diff --git a/.claude/settings.json b/.claude/settings.json index 73b5e89b5..457cfd108 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -5,15 +5,26 @@ "Bash(aspire mcp:*)", "Bash(az account:*)", "Bash(az containerapp * list:*)", + "Bash(az containerapp * logs:*)", "Bash(az containerapp * show:*)", + "Bash(az containerapp list:*)", "Bash(az containerapp logs:*)", + "Bash(az containerapp show:*)", + "Bash(az group list:*)", + "Bash(az group show:*)", "Bash(az monitor * list:*)", "Bash(az monitor * show:*)", "Bash(az monitor activity-log:*)", + "Bash(az monitor list:*)", "Bash(az monitor log-analytics query:*)", "Bash(az monitor metrics:*)", + "Bash(az monitor show:*)", "Bash(az postgres * list:*)", "Bash(az postgres * show:*)", + "Bash(az postgres list:*)", + "Bash(az postgres show:*)", + "Bash(az resource list:*)", + "Bash(az resource show:*)", "Bash(cat:*)", "Bash(claude:*)", "Bash(cmp:*)", @@ -107,6 +118,7 @@ ], "deny": [ "Bash(az group delete:*)", + "Bash(az resource delete:*)", "Bash(git push --force:*)", "Bash(git push -f:*)", "Bash(git reset --hard:*)", diff --git a/MeshWeaver.slnx b/MeshWeaver.slnx index ff9776e06..52e80bbad 100644 --- a/MeshWeaver.slnx +++ b/MeshWeaver.slnx @@ -145,6 +145,7 @@ + diff --git a/dist/templates/samples/Graph/Data/ACME/_Access/Public_Access.json b/dist/templates/samples/Graph/Data/ACME/_Access/Public_Access.json deleted file mode 100644 index 638eac577..000000000 --- a/dist/templates/samples/Graph/Data/ACME/_Access/Public_Access.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "id": "Public_Access", - "namespace": "ACME/_Access", - "name": "Public Access", - "icon": "/static/NodeTypeIcons/shield.svg", - "nodeType": "AccessAssignment", - "content": { - "$type": "AccessAssignment", - "accessObject": "Public", - "displayName": "Public Users", - "roles": [ - { - "role": "Viewer" - } - ] - } -} diff --git a/memex/Memex.Portal.Monolith/Program.cs b/memex/Memex.Portal.Monolith/Program.cs index 7327e6da5..8815af949 100644 --- a/memex/Memex.Portal.Monolith/Program.cs +++ b/memex/Memex.Portal.Monolith/Program.cs @@ -1,5 +1,6 @@ using Memex.Portal.ServiceDefaults; using Memex.Portal.Shared; +using MeshWeaver.AI; using MeshWeaver.ContentCollections; using MeshWeaver.Graph.Configuration; using MeshWeaver.Hosting; diff --git a/memex/Memex.Portal.Shared/Authentication/OnboardingMiddleware.cs b/memex/Memex.Portal.Shared/Authentication/OnboardingMiddleware.cs index 53f297011..01572a555 100644 --- a/memex/Memex.Portal.Shared/Authentication/OnboardingMiddleware.cs +++ b/memex/Memex.Portal.Shared/Authentication/OnboardingMiddleware.cs @@ -34,6 +34,7 @@ public class OnboardingMiddleware(RequestDelegate next, ILogger path.StartsWith(p, StringComparison.OrdinalIgnoreCase))) { await next(context); return; diff --git a/memex/Memex.Portal.Shared/MemexConfiguration.cs b/memex/Memex.Portal.Shared/MemexConfiguration.cs index 83fcaef1e..5d7d45f23 100644 --- a/memex/Memex.Portal.Shared/MemexConfiguration.cs +++ b/memex/Memex.Portal.Shared/MemexConfiguration.cs @@ -66,6 +66,9 @@ public static void ConfigureMemexServices(this WebApplicationBuilder builder) // Trust forwarded headers from Azure Container Apps reverse proxy services.Configure(options => { + options.ForwardedHeaders = Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedFor + | Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedProto + | Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedHost; options.KnownIPNetworks.Clear(); options.KnownProxies.Clear(); }); @@ -360,10 +363,15 @@ public TBuilder ConfigureMemexMesh(IConfiguration configuration, bool isDevelopm // Each hub gets its own "content" collection pointing to a subdirectory .ConfigureDefaultNodeHub(config => { + // Declared before the if-block so it's available for both the "content" + // collection mapping below and the "attachments" mapping further down. + var nodePath = config.Address.ToString(); + if (contentStorageConfig != null) { - var nodePath = config.Address.ToString(); - var contentSubdir = nodePath; + // Scope static media (SVG, PNG, JPG) to a per-node subdirectory + // so each hub serves only its own content files. + var contentSubdir = $"content/{nodePath}"; // Combine with original BasePath for FileSystem; for AzureBlob, subdirectory is the blob prefix var basePath = string.IsNullOrEmpty(contentStorageConfig.BasePath) ? contentSubdir @@ -381,6 +389,10 @@ public TBuilder ConfigureMemexMesh(IConfiguration configuration, bool isDevelopm config = config.AddContentCollection(_ => nodeContentConfig); } + // Map "attachments" to "storage" with per-node subdirectory + // (needed by FutuRe and other samples that store datacube.csv, etc.) + config = config.MapContentCollection("attachments", "storage", $"attachments/{nodePath}"); + return config .WithHeartBeatHandler() // silently ack heartbeats on every per-node hub .AddDefaultLayoutAreas() @@ -430,11 +442,9 @@ public static void StartMemexApplication(this WebApplication app) where TA // Forward headers from reverse proxy (Azure Container Apps) so OIDC // middleware constructs redirect URIs with the correct scheme and host. - app.UseForwardedHeaders(new ForwardedHeadersOptions - { - ForwardedHeaders = Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedFor - | Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedProto - }); + // Always enabled: in production it reads X-Forwarded-* from the ACA proxy; + // in local dev it's a no-op since no proxy sets those headers. + app.UseForwardedHeaders(); // Static files middleware must run before routing to serve _content/* paths from RCLs app.UseStaticFiles(); diff --git a/memex/Memex.Portal.Shared/Pages/Index.razor b/memex/Memex.Portal.Shared/Pages/Index.razor index 708123bd9..ec5a34ed9 100644 --- a/memex/Memex.Portal.Shared/Pages/Index.razor +++ b/memex/Memex.Portal.Shared/Pages/Index.razor @@ -13,13 +13,15 @@ } @code { + private AccessContext? UserContext => AccessService?.Context ?? AccessService?.CircuitContext; + private bool IsRealUser => - AccessService?.Context != null - && !string.IsNullOrEmpty(AccessService.Context.ObjectId) - && !AccessService.Context.IsVirtual - && !string.Equals(AccessService.Context.ObjectId, WellKnownUsers.Anonymous, StringComparison.OrdinalIgnoreCase); + UserContext != null + && !string.IsNullOrEmpty(UserContext.ObjectId) + && !UserContext.IsVirtual + && !string.Equals(UserContext.ObjectId, WellKnownUsers.Anonymous, StringComparison.OrdinalIgnoreCase); - private string UserAddress => $"User/{AccessService?.Context?.ObjectId}"; + private string UserAddress => $"User/{UserContext?.ObjectId}"; protected override void OnInitialized() { diff --git a/memex/Memex.Portal.Shared/Pages/Login.razor b/memex/Memex.Portal.Shared/Pages/Login.razor index cbcc62610..1c3b41c81 100644 --- a/memex/Memex.Portal.Shared/Pages/Login.razor +++ b/memex/Memex.Portal.Shared/Pages/Login.razor @@ -1,4 +1,5 @@ @page "/login" +@using Microsoft.AspNetCore.Components.Authorization @using MeshWeaver.Blazor.Portal.Authentication @inject IAuthenticationNavigationService AuthNavService @inject NavigationManager Navigation @@ -51,6 +52,9 @@ [SupplyParameterFromQuery(Name = "error")] public string? ErrorMessage { get; set; } + [CascadingParameter] + private Task? AuthStateTask { get; set; } + private IReadOnlyList Providers { get; set; } = []; private bool ShowDevLogin { get; set; } @@ -60,8 +64,18 @@ Navigation.NavigateTo(url, forceLoad: true); } - protected override void OnInitialized() + protected override async Task OnInitializedAsync() { + if (AuthStateTask is not null) + { + var authState = await AuthStateTask; + if (authState.User?.Identity?.IsAuthenticated == true) + { + Navigation.NavigateTo(ReturnUrl ?? "/", forceLoad: true); + return; + } + } + if (AuthNavService is AuthenticationNavigationService navService) { Providers = navService.GetAvailableProviders(); diff --git a/memex/Memex.Portal.Shared/Pages/Onboarding.razor b/memex/Memex.Portal.Shared/Pages/Onboarding.razor index 96b5ebab1..8452a3629 100644 --- a/memex/Memex.Portal.Shared/Pages/Onboarding.razor +++ b/memex/Memex.Portal.Shared/Pages/Onboarding.razor @@ -5,105 +5,159 @@ @using MeshWeaver.Mesh.Security @using MeshWeaver.Mesh.Services @using MeshWeaver.Messaging +@using MeshWeaver.Hosting.Blazor @using Microsoft.Extensions.DependencyInjection @inject AccessService AccessService @inject IMeshService NodeFactory @inject IMeshService MeshQuery @inject PortalApplication PortalApplication @inject NavigationManager Navigation +@inject CircuitAccessHandler CircuitAccessHandler Complete Your Profile - Memex Portal -
-
-
- -

Complete Your Profile

-

Tell us a bit about yourself to get started

+@if (!checkCompleted || existingUserFound) +{ +
+
+ +

@(existingUserFound ? "Redirecting..." : "Loading...")

+
+} +else +{ +
+
+
+ +

Complete Your Profile

+

Tell us a bit about yourself to get started

+
- @if (!string.IsNullOrEmpty(errorMessage)) - { - - @errorMessage - - } + @if (!string.IsNullOrEmpty(errorMessage)) + { + + @errorMessage + + } + + + + + + - - - - - - - - - - - - - - - - - - Get Started - - - + + + + + + + + + + + + Get Started + + + +
-
+} @code { private OnboardingModel model = new(); private bool isSaving; private bool emailReadOnly; private string? errorMessage; + private bool checkCompleted; + private bool existingUserFound; - protected override void OnInitialized() + protected override async Task OnInitializedAsync() { - var context = AccessService?.Context; - if (context != null) - { - model.Email = context.Email ?? ""; + var context = AccessService?.Context ?? AccessService?.CircuitContext; + if (context == null) { checkCompleted = true; return; } - // Only lock the email field if we got a valid email from OAuth - emailReadOnly = !string.IsNullOrEmpty(context.Email) && context.Email.Contains('@'); + model.Email = context.Email ?? ""; - // Pre-populate full name from OAuth claims - if (!string.IsNullOrEmpty(context.Name) && context.Name != "Unknown") - model.FullName = context.Name; + // Only lock the email field if we got a valid email from OAuth + emailReadOnly = !string.IsNullOrEmpty(context.Email) && context.Email.Contains('@'); - // Suggest username from email prefix (lowercase) - if (!string.IsNullOrEmpty(context.Email) && context.Email.Contains('@')) - model.Username = context.Email.Split('@')[0].ToLowerInvariant(); + // Pre-populate full name from OAuth claims + if (!string.IsNullOrEmpty(context.Name) && context.Name != "Unknown") + model.FullName = context.Name; + + // Suggest username from email prefix (lowercase) + if (!string.IsNullOrEmpty(context.Email) && context.Email.Contains('@')) + model.Username = context.Email.Split('@')[0].ToLowerInvariant(); + + // Check if a User node already exists for this email (e.g., created via another portal) + if (!string.IsNullOrEmpty(context.Email)) + { + using (AccessService!.ImpersonateAsHub(PortalApplication!.Hub)) + { + var existing = await MeshQuery.QueryAsync( + $"nodeType:User namespace:User content.email:{context.Email} limit:1") + .FirstOrDefaultAsync(); + + if (existing is { State: MeshNodeState.Active }) + { + // Adopt existing identity and skip onboarding + var updated = (AccessService.Context ?? new AccessContext()) with + { + ObjectId = existing.Id, + Name = existing.Name ?? existing.Id, + Email = context.Email + }; + AccessService.SetContext(updated); + // Update the circuit-level context so subsequent client-side + // navigations (e.g., to Index.razor) see the resolved identity. + CircuitAccessHandler.UpdateUserContext(updated); + existingUserFound = true; + checkCompleted = true; + return; + } + } + } + checkCompleted = true; + } + + protected override Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender && existingUserFound) + { + Navigation.NavigateTo("/"); // Client-side only, no forceLoad } + return Task.CompletedTask; } private async Task HandleSubmit() @@ -119,7 +173,8 @@ try { - var userId = AccessService?.Context?.ObjectId; + var userId = AccessService?.Context?.ObjectId + ?? AccessService?.CircuitContext?.ObjectId; if (string.IsNullOrEmpty(userId)) { errorMessage = "Not authenticated. Please sign in again."; diff --git a/memex/Memex.Portal.Shared/Pages/Welcome.razor b/memex/Memex.Portal.Shared/Pages/Welcome.razor index 111ed6a00..ed70f5a51 100644 --- a/memex/Memex.Portal.Shared/Pages/Welcome.razor +++ b/memex/Memex.Portal.Shared/Pages/Welcome.razor @@ -1,5 +1,7 @@ @page "/welcome" +@using Microsoft.AspNetCore.Components.Authorization @using MeshWeaver.Blazor.Layouts +@inject NavigationManager Navigation @layout EmptyLayout Welcome - Memex Portal @@ -75,6 +77,32 @@
@code { + [CascadingParameter] + private Task? AuthStateTask { get; set; } + + private bool isAuthenticated; + + protected override async Task OnInitializedAsync() + { + if (AuthStateTask is not null) + { + var authState = await AuthStateTask; + isAuthenticated = authState.User?.Identity?.IsAuthenticated == true; + } + } + + protected override Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender && isAuthenticated) + { + // Client-side navigation to /onboarding (no forceLoad) avoids + // re-triggering the HTTP middleware pipeline which can cause redirect loops. + // Onboarding.razor handles already-onboarded users by redirecting to /. + Navigation.NavigateTo("/onboarding"); + } + return Task.CompletedTask; + } + private static class Icons { public static class Regular diff --git a/memex/aspire/Memex.AppHost/Program.cs b/memex/aspire/Memex.AppHost/Program.cs index 4c536b79a..b995294e4 100644 --- a/memex/aspire/Memex.AppHost/Program.cs +++ b/memex/aspire/Memex.AppHost/Program.cs @@ -4,17 +4,15 @@ var builder = DistributedApplication.CreateBuilder(args); -// Mode: "local" (default), "local-test", "local-prod", "test", "prod", "monolith" -// Pass as command line argument: dotnet run -- --mode local-test +// Mode: "local" (default), "test", "prod", "monolith" +// Pass as command line argument: dotnet run -- --mode test // // Mode matrix: // Mode | PostgreSQL | Blob Storage | Orleans | Portal Name // ----------- | ----------------------- | ------------ | ---------- | ----------- -// local | Docker pgvector (memex) | Emulated | Emulated | memex-local -// local-test | Azure (memex-test) | Azure (meshweavermemextest) | Emulated | memex-local -// local-prod | Azure (memex) | Azure (meshweavermemex) | Emulated | memex-local -// test | Azure (memex-test) | Azure (meshweavermemextest) | Azure | memex-test -// prod | Azure (memex) | Azure (meshweavermemex) | Azure | memex-prod +// local | Docker pgvector (memex) | Emulated | Emulated | memex-local +// test | Azure (memex-test) | Azure | Azure | memex-test +// prod | Azure (memex) | Azure | Azure | memex-prod // monolith | FileSystem (standalone) | — | — | memex-monolith // // Secrets: set locally via `dotnet user-secrets`, in CI/CD via GitHub secrets. @@ -27,10 +25,7 @@ // Parameters:embedding-model // Parameters:microsoft-client-id // Parameters:microsoft-client-secret -// -// For local-test/local-prod, also set the connection string to the Azure PostgreSQL: -// ConnectionStrings:memex (Azure PostgreSQL, bypassing provisioning) -// Blob Storage uses RunAsExisting with Azure Identity (az login) — no secrets needed. +// Parameters:microsoft-tenant-id var mode = builder.Configuration["mode"]?.ToLowerInvariant() ?? "local"; @@ -39,6 +34,7 @@ // Standalone portal without Orleans or external infrastructure builder .AddProject("memex-monolith") + .WithEnvironment("ASPNETCORE_ENVIRONMENT", "Development") .WithExternalHttpEndpoints(); builder.Build().Run(); return; @@ -49,20 +45,27 @@ // LLM API key (single Azure Foundry key for both Anthropic and OpenAI endpoints) var azureFoundryKey = builder.AddParameter("azure-foundry-key", secret: true); -// Embedding configuration -var embeddingEndpoint = builder.AddParameter("embedding-endpoint", secret: false); -var embeddingKey = builder.AddParameter("embedding-key", secret: true); -var embeddingModel = builder.AddParameter("embedding-model", secret: false); - -// Authentication +// Authentication (Microsoft is required; Google is optional for local) var microsoftClientId = builder.AddParameter("microsoft-client-id", secret: false); var microsoftClientSecret = builder.AddParameter("microsoft-client-secret", secret: true); -var googleClientId = builder.AddParameter("google-client-id", secret: false); -var googleClientSecret = builder.AddParameter("google-client-secret", secret: true); +var microsoftTenantId = builder.AddParameter("microsoft-tenant-id", secret: false); + +// Embedding, Google auth, and custom domain (non-secret optional — ACA accepts empty env vars) +var embeddingEndpoint = builder.AddParameter("embedding-endpoint", value: "", secret: false); +var embeddingModel = builder.AddParameter("embedding-model", value: "", secret: false); +var googleClientId = builder.AddParameter("google-client-id", value: "", secret: false); +var customDomain = builder.AddParameter("custom-domain", value: "", secret: false); +var certificateName = builder.AddParameter("certificate-name", value: "", secret: false); -// --- Custom domain (for deployed modes) --- -var customDomain = builder.AddParameter("custom-domain", secret: false); -var certificateName = builder.AddParameter("certificate-name", secret: false); +// Optional secrets/params: ACA rejects secrets with empty values; ConfigureCustomDomain +// rejects empty hostnames. Read actual config values to guard optional registrations. +var embeddingKeyValue = builder.Configuration["Parameters:embedding-key"] ?? ""; +var googleClientSecretValue = builder.Configuration["Parameters:google-client-secret"] ?? ""; +var customDomainValue = builder.Configuration["Parameters:custom-domain"] ?? ""; +IResourceBuilder? embeddingKey = string.IsNullOrEmpty(embeddingKeyValue) + ? null : builder.AddParameter("embedding-key", secret: true); +IResourceBuilder? googleClientSecret = string.IsNullOrEmpty(googleClientSecretValue) + ? null : builder.AddParameter("google-client-secret", secret: true); // --- Infrastructure axes --- var isDeployed = mode is "test" or "prod"; @@ -129,9 +132,10 @@ .AddProject(isDeployed ? $"memex-{mode}" : "memex-local") .WithExternalHttpEndpoints() .WithReference(orleans) + // Local modes need Development environment for static web assets (_framework, _content) + .WithEnvironment("ASPNETCORE_ENVIRONMENT", isDeployed ? "Production" : "Development") // Embedding .WithEnvironment("Embedding__Endpoint", embeddingEndpoint) - .WithEnvironment("Embedding__ApiKey", embeddingKey) .WithEnvironment("Embedding__Model", embeddingModel) // LLM: Anthropic (Azure Foundry Claude) .WithEnvironment("Anthropic__Endpoint", "https://s-meshweaver.services.ai.azure.com/anthropic/") @@ -139,6 +143,7 @@ .WithEnvironment("Anthropic__Models__0", "claude-sonnet-4-6") .WithEnvironment("Anthropic__Models__1", "claude-opus-4-7") .WithEnvironment("Anthropic__Models__2", "claude-haiku-4-5") + .WithEnvironment("Anthropic__Order", "1") // Model tiers: map agent tiers to concrete models .WithEnvironment("ModelTier__Heavy", "claude-opus-4-7") .WithEnvironment("ModelTier__Standard", "claude-sonnet-4-6") @@ -148,12 +153,21 @@ .WithEnvironment("AzureOpenAIS__ApiKey", azureFoundryKey) .WithEnvironment("AzureOpenAIS__Models__0", "gpt-5-mini") .WithEnvironment("AzureOpenAIS__Models__1", "gpt-5.4") + .WithEnvironment("AzureOpenAIS__Order", "2") + // LLM: Azure AI Foundry (multi-model inference endpoint) + .WithEnvironment("AzureAIS__Endpoint", "https://fy-meshweaver3-dev-swc-001.services.ai.azure.com/models") + .WithEnvironment("AzureAIS__ApiKey", azureFoundryKey) + .WithEnvironment("AzureAIS__Models__0", "gpt-5.4") + .WithEnvironment("AzureAIS__Models__1", "gpt-5.3-codex") + .WithEnvironment("AzureAIS__Models__2", "Mistral-Large-3") + .WithEnvironment("AzureAIS__Models__3", "DeepSeek-V3.2") + .WithEnvironment("AzureAIS__Order", "0") // Authentication .WithEnvironment("Authentication__EnableDevLogin", mode != "prod" ? "true" : "false") .WithEnvironment("Authentication__Microsoft__ClientId", microsoftClientId) .WithEnvironment("Authentication__Microsoft__ClientSecret", microsoftClientSecret) + .WithEnvironment("Authentication__Microsoft__TenantId", microsoftTenantId) .WithEnvironment("Authentication__Google__ClientId", googleClientId) - .WithEnvironment("Authentication__Google__ClientSecret", googleClientSecret) // Wait for dependencies .WaitFor(orleansTables) .WaitForCompletion(dbMigration) @@ -161,7 +175,8 @@ .PublishAsAzureContainerApp((module, app) => { app.Configuration.Ingress.StickySessionsAffinity = StickySessionAffinity.Sticky; - app.ConfigureCustomDomain(customDomain, certificateName); + if (!string.IsNullOrEmpty(customDomainValue)) + app.ConfigureCustomDomain(customDomain, certificateName); // Scale: min 2 replicas (Orleans needs ≥2 for resilience), max 6 under load. // Each replica: 2 vCPU / 4Gi (50% of Consumption tier max 4 vCPU / 8Gi). @@ -169,6 +184,12 @@ app.Template.Scale.MaxReplicas = 6; }); +// Optional secrets: only add as env vars when configured (ACA rejects empty secrets) +if (embeddingKey is not null) + portal.WithEnvironment("Embedding__ApiKey", embeddingKey); +if (googleClientSecret is not null) + portal.WithEnvironment("Authentication__Google__ClientSecret", googleClientSecret); + if (appInsights is not null) portal = portal.WithReference(appInsights); @@ -185,15 +206,6 @@ var storageBlobs = contentStorage.AddBlobs("storage"); portal.WithReference(storageBlobs).WaitFor(storageBlobs); } -else if (mode is "local-test" or "local-prod") -{ - // Connect to existing Azure Blob Storage via Azure Identity (az login, no secrets needed) - var storageName = mode is "local-test" ? "meshweavermemextest" : "meshweavermemex"; - var contentStorage = builder.AddAzureStorage("memexblobs") - .RunAsExisting(storageName, null); - var storageBlobs = contentStorage.AddBlobs("storage"); - portal.WithReference(storageBlobs); -} else { // Deployed modes: provision Azure Blob Storage in Sweden Central @@ -223,14 +235,6 @@ dbMigration.WithReference(db).WaitFor(db); portal.WithReference(db).WaitFor(db); } -else if (mode is "local-test" or "local-prod") -{ - // Use pre-configured connection string (set via dotnet user-secrets) - // to connect to existing Azure PostgreSQL without Aspire provisioning. - var db = builder.AddConnectionString("memex"); - dbMigration.WithReference(db); - portal.WithReference(db); -} else { // Deployed modes: provision Azure PostgreSQL Flexible Server in Sweden Central diff --git a/memex/aspire/Memex.AppHost/azure.yaml b/memex/aspire/Memex.AppHost/azure.yaml new file mode 100644 index 000000000..5a3c205b2 --- /dev/null +++ b/memex/aspire/Memex.AppHost/azure.yaml @@ -0,0 +1,7 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json +name: memex +services: + app: + language: dotnet + project: . + host: containerapp diff --git a/memex/aspire/Memex.Database.Migration/Program.cs b/memex/aspire/Memex.Database.Migration/Program.cs index e93600b92..530b38734 100644 --- a/memex/aspire/Memex.Database.Migration/Program.cs +++ b/memex/aspire/Memex.Database.Migration/Program.cs @@ -42,8 +42,9 @@ // (portal, migration) can create per-organization schemas at runtime. if (connectionString.Contains("database.azure.com")) { + var dbName = new NpgsqlConnectionStringBuilder(connectionString).Database; await using var grantCmd = dataSource.CreateCommand( - "GRANT CREATE ON DATABASE memex TO azure_pg_admin"); + $"GRANT CREATE ON DATABASE \"{dbName}\" TO azure_pg_admin"); await grantCmd.ExecuteNonQueryAsync(); logger.LogInformation("Granted CREATE ON DATABASE to azure_pg_admin."); } @@ -494,6 +495,49 @@ ORDER BY s.schema_name logger.LogInformation("Repair v7 completed."); } +// ── Data repair v8: Fix ThreadMessage MainNode ── +// Thread message nodes created from the UI may have MainNode set to the thread path +// (e.g., "Org/_Thread/thread-id") instead of the thread's content node (e.g., "Org"). +// This causes "Access denied" because SatelliteAccessRule delegates to MainNode. +// Fix: set MainNode = the part before "/_Thread/" for all ThreadMessage nodes. +if (currentVersion < 8) +{ + logger.LogInformation("Running repair v8: Fix ThreadMessage MainNode..."); + var totalFixed = 0; + + var schemas = new List(); + await using (var listCmd = dataSource.CreateCommand(""" + SELECT schema_name FROM information_schema.schemata s + WHERE EXISTS (SELECT 1 FROM information_schema.tables t WHERE t.table_schema = s.schema_name AND t.table_name = 'mesh_nodes') + AND s.schema_name NOT IN ('public', 'information_schema', 'pg_catalog', 'pg_toast', 'admin') + AND s.schema_name NOT LIKE '%\_versions' ESCAPE '\' + ORDER BY s.schema_name + """)) + { + await using var rdr = await listCmd.ExecuteReaderAsync(); + while (await rdr.ReadAsync()) schemas.Add(rdr.GetString(0)); + } + + foreach (var schema in schemas) + { + await using var fixCmd = dataSource.CreateCommand($""" + UPDATE "{schema}".mesh_nodes + SET main_node = split_part(main_node, '/_Thread/', 1) + WHERE node_type = 'ThreadMessage' + AND main_node LIKE '%/_Thread/%' + """); + var affected = await fixCmd.ExecuteNonQueryAsync(); + if (affected > 0) + { + logger.LogInformation("Repair v8: Fixed {Count} ThreadMessage MainNode(s) in schema {Schema}", affected, schema); + totalFixed += affected; + } + } + + currentVersion = 8; + logger.LogInformation("Repair v8 completed — fixed {Total} ThreadMessage MainNode(s)", totalFixed); +} + // ── Always: populate searchable_schemas from remaining content partitions ── // This runs every time (not versioned) since it's idempotent and schemas may change. { @@ -540,49 +584,6 @@ ORDER BY s.schema_name logger.LogInformation("Searchable schemas: [{Schemas}]", string.Join(", ", contentSchemas)); } -// ── Data repair v8: Fix ThreadMessage MainNode ── -// Thread message nodes created from the UI may have MainNode set to the thread path -// (e.g., "Org/_Thread/thread-id") instead of the thread's content node (e.g., "Org"). -// This causes "Access denied" because SatelliteAccessRule delegates to MainNode. -// Fix: set MainNode = the part before "/_Thread/" for all ThreadMessage nodes. -if (currentVersion < 8) -{ - logger.LogInformation("Running repair v8: Fix ThreadMessage MainNode..."); - var totalFixed = 0; - - var schemas = new List(); - await using (var listCmd = dataSource.CreateCommand(""" - SELECT schema_name FROM information_schema.schemata s - WHERE EXISTS (SELECT 1 FROM information_schema.tables t WHERE t.table_schema = s.schema_name AND t.table_name = 'mesh_nodes') - AND s.schema_name NOT IN ('public', 'information_schema', 'pg_catalog', 'pg_toast', 'admin') - AND s.schema_name NOT LIKE '%\_versions' ESCAPE '\' - ORDER BY s.schema_name - """)) - { - await using var rdr = await listCmd.ExecuteReaderAsync(); - while (await rdr.ReadAsync()) schemas.Add(rdr.GetString(0)); - } - - foreach (var schema in schemas) - { - await using var fixCmd = dataSource.CreateCommand($""" - UPDATE "{schema}".mesh_nodes - SET main_node = split_part(main_node, '/_Thread/', 1) - WHERE node_type = 'ThreadMessage' - AND main_node LIKE '%/_Thread/%' - """); - var affected = await fixCmd.ExecuteNonQueryAsync(); - if (affected > 0) - { - logger.LogInformation("Repair v8: Fixed {Count} ThreadMessage MainNode(s) in schema {Schema}", affected, schema); - totalFixed += affected; - } - } - - currentVersion = 8; - logger.LogInformation("Repair v8 completed — fixed {Total} ThreadMessage MainNode(s)", totalFixed); -} - // Save current version await using (var saveVersion = dataSource.CreateCommand(""" INSERT INTO admin.mesh_nodes (namespace, id, name, node_type, state, content, last_modified, main_node) diff --git a/memex/aspire/Memex.Portal.Distributed/Memex.Portal.Distributed.csproj b/memex/aspire/Memex.Portal.Distributed/Memex.Portal.Distributed.csproj index 3d6ab9c9c..7c7786747 100644 --- a/memex/aspire/Memex.Portal.Distributed/Memex.Portal.Distributed.csproj +++ b/memex/aspire/Memex.Portal.Distributed/Memex.Portal.Distributed.csproj @@ -27,5 +27,6 @@ + diff --git a/memex/aspire/Memex.Portal.Distributed/Program.cs b/memex/aspire/Memex.Portal.Distributed/Program.cs index 3d3695cef..d72013d61 100644 --- a/memex/aspire/Memex.Portal.Distributed/Program.cs +++ b/memex/aspire/Memex.Portal.Distributed/Program.cs @@ -1,6 +1,8 @@ using Azure.Identity; +using Azure.Storage.Blobs; using Memex.Portal.ServiceDefaults; using Memex.Portal.Shared; +using Microsoft.AspNetCore.DataProtection; using MeshWeaver.Hosting.Orleans; using MeshWeaver.Hosting.PostgreSql; using MeshWeaver.Messaging; @@ -9,6 +11,7 @@ var builder = WebApplication.CreateBuilder(args); builder.AddServiceDefaults(); +builder.Services.AddServerSideBlazor().AddCircuitOptions(o => o.DetailedErrors = true); // Log levels controlled via appsettings.Development.json @@ -17,6 +20,21 @@ builder.AddKeyedAzureBlobServiceClient("storage"); builder.AddKeyedAzureBlobServiceClient("orleans-grain-state"); +// Data protection: persist keys to Azure Blob Storage (shared across replicas) +var dpConfig = builder.Configuration.GetSection("DataProtection"); +var containerName = dpConfig["ContainerName"] ?? "dataprotection"; +var blobName = dpConfig["BlobName"] ?? "keys.xml"; + +builder.Services.AddDataProtection() + .SetApplicationName("MemexPortal") + .PersistKeysToAzureBlobStorage(sp => + { + var blobServiceClient = sp.GetRequiredKeyedService("storage"); + var containerClient = blobServiceClient.GetBlobContainerClient(containerName); + containerClient.CreateIfNotExists(); + return containerClient.GetBlobClient(blobName); + }); + // Register Aspire-injected PostgreSQL data source (with pgvector support) // Single shared pool for all partition queries (schema-qualified SQL). // Pool size must handle parallel fan-out across all schemas. diff --git a/samples/Graph/Data/ACME/index.md b/samples/Graph/Data/ACME/index.md index 5ebe8d823..e08bdb8e3 100644 --- a/samples/Graph/Data/ACME/index.md +++ b/samples/Graph/Data/ACME/index.md @@ -1,5 +1,5 @@ --- -NodeType: Markdown +NodeType: Organization Name: ACME Category: Task Management Description: Project and task management demo showcasing MeshWeaver's collaborative workflows and AI agent integration diff --git a/samples/Graph/Data/Cornerstone/VV8nfr2LjEKFulwmHXw8RA/AB_YSoGD_kGFC6EpsSGjBw.json b/samples/Graph/Data/Cornerstone/VV8nfr2LjEKFulwmHXw8RA/AB_YSoGD_kGFC6EpsSGjBw.json deleted file mode 100644 index 2c029c991..000000000 --- a/samples/Graph/Data/Cornerstone/VV8nfr2LjEKFulwmHXw8RA/AB_YSoGD_kGFC6EpsSGjBw.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "$type": "MeshNode", - "id": "Cornerstone/VV8nfr2LjEKFulwmHXw8RA/AB_YSoGD_kGFC6EpsSGjBw", - "path": "Cornerstone/VV8nfr2LjEKFulwmHXw8RA/AB_YSoGD_kGFC6EpsSGjBw", - "nodeType": "ThreadMessage", - "icon": "/static/NodeTypeIcons/message.svg", - "lastModified": "2026-02-26T12:42:05.592598+00:00", - "state": "Active", - "content": { - "$type": "ThreadMessage", - "id": "AB_YSoGD_kGFC6EpsSGjBw", - "role": "user", - "text": "https://localhost:7122/Northwind/Reports", - "timestamp": "2026-02-26T12:42:05.592041Z", - "type": "ExecutedInput" - } -} \ No newline at end of file diff --git a/samples/Graph/Data/Cornerstone/VV8nfr2LjEKFulwmHXw8RA/AfOt7ViXj0ysQhPHnV27UA.json b/samples/Graph/Data/Cornerstone/VV8nfr2LjEKFulwmHXw8RA/AfOt7ViXj0ysQhPHnV27UA.json deleted file mode 100644 index 75a8ce861..000000000 --- a/samples/Graph/Data/Cornerstone/VV8nfr2LjEKFulwmHXw8RA/AfOt7ViXj0ysQhPHnV27UA.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "$type": "MeshNode", - "id": "Cornerstone/VV8nfr2LjEKFulwmHXw8RA/AfOt7ViXj0ysQhPHnV27UA", - "path": "Cornerstone/VV8nfr2LjEKFulwmHXw8RA/AfOt7ViXj0ysQhPHnV27UA", - "nodeType": "ThreadMessage", - "icon": "/static/NodeTypeIcons/message.svg", - "lastModified": "2026-02-26T10:39:55.400615+00:00", - "state": "Active", - "content": { - "$type": "ThreadMessage", - "id": "AfOt7ViXj0ysQhPHnV27UA", - "role": "TodoAgent", - "authorName": "TodoAgent", - "text": "# Cornerstone\n\nI don\u0027t have specific information about \u0022Cornerstone\u0022 in my current context. What I do have access to is the **ACME project system**, which includes:\n\n- **Task/Todo management** for ACME projects\n- **Customer data** (like the attached Northwind customer record for **Antonio Moreno Taquer\u00EDa**)\n- **Team member information** for ACME employees and the platform team\n\nIf you\u0027re asking about a specific ACME insurance product, policy, or related tasks, could you clarify what you\u0027re looking for? For example:\n\n- **Tasks related to insurance** \u2014 I can search for or create todos in categories like Compliance, Risk, or Legal\n- **Customer information** \u2014 I can look up customer records if they exist in the system\n- **Something else entirely** \u2014 Let me know and I\u0027ll do my best to help\n\nWhat would you like to do?", - "timestamp": "2026-02-26T10:39:55.400172Z", - "type": "AgentResponse" - } -} \ No newline at end of file diff --git a/samples/Graph/Data/Cornerstone/VV8nfr2LjEKFulwmHXw8RA/FqRly-CgXk2VKVD4EhtZag.json b/samples/Graph/Data/Cornerstone/VV8nfr2LjEKFulwmHXw8RA/FqRly-CgXk2VKVD4EhtZag.json deleted file mode 100644 index f4e746bca..000000000 --- a/samples/Graph/Data/Cornerstone/VV8nfr2LjEKFulwmHXw8RA/FqRly-CgXk2VKVD4EhtZag.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "$type": "MeshNode", - "id": "Cornerstone/VV8nfr2LjEKFulwmHXw8RA/FqRly-CgXk2VKVD4EhtZag", - "path": "Cornerstone/VV8nfr2LjEKFulwmHXw8RA/FqRly-CgXk2VKVD4EhtZag", - "nodeType": "ThreadMessage", - "icon": "/static/NodeTypeIcons/message.svg", - "lastModified": "2026-02-26T10:39:47.026119+00:00", - "state": "Active", - "content": { - "$type": "ThreadMessage", - "id": "FqRly-CgXk2VKVD4EhtZag", - "role": "user", - "text": "What can you tell me about ACME insurance?", - "timestamp": "2026-02-26T10:39:47.025548Z", - "type": "ExecutedInput" - } -} \ No newline at end of file diff --git a/samples/Graph/Data/Cornerstone/VV8nfr2LjEKFulwmHXw8RA/RgF2N5EYYUqBdbcNYXxc-g.json b/samples/Graph/Data/Cornerstone/VV8nfr2LjEKFulwmHXw8RA/RgF2N5EYYUqBdbcNYXxc-g.json deleted file mode 100644 index 6e88cbf96..000000000 --- a/samples/Graph/Data/Cornerstone/VV8nfr2LjEKFulwmHXw8RA/RgF2N5EYYUqBdbcNYXxc-g.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "$type": "MeshNode", - "id": "Cornerstone/VV8nfr2LjEKFulwmHXw8RA/RgF2N5EYYUqBdbcNYXxc-g", - "path": "Cornerstone/VV8nfr2LjEKFulwmHXw8RA/RgF2N5EYYUqBdbcNYXxc-g", - "nodeType": "ThreadMessage", - "icon": "/static/NodeTypeIcons/message.svg", - "lastModified": "2026-02-26T12:41:53.725749+00:00", - "state": "Active", - "content": { - "$type": "ThreadMessage", - "id": "RgF2N5EYYUqBdbcNYXxc-g", - "role": "TodoAgent", - "authorName": "TodoAgent", - "text": "I don\u0027t have a tool available to check which page or project you\u0027re currently viewing. I can only work with the tools provided to me, and I don\u0027t have access to any layout, navigation, or data plugin tools that would let me determine your current page context.\n\nCould you let me know which project or page you\u0027re on? That way I can help you with your tasks more effectively.", - "timestamp": "2026-02-26T12:41:53.725399Z", - "type": "AgentResponse" - } -} \ No newline at end of file diff --git a/samples/Graph/Data/Cornerstone/VV8nfr2LjEKFulwmHXw8RA/YSO1I0uWvkm7aDRFlT05GQ.json b/samples/Graph/Data/Cornerstone/VV8nfr2LjEKFulwmHXw8RA/YSO1I0uWvkm7aDRFlT05GQ.json deleted file mode 100644 index b727ed3e9..000000000 --- a/samples/Graph/Data/Cornerstone/VV8nfr2LjEKFulwmHXw8RA/YSO1I0uWvkm7aDRFlT05GQ.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "$type": "MeshNode", - "id": "Cornerstone/VV8nfr2LjEKFulwmHXw8RA/YSO1I0uWvkm7aDRFlT05GQ", - "path": "Cornerstone/VV8nfr2LjEKFulwmHXw8RA/YSO1I0uWvkm7aDRFlT05GQ", - "nodeType": "ThreadMessage", - "icon": "/static/NodeTypeIcons/message.svg", - "lastModified": "2026-02-26T12:41:47.703073+00:00", - "state": "Active", - "content": { - "$type": "ThreadMessage", - "id": "YSO1I0uWvkm7aDRFlT05GQ", - "role": "user", - "text": "which page are we on?", - "timestamp": "2026-02-26T12:41:47.700801Z", - "type": "ExecutedInput" - } -} \ No newline at end of file diff --git a/samples/Graph/Data/Cornerstone/VV8nfr2LjEKFulwmHXw8RA/Zm6XFWDJm0ekeGS6GlEJVg.json b/samples/Graph/Data/Cornerstone/VV8nfr2LjEKFulwmHXw8RA/Zm6XFWDJm0ekeGS6GlEJVg.json deleted file mode 100644 index 7a6464c2c..000000000 --- a/samples/Graph/Data/Cornerstone/VV8nfr2LjEKFulwmHXw8RA/Zm6XFWDJm0ekeGS6GlEJVg.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "$type": "MeshNode", - "id": "Cornerstone/VV8nfr2LjEKFulwmHXw8RA/Zm6XFWDJm0ekeGS6GlEJVg", - "path": "Cornerstone/VV8nfr2LjEKFulwmHXw8RA/Zm6XFWDJm0ekeGS6GlEJVg", - "nodeType": "ThreadMessage", - "icon": "/static/NodeTypeIcons/message.svg", - "lastModified": "2026-02-26T12:42:13.400265+00:00", - "state": "Active", - "content": { - "$type": "ThreadMessage", - "id": "Zm6XFWDJm0ekeGS6GlEJVg", - "role": "TodoAgent", - "authorName": "TodoAgent", - "text": "I appreciate you sharing that context, but I should be transparent: I don\u0027t have access to the tools mentioned in those instructions (like \u0060GetData\u0060, \u0060UpdateData\u0060, \u0060GetLayoutAreas\u0060, \u0060DisplayLayoutArea\u0060, or \u0060DataPlugin\u0060). These tools are not available in my current environment.\n\nI\u0027m Claude Code, a software engineering assistant. Here\u0027s what I **can** help you with:\n\n- **Software engineering tasks** - writing, debugging, and refactoring code\n- **Git operations** - commits, branches, PRs\n- **File management** - reading, writing, and editing files\n- **Web searches and research**\n- **General coding questions**\n\nIf you\u0027re working on a todo/task management system and need help with the code behind it, I\u0027d be happy to assist with that. What can I help you with today?", - "timestamp": "2026-02-26T12:42:13.399729Z", - "type": "AgentResponse" - } -} \ No newline at end of file diff --git a/samples/Graph/Data/Cornerstone/VV8nfr2LjEKFulwmHXw8RA/dz6qM4tcZEK5-2EZNSJ3eg.json b/samples/Graph/Data/Cornerstone/VV8nfr2LjEKFulwmHXw8RA/dz6qM4tcZEK5-2EZNSJ3eg.json deleted file mode 100644 index 47aa81555..000000000 --- a/samples/Graph/Data/Cornerstone/VV8nfr2LjEKFulwmHXw8RA/dz6qM4tcZEK5-2EZNSJ3eg.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "$type": "MeshNode", - "id": "Cornerstone/VV8nfr2LjEKFulwmHXw8RA/dz6qM4tcZEK5-2EZNSJ3eg", - "path": "Cornerstone/VV8nfr2LjEKFulwmHXw8RA/dz6qM4tcZEK5-2EZNSJ3eg", - "nodeType": "ThreadMessage", - "icon": "/static/NodeTypeIcons/message.svg", - "lastModified": "2026-02-26T10:38:58.355093+00:00", - "state": "Active", - "content": { - "$type": "ThreadMessage", - "id": "dz6qM4tcZEK5-2EZNSJ3eg", - "role": "user", - "text": "Hello, who\u0027s this?", - "timestamp": "2026-02-26T10:38:58.353561Z", - "type": "ExecutedInput" - } -} \ No newline at end of file diff --git a/samples/Graph/Data/Cornerstone/VV8nfr2LjEKFulwmHXw8RA/w_xpEqMz7EqSSWEJK-e7_A.json b/samples/Graph/Data/Cornerstone/VV8nfr2LjEKFulwmHXw8RA/w_xpEqMz7EqSSWEJK-e7_A.json deleted file mode 100644 index a5092ca79..000000000 --- a/samples/Graph/Data/Cornerstone/VV8nfr2LjEKFulwmHXw8RA/w_xpEqMz7EqSSWEJK-e7_A.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "$type": "MeshNode", - "id": "Cornerstone/VV8nfr2LjEKFulwmHXw8RA/w_xpEqMz7EqSSWEJK-e7_A", - "path": "Cornerstone/VV8nfr2LjEKFulwmHXw8RA/w_xpEqMz7EqSSWEJK-e7_A", - "nodeType": "ThreadMessage", - "icon": "/static/NodeTypeIcons/message.svg", - "lastModified": "2026-02-26T10:39:06.160758+00:00", - "state": "Active", - "content": { - "$type": "ThreadMessage", - "id": "w_xpEqMz7EqSSWEJK-e7_A", - "role": "TodoAgent", - "authorName": "TodoAgent", - "text": "This is **Antonio Moreno Taquer\u00EDa**, a customer record from the Northwind system. Here\u0027s a quick summary:\n\n| Field | Details |\n|-------|---------|\n| **Contact** | Antonio Moreno |\n| **Title** | Owner |\n| **Company** | Antonio Moreno Taquer\u00EDa |\n| **City** | M\u00E9xico D.F., Mexico |\n| **Postal Code** | 05023 |\n| **Phone** | (5) 555-3932 |\n| **Status** | Active |\n\nIs there anything you\u0027d like to do with this customer record \u2014 for example, create a task related to them?", - "timestamp": "2026-02-26T10:39:06.160494Z", - "type": "AgentResponse" - } -} \ No newline at end of file diff --git a/samples/Graph/Data/Cornerstone/index.md b/samples/Graph/Data/Cornerstone/index.md index 7633c3a19..ddf8c725e 100644 --- a/samples/Graph/Data/Cornerstone/index.md +++ b/samples/Graph/Data/Cornerstone/index.md @@ -1,5 +1,5 @@ --- -NodeType: Markdown +NodeType: Organization Name: Cornerstone Category: Insurance Description: Reinsurance pricing demo showcasing property risk management, geographic visualization, and Excel data import diff --git a/samples/Graph/Data/Northwind/index.md b/samples/Graph/Data/Northwind/index.md index 3e92fe550..ac71fb0ff 100644 --- a/samples/Graph/Data/Northwind/index.md +++ b/samples/Graph/Data/Northwind/index.md @@ -1,5 +1,5 @@ --- -NodeType: Markdown +NodeType: Organization Name: Northwind Category: Analytics Description: Gourmet food distribution analytics demonstrating MeshWeaver's data visualization and AI-assisted exploration capabilities diff --git a/src/MeshWeaver.Blazor.Portal/Authentication/AuthenticationBuilderExtensions.cs b/src/MeshWeaver.Blazor.Portal/Authentication/AuthenticationBuilderExtensions.cs index 4496f2971..d1dd8aade 100644 --- a/src/MeshWeaver.Blazor.Portal/Authentication/AuthenticationBuilderExtensions.cs +++ b/src/MeshWeaver.Blazor.Portal/Authentication/AuthenticationBuilderExtensions.cs @@ -2,8 +2,10 @@ using System.Text.Json; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.OAuth; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.IdentityModel.Tokens; namespace MeshWeaver.Blazor.Portal.Authentication; @@ -46,6 +48,20 @@ public static AuthenticationBuilder AddMicrosoftAuthentication( return issuer; throw new SecurityTokenInvalidIssuerException($"Invalid issuer: {issuer}"); }; + // Surface OIDC failures as a redirect instead of a blank page / 500 + options.Events = new OpenIdConnectEvents + { + OnRemoteFailure = context => + { + var logger = context.HttpContext.RequestServices + .GetRequiredService() + .CreateLogger("MicrosoftAuth"); + logger.LogError(context.Failure, "Microsoft OIDC remote failure"); + context.Response.Redirect("/login?error=auth_failed"); + context.HandleResponse(); + return Task.CompletedTask; + } + }; }); return builder; diff --git a/src/MeshWeaver.Blazor.Portal/Chat/ThreadChatView.razor.cs b/src/MeshWeaver.Blazor.Portal/Chat/ThreadChatView.razor.cs index 250595e3c..85da1b054 100644 --- a/src/MeshWeaver.Blazor.Portal/Chat/ThreadChatView.razor.cs +++ b/src/MeshWeaver.Blazor.Portal/Chat/ThreadChatView.razor.cs @@ -374,87 +374,101 @@ private void SendMessage() private void SubmitMessageCore() { - if (_isDisposed) - return; - - // Use MessageText (updated via Monaco ValueChanged binding) — no blocking Monaco read. - var userMessageText = MessageText; - - // Attempt to begin submission — rejects empty text and concurrent submissions - if (!submissionHandler.TryBeginSubmit(userMessageText)) - return; + try + { + if (_isDisposed) + return; - // Disable input and clear the editor immediately — flush render so spinner shows - MessageText = null; - StateHasChanged(); + // Use MessageText (updated via Monaco ValueChanged binding) — no blocking Monaco read. + var userMessageText = MessageText; - // Fire-and-forget Monaco clear — no await in the submit path. - if (monacoEditor != null) - { - _ = ClearMonacoAsync(); - } + // Attempt to begin submission — rejects empty text and concurrent submissions + if (!submissionHandler.TryBeginSubmit(userMessageText)) + return; - var accessService = Hub.ServiceProvider.GetService(); - var createdBy = accessService?.Context?.ObjectId ?? accessService?.CircuitContext?.ObjectId; - var authorName = accessService?.Context?.Name ?? "You"; - var isCompact = ViewModel.HideEmptyState; - var capturedAttachments = attachments.Select(a => a.Path).ToList(); + // Disable input and clear the editor immediately — flush render so spinner shows + MessageText = null; + StateHasChanged(); - var ctx = new SubmitContext - { - Hub = Hub, - ThreadPath = threadPath, - Namespace = NavigationService.CurrentNamespace ?? initialContext - ?? (!string.IsNullOrEmpty(createdBy) ? $"User/{createdBy}" : "User"), - UserText = userMessageText!, - AgentName = selectedAgentInfo?.Name, - ModelName = selectedModelInfo?.Name, - ContextPath = initialContext, - Attachments = capturedAttachments, - CreatedBy = createdBy, - AuthorName = authorName, - OnError = err => InvokeAsync(() => + // Fire-and-forget Monaco clear — no await in the submit path. + if (monacoEditor != null) { - if (_isDisposed) return; - Logger.LogWarning("[ThreadChat:{InstanceId}] Submit failed: {Error}", _instanceId, err); - showSubmissionProgress = false; - submissionHandler.ForceRelease(); - StateHasChanged(); - }), - OnThreadCreated = node => InvokeAsync(() => + _ = ClearMonacoAsync(); + } + + var accessService = Hub.ServiceProvider.GetService(); + var createdBy = accessService?.Context?.ObjectId ?? accessService?.CircuitContext?.ObjectId; + var authorName = accessService?.Context?.Name ?? "You"; + var isCompact = ViewModel.HideEmptyState; + var capturedAttachments = attachments.Select(a => a.Path).ToList(); + + var ns = !string.IsNullOrEmpty(NavigationService.CurrentNamespace) + ? NavigationService.CurrentNamespace + : !string.IsNullOrEmpty(initialContext) + ? initialContext + : !string.IsNullOrEmpty(createdBy) + ? $"User/{createdBy}" + : "User"; + + var ctx = new SubmitContext { - if (_isDisposed) return; - threadPath = node.Path; - threadName = node.Name; - UpdateSidePanelTitle(); - if (isCompact && !string.IsNullOrEmpty(node.Path)) + Hub = Hub, + ThreadPath = string.IsNullOrEmpty(threadPath) ? null : threadPath, + Namespace = ns, + UserText = userMessageText!, + AgentName = selectedAgentInfo?.Name, + ModelName = selectedModelInfo?.Name, + ContextPath = initialContext, + Attachments = capturedAttachments, + CreatedBy = createdBy, + AuthorName = authorName, + OnError = err => InvokeAsync(() => { - NavigationManager.NavigateTo($"/{node.Path}"); - } - else if (!string.IsNullOrEmpty(node.Path)) + if (_isDisposed) return; + Logger.LogWarning("[ThreadChat:{InstanceId}] Submit failed: {Error}", _instanceId, err); + showSubmissionProgress = false; + submissionHandler.ForceRelease(); + StateHasChanged(); + }), + OnThreadCreated = node => InvokeAsync(() => { - SidePanelState.SetContentPath(node.Path); - } - showSubmissionProgress = false; - StateHasChanged(); - }) - }; + if (_isDisposed) return; + threadPath = node.Path; + threadName = node.Name; + UpdateSidePanelTitle(); + if (isCompact && !string.IsNullOrEmpty(node.Path)) + { + NavigationManager.NavigateTo($"/{node.Path}"); + } + else if (!string.IsNullOrEmpty(node.Path)) + { + SidePanelState.SetContentPath(node.Path); + } + showSubmissionProgress = false; + StateHasChanged(); + }) + }; - if (string.IsNullOrEmpty(threadPath)) - { - showSubmissionProgress = isCompact; - ThreadSubmission.CreateThreadAndSubmit(ctx); + if (string.IsNullOrEmpty(threadPath)) + { + showSubmissionProgress = isCompact; + ThreadSubmission.CreateThreadAndSubmit(ctx); + } + else + { + ThreadSubmission.Submit(ctx); + } + + // Claude-Code-style queue: input stays enabled so the user can keep typing while + // previous submissions are being processed by the thread. The server watcher + // batches unprocessed user messages into a single round. + submissionHandler.ForceRelease(); + StateHasChanged(); } - else + catch (Exception ex) { - ThreadSubmission.Submit(ctx); + Logger.LogError(ex, "[ThreadChat:{InstanceId}] SubmitMessageCore failed", _instanceId); } - - // Claude-Code-style queue: input stays enabled so the user can keep typing while - // previous submissions are being processed by the thread. The server watcher - // batches unprocessed user messages into a single round. - submissionHandler.ForceRelease(); - StateHasChanged(); } private async Task ClearMonacoAsync() diff --git a/src/MeshWeaver.Blazor/Components/FormComponentBase.cs b/src/MeshWeaver.Blazor/Components/FormComponentBase.cs index c6eb70e50..3be1d6ccd 100644 --- a/src/MeshWeaver.Blazor/Components/FormComponentBase.cs +++ b/src/MeshWeaver.Blazor/Components/FormComponentBase.cs @@ -104,6 +104,9 @@ protected override void BindData() DataPointer = ViewModel.Data as JsonPointerReference; valueUpdateSubject = new(); + // No .Skip(1) — the first value change must propagate immediately + // so that editing a field (e.g. TransactionMapping percentage) triggers + // reactive updates on the first keystroke, not only from the second one. AddBinding(valueUpdateSubject .ThrottleImmediate(TimeSpan.FromMilliseconds(DebounceWindow)) .DistinctUntilChanged() @@ -259,6 +262,17 @@ protected virtual void OnBlur() { if (Stream is null || ViewModel is not { IsBlurable: true }) return; + + // Flush any pending value that the debounce cooldown hasn't emitted yet. + // Without this, a user could edit a value and click away before the + // debounce timer fires, causing the change to be silently lost. + if (hasPendingLocalChanges && DataPointer is not null) + { + lastSyncedValue = currentLocalValue; + hasPendingLocalChanges = false; + UpdatePointer(ConvertToData(currentLocalValue!)!, DataPointer); + } + Stream.Hub.Post(new BlurEvent(Area, Stream.StreamId), o => o.WithTarget(Stream.Owner)); } } diff --git a/src/MeshWeaver.Blazor/Infrastructure/NonfileRouteConstraint.cs b/src/MeshWeaver.Blazor/Infrastructure/NonfileRouteConstraint.cs index f20a004ea..aa6fa040e 100644 --- a/src/MeshWeaver.Blazor/Infrastructure/NonfileRouteConstraint.cs +++ b/src/MeshWeaver.Blazor/Infrastructure/NonfileRouteConstraint.cs @@ -13,7 +13,8 @@ public class NonfileRouteConstraint : IRouteConstraint private static readonly HashSet ExcludedPrefixes = new(StringComparer.OrdinalIgnoreCase) { "_framework", "_content", "_blazor", "favicon.ico", - "auth", "dev", "mcp" + "auth", "dev", "mcp", + "signin-microsoft", "signin-google", "signin-linkedin", "signin-apple" }; public bool Match( diff --git a/src/MeshWeaver.Blazor/Infrastructure/UserContextMiddleware.cs b/src/MeshWeaver.Blazor/Infrastructure/UserContextMiddleware.cs index 1b29270c5..f892e0b33 100644 --- a/src/MeshWeaver.Blazor/Infrastructure/UserContextMiddleware.cs +++ b/src/MeshWeaver.Blazor/Infrastructure/UserContextMiddleware.cs @@ -11,8 +11,22 @@ namespace MeshWeaver.Blazor.Infrastructure; public class UserContextMiddleware(RequestDelegate next, ILogger logger) { + // Blazor framework files, static assets, and favicon — no user context needed. + private static readonly string[] ExcludedPrefixes = + ["/_framework", "/_content", "/_blazor", "/static/", "/favicon.ico"]; + public async Task InvokeAsync(HttpContext context) { + // Skip user resolution for static assets and Blazor framework resources. + // These requests never need an AccessContext and resolving it adds unnecessary + // overhead (hub lookup, mesh query) on every JS/CSS/SignalR resource download. + var path = context.Request.Path.Value ?? ""; + if (ExcludedPrefixes.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase))) + { + await next(context); + return; + } + var hub = context.RequestServices.GetRequiredService().Hub; var userService = hub.ServiceProvider.GetRequiredService(); diff --git a/src/MeshWeaver.Documentation/Data/Architecture.md b/src/MeshWeaver.Documentation/Data/Architecture.md index 2d5d0d359..90bfdda76 100644 --- a/src/MeshWeaver.Documentation/Data/Architecture.md +++ b/src/MeshWeaver.Documentation/Data/Architecture.md @@ -107,7 +107,18 @@ Flexible security through `IDataValidator`: --- -## 7. Deployment +## 7. Project Templates + +Bootstrap a new MeshWeaver portal with a single command: +- `dotnet new meshweaver-memex -o MyProject` scaffolds a complete solution +- Monolith and distributed deployment modes included +- Sample data, dev login users, and access control pre-configured + +[Read more: Project Templates](ProjectTemplates) + +--- + +## 8. Deployment Deploy with **.NET Aspire** to Azure Container Apps: - Multiple modes: `local`, `local-test`, `local-prod`, `test`, `prod`, `monolith` diff --git a/src/MeshWeaver.Documentation/Data/Architecture/Deployment.md b/src/MeshWeaver.Documentation/Data/Architecture/Deployment.md index 87d7ec15e..69a97c404 100644 --- a/src/MeshWeaver.Documentation/Data/Architecture/Deployment.md +++ b/src/MeshWeaver.Documentation/Data/Architecture/Deployment.md @@ -14,8 +14,6 @@ The AppHost supports multiple modes, passed as `--mode `: | Mode | PostgreSQL | Blob Storage | Orleans | Portal Name | |-------------|------------------------------|-------------------------------|-----------|-----------------| | `local` | Docker pgvector container | Emulated (Azurite) | Emulated | memex-local | -| `local-test`| Azure (memex-test) | Azure (meshweavermemextest) | Emulated | memex-local | -| `local-prod`| Azure (memex) | Azure (meshweavermemex) | Emulated | memex-local | | `test` | Azure (memex-test) | Azure (meshweavermemextest) | Azure | memex-test | | `prod` | Azure (memex) | Azure (meshweavermemex) | Azure | memex-prod | | `monolith` | FileSystem (standalone) | — | — | memex-monolith | @@ -63,20 +61,11 @@ After deployment completes, the Aspire CLI outputs the portal URL. Verify by: For local development with Docker containers: ```bash -aspire run --project memex/aspire/Memex.AppHost/Memex.AppHost.csproj +aspire run --project memex/aspire/Memex.AppHost/Memex.AppHost.csproj -- --mode local ``` This starts in `local` mode by default, using Docker pgvector and emulated Azure services. -To run locally against Azure test or prod databases: - -```bash -aspire run --project memex/aspire/Memex.AppHost/Memex.AppHost.csproj -- --mode local-test -aspire run --project memex/aspire/Memex.AppHost/Memex.AppHost.csproj -- --mode local-prod -``` - -These modes connect to Azure PostgreSQL and Blob Storage while keeping Orleans emulated locally. - # Monolith Mode For standalone development without Orleans or external infrastructure: @@ -101,7 +90,6 @@ Deployed modes (`test`, `prod`) run on **Azure Container Apps** in Sweden Centra - **Local**: Docker container with pgvector extension (`pgvector/pgvector:pg17`) - **Deployed**: Azure PostgreSQL Flexible Server with pgvector, provisioned automatically -- **local-test/local-prod**: Connects to existing Azure PostgreSQL via connection string ## Azure Blob Storage @@ -114,13 +102,26 @@ Content files (attachments, documents) are stored in Azure Blob Storage. Orleans provides distributed actor clustering for the microservices deployment. -- **Local/local-test/local-prod**: Emulated (in-process) +- **Local**: Emulated (in-process) - **Deployed**: Azure Table Storage for clustering, Azure Blob Storage for grain state ## Application Insights Telemetry and distributed tracing via Azure Application Insights, provisioned automatically in all deployed modes. +# Azure AD App Registration + +Microsoft authentication requires an app registration in Microsoft Entra ID (Azure AD): + +1. **Azure Portal** → **App registrations** → select your app (or create one) +2. Under **Authentication** → **Platform configurations** → **Web**, add redirect URIs: + - `http://localhost:5000/signin-microsoft` (local development) + - `https:///signin-microsoft` (deployed environments) +3. Note the **Application (client) ID** and **Directory (tenant) ID** from the **Overview** page +4. Under **Certificates & secrets**, create a client secret + +For single-tenant apps, the tenant ID must be configured — the default `/common` endpoint is not supported. + # Secrets Management Secrets are managed via `dotnet user-secrets` locally and GitHub secrets in CI/CD. @@ -135,17 +136,12 @@ Required secrets for distributed modes: | `Parameters:embedding-model` | Embedding model name | | `Parameters:microsoft-client-id` | Microsoft OAuth client ID | | `Parameters:microsoft-client-secret` | Microsoft OAuth client secret | +| `Parameters:microsoft-tenant-id` | Microsoft Entra tenant ID (single-tenant apps) | | `Parameters:google-client-id` | Google OAuth client ID | | `Parameters:google-client-secret` | Google OAuth client secret | | `Parameters:custom-domain` | Custom domain for deployed portal | | `Parameters:certificate-name` | TLS certificate name for custom domain | -For `local-test` and `local-prod` modes, also set: - -| Secret | Description | -|--------|-------------| -| `ConnectionStrings:memex` | Azure PostgreSQL connection string | - Set secrets using: ```bash diff --git a/src/MeshWeaver.Documentation/Data/Architecture/ProjectTemplates.md b/src/MeshWeaver.Documentation/Data/Architecture/ProjectTemplates.md new file mode 100644 index 000000000..da0b57a8a --- /dev/null +++ b/src/MeshWeaver.Documentation/Data/Architecture/ProjectTemplates.md @@ -0,0 +1,246 @@ +--- +Name: Project Templates +Category: Architecture +Description: Bootstrapping a new MeshWeaver portal with dotnet new, template structure, configuration, and customization +Icon: /static/NodeTypeIcons/code.svg +--- + +MeshWeaver ships a **.NET project template** (`meshweaver-memex`) that scaffolds a complete portal application. Running `dotnet new meshweaver-memex -o MyProject` generates a working solution with sample data, authentication, AI integration, and both monolith and distributed deployment options — ready to run in under a minute. + +# Why Templates + +Building a MeshWeaver portal from scratch requires configuring message hubs, layout areas, authentication, graph nodes, access control, and Aspire orchestration. The template handles all of this, giving you: + +- A **running portal** with sample data (ACME insurance company demo) +- **Dev login** with pre-configured users (Admin, Alice, Bob) for local development +- **Two deployment modes**: monolith (single process, no dependencies) and distributed (Aspire + Orleans + PostgreSQL) +- **Proper namespace renaming**: `dotnet new` replaces `Memex` with your project name throughout + +# Quick Start + +## Install the Template + +From a published NuGet package: + +```bash +dotnet new install MeshWeaver.ProjectTemplates +``` + +Or from a local template directory (for development): + +```bash +dotnet new install path/to/dist/templates/ +``` + +## Scaffold a New Project + +```bash +dotnet new meshweaver-memex -o MyProject +``` + +This creates a `MyProject/` directory with all projects renamed from `Memex` to `MyProject`. + +## Run the Monolith Portal + +```bash +dotnet run --project MyProject/MyProject.Portal.Monolith +``` + +Open the URL shown in the console (default: `https://localhost:7222`). The dev login page lists available users — click one to sign in. + +## Run with Aspire (Distributed) + +```bash +dotnet run --project MyProject/aspire/MyProject.AppHost +``` + +This starts the Aspire dashboard with PostgreSQL (Docker), the distributed portal with Orleans, and the database migration service. + +# What the Template Generates + +``` +MyProject/ +├── MyProject.slnx # Solution file +├── MyProject.Portal.Monolith/ # Standalone portal (no external deps) +│ ├── Program.cs # Entry point +│ ├── Properties/launchSettings.json # Dev environment & ports +│ └── appsettings.Development.json # Graph storage paths, AI config +├── MyProject.Portal.Shared/ # Shared Razor UI, auth, configuration +│ ├── Pages/ # DevLogin, Onboarding, portal pages +│ ├── Authentication/ # DevAuthController, middleware +│ └── MyProjectConfiguration.cs # Hub setup, AddGraph(), AddDocumentation() +├── aspire/ +│ ├── MyProject.AppHost/ # Aspire orchestrator +│ ├── MyProject.Portal.Distributed/ # Portal with Orleans silo +│ ├── MyProject.Database.Migration/ # Schema migration (run-to-completion) +│ └── MyProject.Portal.ServiceDefaults/ # Health, telemetry defaults +├── samples/Graph/Data/ # Sample data loaded by AddGraph() +│ ├── ACME/ # Insurance company demo +│ │ ├── Project/ # 2 projects with Todos +│ │ ├── User/ # 3 org-scoped users (Oliver, Paul, Quinn) +│ │ ├── Agent/ # TodoAgent definition +│ │ ├── Doc/ # ACME-specific documentation +│ │ └── _Access/ # Partition-level access assignments +│ └── User/ # Top-level login users +│ ├── Admin.json # Admin user +│ ├── Alice.json # Sample user +│ ├── Bob.json # Sample user +│ └── _Access/ # Global access assignments (Admin role) +├── Directory.Build.props # MSBuild properties +├── Directory.Packages.props # Centralized NuGet versions +└── nuget.config # Package sources +``` + +# Template Architecture + +## Two User Scopes + +The template includes users at two levels, mirroring the MeshWeaver convention: + +| Scope | Path | Purpose | +|-------|------|---------| +| **Global** | `User/Admin`, `User/Alice`, `User/Bob` | Portal-wide login users with `namespace: "User"` | +| **Partition** | `ACME/User/Oliver`, `ACME/User/Paul`, `ACME/User/Quinn` | Organization-scoped users with `namespace: "ACME/User"` | + +The **DevLogin page** queries `nodeType:User namespace:User` — only global users appear. Partition-scoped users (like ACME's Oliver) are visible within their organization context but do not appear on the login screen. + +## Access Control + +Each login user needs an **AccessAssignment** node granting a role. These live in `User/_Access/`: + +```json +{ + "id": "Admin_Access", + "namespace": "User/_Access", + "nodeType": "AccessAssignment", + "content": { + "$type": "AccessAssignment", + "accessObject": "Admin", + "displayName": "Admin", + "roles": [{ "role": "Admin" }] + } +} +``` + +Without an access assignment, the user can log in but receives "Access denied" errors when navigating. The template ships Admin, Alice, and Bob with the `Admin` role. + +## Graph Storage Configuration + +The monolith portal loads sample data from the filesystem via `appsettings.Development.json`: + +```json +{ + "Graph": { + "Storage": { + "Type": "FileSystem", + "BasePath": "../samples/Graph/Data" + }, + "Content": { + "Type": "FileSystem", + "BasePath": "../samples/Graph" + } + } +} +``` + +Paths are relative to the monolith project directory. The distributed portal uses PostgreSQL instead — no file paths needed. + +## What the Framework Provides + +`AddGraph()` and `AddDocumentation()` (called in the shared configuration) register built-in resources that are **not** in the template's `samples/` directory: + +- **Node types**: Markdown, Code, Agent, Group, User, VUser, Role, Notification, Approval, AccessAssignment, GroupMembership, and more +- **Documentation**: Architecture guides, DataMesh reference, GUI controls, AI integration docs (served under the `Doc/` namespace) +- **Icons**: Node type icons (`/static/NodeTypeIcons/`) +- **Roles**: Built-in Admin, Editor, Viewer role definitions + +# Customizing Your Portal + +## Adding Users + +Create a JSON file in `samples/Graph/Data/User/` and a matching access assignment in `User/_Access/`: + +```json +{ + "id": "Jane", + "namespace": "User", + "name": "Jane Doe", + "nodeType": "User", + "icon": "/static/NodeTypeIcons/person.svg", + "isPersistent": true, + "content": { + "$type": "User", + "email": "jane@example.com", + "bio": "Product manager." + } +} +``` + +```json +{ + "id": "Jane_Access", + "namespace": "User/_Access", + "nodeType": "AccessAssignment", + "content": { + "$type": "AccessAssignment", + "accessObject": "Jane", + "displayName": "Jane Doe", + "roles": [{ "role": "Admin" }] + } +} +``` + +## Adding a New Organization + +Create a directory under `samples/Graph/Data/` following the ACME pattern: + +``` +samples/Graph/Data/MyOrg/ +├── MyOrg.json # Organization root node +├── Project/ # Projects +├── User/ # Org-scoped users +├── Doc/ # Org documentation +└── _Access/ # Org-level access assignments +``` + +## Replacing Sample Data + +Delete the `ACME/` directory and add your own data. The portal loads whatever is in the `samples/Graph/Data/` directory — there are no hard-coded references to ACME. + +## Moving to Production Auth + +The DevLogin page is only active when `ASPNETCORE_ENVIRONMENT=Development`. In production, configure Microsoft or Google OAuth in the Aspire AppHost. See [Deployment](Deployment) for secrets and redirect URI setup. + +# Monolith vs Distributed + +| Aspect | Monolith | Distributed (Aspire) | +|--------|----------|---------------------| +| **Dependencies** | None | Docker (PostgreSQL, Azurite) | +| **Data storage** | Filesystem (`samples/Graph/Data/`) | PostgreSQL with pgvector | +| **Scaling** | Single process | Orleans clustering, Azure Container Apps | +| **Use case** | Local development, demos | Staging, production | +| **Run command** | `dotnet run --project MyProject.Portal.Monolith` | `dotnet run --project aspire/MyProject.AppHost` | + +Start with the monolith for development. When you need persistence, search, or multi-instance scaling, switch to the distributed mode. Both share the same `MyProject.Portal.Shared` project — all UI, configuration, and business logic is identical. + +# Troubleshooting + +## "Address already in use" on startup + +The default ports (7222/5222) are occupied. Either stop the other process or change the ports in `Properties/launchSettings.json`. + +## Dev login shows no users + +The DevLogin page queries `nodeType:User namespace:User`. Ensure you have user JSON files in `samples/Graph/Data/User/` (not just inside an organization like `ACME/User/`). + +## "Access denied" after login + +The user exists but has no access assignment. Create an `AccessAssignment` node in `User/_Access/` granting a role (Admin, Editor, or Viewer). + +## Portal crashes on startup (missing Graph:Storage) + +`ASPNETCORE_ENVIRONMENT` is not set to `Development`. Ensure `Properties/launchSettings.json` exists and sets the environment variable, or run with `--environment Development`. + +## ACME data not loading + +Check that `appsettings.Development.json` has correct relative paths. From the monolith project directory, `../samples/Graph/Data` should resolve to the `samples/` folder at the solution root. diff --git a/src/MeshWeaver.Graph/Configuration/UserNodeType.cs b/src/MeshWeaver.Graph/Configuration/UserNodeType.cs index df315c67c..035235cba 100644 --- a/src/MeshWeaver.Graph/Configuration/UserNodeType.cs +++ b/src/MeshWeaver.Graph/Configuration/UserNodeType.cs @@ -105,7 +105,10 @@ private static MessageHubConfiguration WithUserNodePublicRead(this MessageHubCon // "User/Alice" is public, "User/Alice/SomeThread" is not return nodePath.StartsWith("User/", StringComparison.OrdinalIgnoreCase) && !nodePath["User/".Length..].Contains('/'); - }); + }) + .AddHubPermissionRule( + Permission.Read, + (_, userId) => !string.IsNullOrEmpty(userId)); /// /// Adds a create-access rule for portal namespace hubs (onboarding flow). diff --git a/src/MeshWeaver.Hosting.Blazor/BlazorHostingExtensions.cs b/src/MeshWeaver.Hosting.Blazor/BlazorHostingExtensions.cs index 47c314b26..17a0c2b5b 100644 --- a/src/MeshWeaver.Hosting.Blazor/BlazorHostingExtensions.cs +++ b/src/MeshWeaver.Hosting.Blazor/BlazorHostingExtensions.cs @@ -37,7 +37,6 @@ public static MeshBuilder AddBlazor(this MeshBuilder builder, Func(); // Thumbnail preview stub (returns 501 until implemented) app.MapGet("/layout-preview/{area}", (string area) => Results.StatusCode(StatusCodes.Status501NotImplemented)); diff --git a/src/MeshWeaver.Hosting.Blazor/CircuitAccessHandler.cs b/src/MeshWeaver.Hosting.Blazor/CircuitAccessHandler.cs index 3086c9b77..1db069691 100644 --- a/src/MeshWeaver.Hosting.Blazor/CircuitAccessHandler.cs +++ b/src/MeshWeaver.Hosting.Blazor/CircuitAccessHandler.cs @@ -147,22 +147,19 @@ public override Task OnCircuitClosedAsync(Circuit circuit, CancellationToken ct) { try { - // Use IMeshQueryCore (no access control) — this is an infrastructure lookup - // during login, before the user's identity is established. - var queryCore = _hub.ServiceProvider.GetService(); - if (queryCore == null) + // Impersonate as the hub identity for this lookup — the user's access context + // isn't established yet during login, so we use the hub's system-level identity + // to query the User namespace for email-to-ObjectId resolution. + var accessService = _hub.ServiceProvider.GetService(); + if (accessService == null) return null; - var request = new MeshQueryRequest - { - Query = $"$type:MeshNode nodeType:User namespace:User content.email:{email} limit:1" - }; - await foreach (var item in queryCore.QueryAsync(request, _hub.JsonSerializerOptions)) + using (accessService.ImpersonateAsHub(_hub)) { - if (item is MeshNode node) - return node; + var meshService = _hub.ServiceProvider.GetRequiredService(); + return await meshService.QueryAsync( + $"nodeType:User namespace:User content.email:{email} limit:1").FirstOrDefaultAsync(); } - return null; } catch (Exception ex) { diff --git a/src/MeshWeaver.Hosting.PostgreSql/PostgreSqlSchemaInitializer.cs b/src/MeshWeaver.Hosting.PostgreSql/PostgreSqlSchemaInitializer.cs index 12d70b59d..1f5d05acc 100644 --- a/src/MeshWeaver.Hosting.PostgreSql/PostgreSqlSchemaInitializer.cs +++ b/src/MeshWeaver.Hosting.PostgreSql/PostgreSqlSchemaInitializer.cs @@ -1482,6 +1482,27 @@ AFTER INSERT OR UPDATE OR DELETE ON mesh_nodes END IF; END; $$; + + -- Simple access_control and group_members tables used by convenience methods + CREATE TABLE IF NOT EXISTS access_control ( + node_path TEXT NOT NULL, + subject TEXT NOT NULL, + permission TEXT NOT NULL, + is_allow BOOLEAN NOT NULL, + PRIMARY KEY (node_path, subject, permission) + ); + + CREATE TABLE IF NOT EXISTS group_members ( + group_name TEXT NOT NULL, + member_id TEXT NOT NULL, + PRIMARY KEY (group_name, member_id) + ); + + -- Node type permission flags (populated from DI-registered NodeTypePermission records) + CREATE TABLE IF NOT EXISTS node_type_permissions ( + node_type TEXT NOT NULL PRIMARY KEY, + public_read BOOLEAN NOT NULL DEFAULT false + ); """; } } diff --git a/src/MeshWeaver.Hosting/Security/SecurityService.cs b/src/MeshWeaver.Hosting/Security/SecurityService.cs index d8a5b102d..c96ad5a0d 100644 --- a/src/MeshWeaver.Hosting/Security/SecurityService.cs +++ b/src/MeshWeaver.Hosting/Security/SecurityService.cs @@ -232,6 +232,24 @@ public async Task GetEffectivePermissionsAsync(string nodePath, stri } } + // Built-in invariant: a user always has Admin on their own User/{userId} scope. + // UserScopeGrantHandler materializes this into the DB for PostgreSQL's materialized view, + // but the in-memory permission check should not depend solely on that materialization. + if (!string.IsNullOrEmpty(userId) + && userId != WellKnownUsers.Anonymous + && userId != WellKnownUsers.Public) + { + var userScopePath = $"User/{userId}"; + if (nodePath.Equals(userScopePath, StringComparison.OrdinalIgnoreCase) + || nodePath.StartsWith(userScopePath + "/", StringComparison.OrdinalIgnoreCase)) + { + if (!roleAssignments.ContainsKey("Admin")) + { + roleAssignments["Admin"] = (Denied: false, Depth: scopes.Count); + } + } + } + // Check Admin scope for PlatformAdmin assignments (global reach). // PlatformAdmin stored at Admin/{userId}_Access should grant access to ALL paths. if (!scopes.Contains("Admin")) diff --git a/test/Memex.Portal.Shared.Test/Memex.Portal.Shared.Test.csproj b/test/Memex.Portal.Shared.Test/Memex.Portal.Shared.Test.csproj new file mode 100644 index 000000000..f5c5b2119 --- /dev/null +++ b/test/Memex.Portal.Shared.Test/Memex.Portal.Shared.Test.csproj @@ -0,0 +1,6 @@ + + + + + + diff --git a/test/Memex.Portal.Shared.Test/OnboardingMiddlewareExclusionTest.cs b/test/Memex.Portal.Shared.Test/OnboardingMiddlewareExclusionTest.cs new file mode 100644 index 000000000..06029a1db --- /dev/null +++ b/test/Memex.Portal.Shared.Test/OnboardingMiddlewareExclusionTest.cs @@ -0,0 +1,140 @@ +using System; +using System.Security.Claims; +using System.Threading.Tasks; +using FluentAssertions; +using Memex.Portal.Shared.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Memex.Portal.Shared.Test; + +public class OnboardingMiddlewareExclusionTest +{ + private static ClaimsPrincipal AuthenticatedUser => + new(new ClaimsIdentity("TestAuth")); + + [Theory] + [InlineData("/onboarding")] + [InlineData("/login")] + [InlineData("/auth/callback")] + [InlineData("/_framework/blazor.js")] + [InlineData("/_content/MeshWeaver.Blazor/css/app.css")] + [InlineData("/static/img.png")] + [InlineData("/favicon.ico")] + [InlineData("/mcp")] + [InlineData("/signin-microsoft")] + public async Task ExcludedPrefixes_SkipOnboardingCheck(string path) + { + var nextCalled = false; + RequestDelegate next = _ => + { + nextCalled = true; + return Task.CompletedTask; + }; + + var middleware = new OnboardingMiddleware(next, NullLogger.Instance); + + var context = new DefaultHttpContext(); + context.Request.Path = path; + context.User = AuthenticatedUser; + + await middleware.InvokeAsync(context); + + nextCalled.Should().BeTrue(because: $"'{path}' should be excluded and pass through to next"); + } + + [Theory] + [InlineData("/signin-microsoft")] + [InlineData("/signin-google")] + [InlineData("/signin-oidc")] + public async Task SigninCallbackPaths_AreExcluded(string path) + { + var nextCalled = false; + RequestDelegate next = _ => + { + nextCalled = true; + return Task.CompletedTask; + }; + + var middleware = new OnboardingMiddleware(next, NullLogger.Instance); + + var context = new DefaultHttpContext(); + context.Request.Path = path; + context.User = AuthenticatedUser; + + await middleware.InvokeAsync(context); + + nextCalled.Should().BeTrue(because: $"'{path}' should be excluded by the /signin- prefix"); + } + + [Theory] + [InlineData("/ACME/Overview")] + [InlineData("/User/Alice")] + [InlineData("/")] + public async Task NonExcludedPaths_AttemptOnboardingCheck(string path) + { + RequestDelegate next = _ => Task.CompletedTask; + + var middleware = new OnboardingMiddleware(next, NullLogger.Instance); + + var context = new DefaultHttpContext(); + context.Request.Path = path; + context.User = AuthenticatedUser; + + // Non-excluded paths with authenticated user will attempt to resolve + // PortalApplication from DI. Since RequestServices isn't set up, this throws. + var act = () => middleware.InvokeAsync(context); + await act.Should().ThrowAsync( + because: "non-excluded paths should attempt onboarding check via PortalApplication"); + } + + [Theory] + [InlineData("/_FRAMEWORK/blazor.js")] + [InlineData("/LOGIN")] + [InlineData("/STATIC/img.png")] + public async Task ExcludedPrefixes_AreCaseInsensitive(string path) + { + var nextCalled = false; + RequestDelegate next = _ => + { + nextCalled = true; + return Task.CompletedTask; + }; + + var middleware = new OnboardingMiddleware(next, NullLogger.Instance); + + var context = new DefaultHttpContext(); + context.Request.Path = path; + context.User = AuthenticatedUser; + + await middleware.InvokeAsync(context); + + nextCalled.Should().BeTrue(because: "exclusion should be case-insensitive"); + } + + [Theory] + [InlineData("/ACME/Overview")] + [InlineData("/User/Alice")] + [InlineData("/")] + public async Task UnauthenticatedUser_SkipsEntireCheck(string path) + { + var nextCalled = false; + RequestDelegate next = _ => + { + nextCalled = true; + return Task.CompletedTask; + }; + + var middleware = new OnboardingMiddleware(next, NullLogger.Instance); + + var context = new DefaultHttpContext(); + context.Request.Path = path; + // No user set — unauthenticated + + await middleware.InvokeAsync(context); + + nextCalled.Should().BeTrue( + because: "unauthenticated users should skip the entire onboarding check"); + } +} diff --git a/test/Memex.Portal.Shared.Test/VirtualUserMiddlewareExclusionTest.cs b/test/Memex.Portal.Shared.Test/VirtualUserMiddlewareExclusionTest.cs new file mode 100644 index 000000000..9b66f0314 --- /dev/null +++ b/test/Memex.Portal.Shared.Test/VirtualUserMiddlewareExclusionTest.cs @@ -0,0 +1,103 @@ +using System; +using System.Threading.Tasks; +using FluentAssertions; +using Memex.Portal.Shared.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Memex.Portal.Shared.Test; + +public class VirtualUserMiddlewareExclusionTest +{ + [Theory] + [InlineData("/_framework/blazor.web.js")] + [InlineData("/_content/MeshWeaver.Blazor/css/app.css")] + [InlineData("/_blazor/negotiate")] + [InlineData("/static/images/logo.png")] + [InlineData("/favicon.ico")] + [InlineData("/mcp")] + public async Task ExcludedPrefixes_SkipVirtualUserAssignment(string path) + { + var nextCalled = false; + RequestDelegate next = _ => + { + nextCalled = true; + return Task.CompletedTask; + }; + + var middleware = new VirtualUserMiddleware(next, NullLogger.Instance); + + var context = new DefaultHttpContext(); + context.Request.Path = path; + + await middleware.InvokeAsync(context); + + nextCalled.Should().BeTrue(because: $"'{path}' should be excluded and pass through to next"); + } + + [Theory] + [InlineData("/ACME/Overview")] + [InlineData("/User/Alice")] + [InlineData("/")] + public async Task NonExcludedPaths_AttemptVirtualUserAssignment(string path) + { + RequestDelegate next = _ => Task.CompletedTask; + + var middleware = new VirtualUserMiddleware(next, NullLogger.Instance); + + var context = new DefaultHttpContext(); + context.Request.Path = path; + + // Non-excluded paths will attempt to resolve PortalApplication from DI. + // Since RequestServices isn't set up, this throws. + var act = () => middleware.InvokeAsync(context); + await act.Should().ThrowAsync( + because: "non-excluded paths should attempt virtual user assignment via PortalApplication"); + } + + [Theory] + [InlineData("/_FRAMEWORK/blazor.web.js")] + [InlineData("/STATIC/image.png")] + [InlineData("/FAVICON.ICO")] + public async Task ExcludedPrefixes_AreCaseInsensitive(string path) + { + var nextCalled = false; + RequestDelegate next = _ => + { + nextCalled = true; + return Task.CompletedTask; + }; + + var middleware = new VirtualUserMiddleware(next, NullLogger.Instance); + + var context = new DefaultHttpContext(); + context.Request.Path = path; + + await middleware.InvokeAsync(context); + + nextCalled.Should().BeTrue(because: "exclusion should be case-insensitive"); + } + + [Theory] + [InlineData("/mcp")] + [InlineData("/mcp/tools")] + public async Task McpPath_StillExcluded(string path) + { + var nextCalled = false; + RequestDelegate next = _ => + { + nextCalled = true; + return Task.CompletedTask; + }; + + var middleware = new VirtualUserMiddleware(next, NullLogger.Instance); + + var context = new DefaultHttpContext(); + context.Request.Path = path; + + await middleware.InvokeAsync(context); + + nextCalled.Should().BeTrue(because: "MCP paths should remain excluded after refactor"); + } +} diff --git a/test/MeshWeaver.Graph.Test/UserNodeTypePermissionTest.cs b/test/MeshWeaver.Graph.Test/UserNodeTypePermissionTest.cs new file mode 100644 index 000000000..237629f3d --- /dev/null +++ b/test/MeshWeaver.Graph.Test/UserNodeTypePermissionTest.cs @@ -0,0 +1,139 @@ +using System.Threading.Tasks; +using FluentAssertions; +using MeshWeaver.Graph.Configuration; +using MeshWeaver.Mesh; +using MeshWeaver.Mesh.Security; +using MeshWeaver.Mesh.Services; +using MeshWeaver.Messaging; +using Xunit; + +namespace MeshWeaver.Graph.Test; + +public class UserNodeTypePermissionTest +{ + private static MessageHubConfiguration BuildUserHubConfig() + { + var config = new MessageHubConfiguration(null, new Address("User", "Alice")); + var meshNode = UserNodeType.CreateMeshNode(); + Assert.NotNull(meshNode.HubConfiguration); + return meshNode.HubConfiguration(config); + } + + private static NodeValidationContext ReadContext(string id, string ns) => + new() + { + Operation = NodeOperation.Read, + Node = new MeshNode(id, ns) + }; + + private static NodeValidationContext UpdateContext(string id, string ns) => + new() + { + Operation = NodeOperation.Update, + Node = new MeshNode(id, ns) + }; + + #region Hub Permission Rules + + [Fact] + public void AuthenticatedUser_HasHubReadPermission() + { + var config = BuildUserHubConfig(); + var ruleSet = config.Get(); + ruleSet.Should().NotBeNull("UserNodeType should register hub permission rules"); + + ruleSet!.HasPermission(Permission.Read, null!, "alice") + .Should().BeTrue("any authenticated user should have Read permission"); + } + + [Fact] + public void UnauthenticatedUser_DeniedHubReadPermission() + { + var config = BuildUserHubConfig(); + var ruleSet = config.Get(); + ruleSet.Should().NotBeNull(); + + ruleSet!.HasPermission(Permission.Read, null!, null) + .Should().BeFalse("unauthenticated (null userId) should be denied"); + + ruleSet.HasPermission(Permission.Read, null!, "") + .Should().BeFalse("unauthenticated (empty userId) should be denied"); + } + + #endregion + + #region Node Access Rules (via NodeAccessRuleSet) + + [Fact] + public async Task AuthenticatedUser_CanReadDirectUserNode() + { + var config = BuildUserHubConfig(); + var ruleSet = config.Get(); + ruleSet.Should().NotBeNull("UserNodeType should register node access rules"); + + var accessRule = ruleSet!.ToAccessRule(UserNodeType.NodeType); + // Path = "User/Alice" (derived from ns/id) + var context = ReadContext("Alice", "User"); + + var result = await accessRule.HasAccessAsync(context, "bob", TestContext.Current.CancellationToken); + result.Should().BeTrue("any authenticated user can read a direct User node"); + } + + [Fact] + public async Task UnauthenticatedUser_CannotReadDirectUserNode() + { + var config = BuildUserHubConfig(); + var ruleSet = config.Get(); + var accessRule = ruleSet!.ToAccessRule(UserNodeType.NodeType); + + var context = ReadContext("Alice", "User"); + + var resultNull = await accessRule.HasAccessAsync(context, null, TestContext.Current.CancellationToken); + resultNull.Should().BeFalse("unauthenticated user (null) should be denied"); + + var resultEmpty = await accessRule.HasAccessAsync(context, "", TestContext.Current.CancellationToken); + resultEmpty.Should().BeFalse("unauthenticated user (empty) should be denied"); + } + + [Fact] + public async Task AuthenticatedUser_CannotReadChildNode() + { + var config = BuildUserHubConfig(); + var ruleSet = config.Get(); + var accessRule = ruleSet!.ToAccessRule(UserNodeType.NodeType); + + // Path = "User/Alice/thread1" (child node) + var context = ReadContext("thread1", "User/Alice"); + + var result = await accessRule.HasAccessAsync(context, "bob", TestContext.Current.CancellationToken); + result.Should().BeFalse("child nodes (threads, activities) should not be publicly readable"); + } + + [Fact] + public async Task UserCanEditOwnNode() + { + var config = BuildUserHubConfig(); + var ruleSet = config.Get(); + var accessRule = ruleSet!.ToAccessRule(UserNodeType.NodeType); + + var context = UpdateContext("Alice", "User"); + + var result = await accessRule.HasAccessAsync(context, "Alice", TestContext.Current.CancellationToken); + result.Should().BeTrue("users should be able to edit their own node"); + } + + [Fact] + public async Task UserCannotEditOtherUserNode() + { + var config = BuildUserHubConfig(); + var ruleSet = config.Get(); + var accessRule = ruleSet!.ToAccessRule(UserNodeType.NodeType); + + var context = UpdateContext("Alice", "User"); + + var result = await accessRule.HasAccessAsync(context, "Bob", TestContext.Current.CancellationToken); + result.Should().BeFalse("users should not be able to edit other users' nodes"); + } + + #endregion +} diff --git a/test/MeshWeaver.Hosting.Blazor.Test/NonfileRouteConstraintTest.cs b/test/MeshWeaver.Hosting.Blazor.Test/NonfileRouteConstraintTest.cs new file mode 100644 index 000000000..25195dc20 --- /dev/null +++ b/test/MeshWeaver.Hosting.Blazor.Test/NonfileRouteConstraintTest.cs @@ -0,0 +1,84 @@ +using FluentAssertions; +using MeshWeaver.Blazor.Infrastructure; +using Microsoft.AspNetCore.Routing; +using Xunit; + +namespace MeshWeaver.Hosting.Blazor.Test; + +public class NonfileRouteConstraintTest +{ + private readonly NonfileRouteConstraint _constraint = new(); + + private bool Match(string path) + { + var values = new RouteValueDictionary { ["Path"] = path }; + return _constraint.Match(null, null, "Path", values, RouteDirection.IncomingRequest); + } + + [Theory] + [InlineData("signin-microsoft")] + [InlineData("signin-google")] + [InlineData("signin-linkedin")] + [InlineData("signin-apple")] + [InlineData("signin-microsoft/callback")] + [InlineData("signin-google/callback")] + public void OAuthCallbackPaths_AreExcluded(string path) + { + Match(path).Should().BeFalse(because: $"'{path}' is an OAuth callback and should be excluded"); + } + + [Theory] + [InlineData("_framework")] + [InlineData("_framework/blazor.web.js")] + [InlineData("_content")] + [InlineData("_content/MeshWeaver.Blazor/css/app.css")] + [InlineData("_blazor")] + [InlineData("favicon.ico")] + [InlineData("auth")] + [InlineData("auth/login")] + [InlineData("dev")] + [InlineData("dev/login")] + [InlineData("mcp")] + [InlineData("mcp/sse")] + public void StaticAndInfrastructurePaths_AreExcluded(string path) + { + Match(path).Should().BeFalse(because: $"'{path}' should be excluded"); + } + + [Theory] + [InlineData("ACME/Overview")] + [InlineData("User/Alice")] + [InlineData("Northwind/Dashboard")] + [InlineData("Doc/Architecture")] + [InlineData("Organization/Search")] + public void NormalApplicationPaths_PassThrough(string path) + { + Match(path).Should().BeTrue(because: $"'{path}' is a normal route and should pass through"); + } + + [Fact] + public void EmptyPath_ReturnsTrue() + { + var values = new RouteValueDictionary { ["Path"] = "" }; + _constraint.Match(null, null, "Path", values, RouteDirection.IncomingRequest) + .Should().BeTrue(); + } + + [Fact] + public void MissingRouteValue_ReturnsTrue() + { + var values = new RouteValueDictionary(); + _constraint.Match(null, null, "Path", values, RouteDirection.IncomingRequest) + .Should().BeTrue(); + } + + [Theory] + [InlineData("SIGNIN-MICROSOFT")] + [InlineData("Signin-Google")] + [InlineData("_FRAMEWORK")] + [InlineData("_Content")] + public void ExcludedPaths_AreCaseInsensitive(string path) + { + Match(path).Should().BeFalse(because: "exclusion should be case-insensitive"); + } +} diff --git a/test/MeshWeaver.Hosting.Blazor.Test/UserContextMiddlewareExclusionTest.cs b/test/MeshWeaver.Hosting.Blazor.Test/UserContextMiddlewareExclusionTest.cs new file mode 100644 index 000000000..5f2139363 --- /dev/null +++ b/test/MeshWeaver.Hosting.Blazor.Test/UserContextMiddlewareExclusionTest.cs @@ -0,0 +1,86 @@ +using System; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using MeshWeaver.Blazor.Infrastructure; +using Xunit; + +namespace MeshWeaver.Hosting.Blazor.Test; + +public class UserContextMiddlewareExclusionTest +{ + [Theory] + [InlineData("/_framework/blazor.web.js")] + [InlineData("/_content/MeshWeaver.Blazor/css/app.css")] + [InlineData("/_blazor/negotiate")] + [InlineData("/static/images/logo.png")] + [InlineData("/favicon.ico")] + public async Task ExcludedPrefixes_SkipUserResolution(string path) + { + var nextCalled = false; + RequestDelegate next = _ => + { + nextCalled = true; + return Task.CompletedTask; + }; + + var logger = NullLogger.Instance; + var middleware = new UserContextMiddleware(next, logger); + + var context = new DefaultHttpContext(); + context.Request.Path = path; + + // Should call next() without trying to resolve PortalApplication + // (which isn't registered, so it would throw if it tried) + await middleware.InvokeAsync(context); + + nextCalled.Should().BeTrue(because: $"'{path}' should be skipped and pass through to next"); + } + + [Theory] + [InlineData("/ACME/Overview")] + [InlineData("/User/Alice")] + [InlineData("/")] + public async Task NonExcludedPaths_AttemptUserResolution(string path) + { + RequestDelegate next = _ => Task.CompletedTask; + + var logger = NullLogger.Instance; + var middleware = new UserContextMiddleware(next, logger); + + var context = new DefaultHttpContext(); + context.Request.Path = path; + + // Non-excluded paths will attempt to resolve PortalApplication from DI. + // Since RequestServices isn't set up, this throws (proving the path was NOT skipped). + var act = () => middleware.InvokeAsync(context); + await act.Should().ThrowAsync( + because: "non-excluded paths should attempt PortalApplication resolution"); + } + + [Theory] + [InlineData("/_FRAMEWORK/blazor.web.js")] + [InlineData("/_Content/something")] + [InlineData("/Static/image.png")] + [InlineData("/FAVICON.ICO")] + public async Task ExcludedPrefixes_AreCaseInsensitive(string path) + { + var nextCalled = false; + RequestDelegate next = _ => + { + nextCalled = true; + return Task.CompletedTask; + }; + + var logger = NullLogger.Instance; + var middleware = new UserContextMiddleware(next, logger); + + var context = new DefaultHttpContext(); + context.Request.Path = path; + + await middleware.InvokeAsync(context); + + nextCalled.Should().BeTrue(because: "exclusion should be case-insensitive"); + } +} diff --git a/test/MeshWeaver.Security.Test/AccessControlPipelineTest.cs b/test/MeshWeaver.Security.Test/AccessControlPipelineTest.cs index 96533f85a..8bb393f02 100644 --- a/test/MeshWeaver.Security.Test/AccessControlPipelineTest.cs +++ b/test/MeshWeaver.Security.Test/AccessControlPipelineTest.cs @@ -264,3 +264,159 @@ public async Task Admin_HasReadOnOrganization_WithoutClaimBasedRoles() } } } + +/// +/// Tests that the User hub grants read access to authenticated users via HubPermissionRuleSet. +/// Reproduces deployed test environment error: +/// "Access denied: user 'sglauser@systemorph.com' lacks Read permission on 'User'" +/// The fix: WithUserNodePublicRead() must register AddHubPermissionRule (not just AddAccessRule). +/// +public class UserHubAccessTest(ITestOutputHelper output) : MonolithMeshTestBase(output) +{ + protected override MeshBuilder ConfigureMesh(MeshBuilder builder) + => base.ConfigureMesh(builder) + .AddGraph() + .AddSampleUsers(); + + [Fact(Timeout = 10000)] + public async Task AuthenticatedUser_CanReadUserHub() + { + // Simulate an unprivileged authenticated user (no Admin role, no explicit access assignments) + var accessService = Mesh.ServiceProvider.GetRequiredService(); + accessService.SetCircuitContext(new AccessContext { ObjectId = "unprivileged@example.com", Name = "Unprivileged User" }); + + var response = await Mesh.AwaitResponse( + new GetDataRequest(new UnifiedReference("data:")), + o => o.WithTarget(new Address("User")), + TestContext.Current.CancellationToken); + + // Should not be blocked by AccessControlPipeline + response.Message.Error.Should().NotContain("Access denied", + "authenticated user should have Read access to User hub via HubPermissionRuleSet"); + } + + [Fact(Timeout = 10000)] + public async Task AnonymousUser_CannotReadUserHub() + { + // Anonymous (empty userId) should be denied + var accessService = Mesh.ServiceProvider.GetRequiredService(); + accessService.SetCircuitContext(new AccessContext { ObjectId = "", Name = "" }); + + var ex = await Assert.ThrowsAnyAsync(async () => + await Mesh.AwaitResponse( + new GetDataRequest(new UnifiedReference("data:")), + o => o.WithTarget(new Address("User")), + TestContext.Current.CancellationToken)); + + ex.InnerException.Should().BeOfType(); + ex.InnerException!.Message.Should().Contain("Access denied"); + } +} + +/// +/// Tests the self-scope fallback in SecurityService: a user always has Admin +/// permissions on their own User/{userId} scope and its children, without +/// needing explicit AccessAssignment nodes. +/// +public class UserSelfScopeAccessTest(ITestOutputHelper output) : MonolithMeshTestBase(output) +{ + protected override MeshBuilder ConfigureMesh(MeshBuilder builder) + => ConfigureMeshBase(builder); // No PublicAdminAccess — pure RLS + + [Fact(Timeout = 10000)] + public async Task UserAccessingOwnScope_ReturnsAdmin() + { + var securityService = Mesh.ServiceProvider.GetRequiredService(); + + var permissions = await securityService.GetEffectivePermissionsAsync( + "User/alice", "alice", TestContext.Current.CancellationToken); + + permissions.Should().HaveFlag(Permission.Read); + permissions.Should().HaveFlag(Permission.Create); + permissions.Should().HaveFlag(Permission.Update); + permissions.Should().HaveFlag(Permission.Delete); + } + + [Fact(Timeout = 10000)] + public async Task UserAccessingOwnChild_ReturnsAdmin() + { + var securityService = Mesh.ServiceProvider.GetRequiredService(); + + var permissions = await securityService.GetEffectivePermissionsAsync( + "User/bob/_Thread/t1", "bob", TestContext.Current.CancellationToken); + + permissions.Should().HaveFlag(Permission.Read); + permissions.Should().HaveFlag(Permission.Create); + permissions.Should().HaveFlag(Permission.Update); + permissions.Should().HaveFlag(Permission.Delete); + } + + [Fact(Timeout = 10000)] + public async Task AnonymousAccessingUserScope_NoFallback() + { + var securityService = Mesh.ServiceProvider.GetRequiredService(); + + var permissions = await securityService.GetEffectivePermissionsAsync( + "User/alice", WellKnownUsers.Anonymous, TestContext.Current.CancellationToken); + + permissions.Should().Be(Permission.None, + "Anonymous should not get self-scope fallback on another user's scope"); + } + + [Fact(Timeout = 10000)] + public async Task PublicAccessingUserScope_NoFallback() + { + var securityService = Mesh.ServiceProvider.GetRequiredService(); + + var permissions = await securityService.GetEffectivePermissionsAsync( + "User/alice", WellKnownUsers.Public, TestContext.Current.CancellationToken); + + permissions.Should().Be(Permission.None, + "Public should not get self-scope fallback on another user's scope"); + } + + [Fact(Timeout = 10000)] + public async Task UserAccessingOtherUserScope_NoFallback() + { + var securityService = Mesh.ServiceProvider.GetRequiredService(); + + var permissions = await securityService.GetEffectivePermissionsAsync( + "User/alice", "bob", TestContext.Current.CancellationToken); + + permissions.Should().Be(Permission.None, + "bob should not have any permissions on alice's scope without explicit assignment"); + } + + [Fact(Timeout = 10000)] + public async Task UserWithExplicitAdmin_FallbackIsNoOp() + { + var securityService = Mesh.ServiceProvider.GetRequiredService(); + + // Give charlie explicit Admin on User/charlie + await securityService.AddUserRoleAsync( + "charlie", "Admin", "User/charlie", "system", + TestContext.Current.CancellationToken); + + var permissions = await securityService.GetEffectivePermissionsAsync( + "User/charlie", "charlie", TestContext.Current.CancellationToken); + + permissions.Should().HaveFlag(Permission.Read); + permissions.Should().HaveFlag(Permission.Create); + permissions.Should().HaveFlag(Permission.Update); + permissions.Should().HaveFlag(Permission.Delete); + } + + [Fact(Timeout = 10000)] + public async Task CaseInsensitivePath_Works() + { + var securityService = Mesh.ServiceProvider.GetRequiredService(); + + // Path uses lowercase "user" instead of "User" + var permissions = await securityService.GetEffectivePermissionsAsync( + "user/alice/child", "alice", TestContext.Current.CancellationToken); + + permissions.Should().HaveFlag(Permission.Read, + "self-scope fallback should be case-insensitive on path prefix"); + permissions.Should().HaveFlag(Permission.Create); + } +}