wip: doctor/cli/docs/api to vector db consolidation; api hardening for descriptions, tenant, and scopes; migrations and conversions of all DALs to EF v10

This commit is contained in:
master
2026-02-23 15:30:50 +02:00
parent bd8fee6ed8
commit e746577380
1424 changed files with 81225 additions and 25251 deletions

View File

@@ -1,8 +1,10 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Integrations.Contracts;
using StellaOps.Integrations.Contracts.AiCodeGuard;
using StellaOps.Integrations.Core;
using StellaOps.Integrations.WebService.AiCodeGuard;
using StellaOps.Integrations.WebService.Security;
namespace StellaOps.Integrations.WebService;
@@ -14,6 +16,8 @@ public static class IntegrationEndpoints
public static void MapIntegrationEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/v1/integrations")
.RequireAuthorization(IntegrationPolicies.Read)
.RequireTenant()
.WithTags("Integrations");
// Standalone AI Code Guard run
@@ -25,12 +29,14 @@ public static class IntegrationEndpoints
var response = await aiCodeGuardRunService.RunAsync(request, cancellationToken);
return Results.Ok(response);
})
.RequireAuthorization(IntegrationPolicies.Operate)
.WithName("RunAiCodeGuard")
.WithDescription("Runs standalone AI Code Guard checks (equivalent to stella guard run).");
.WithDescription("Executes a standalone AI Code Guard analysis pipeline against the specified target, equivalent to running `stella guard run`. Returns the scan result including detected issues, severity breakdown, and any policy violations.");
// List integrations
group.MapGet("/", async (
[FromServices] IntegrationService service,
[FromServices] IStellaOpsTenantAccessor tenantAccessor,
[FromQuery] IntegrationType? type,
[FromQuery] IntegrationProvider? provider,
[FromQuery] IntegrationStatus? status,
@@ -42,11 +48,12 @@ public static class IntegrationEndpoints
CancellationToken cancellationToken = default) =>
{
var query = new ListIntegrationsQuery(type, provider, status, search, null, page, pageSize, sortBy, sortDescending);
var result = await service.ListAsync(query, null, cancellationToken);
var result = await service.ListAsync(query, tenantAccessor.TenantId, cancellationToken);
return Results.Ok(result);
})
.RequireAuthorization(IntegrationPolicies.Read)
.WithName("ListIntegrations")
.WithDescription("Lists integrations with optional filtering and pagination.");
.WithDescription("Returns a paginated list of integrations optionally filtered by type, provider, status, or a free-text search term. Results are sorted by the specified field and direction, defaulting to name ascending.");
// Get integration by ID
group.MapGet("/{id:guid}", async (
@@ -57,57 +64,66 @@ public static class IntegrationEndpoints
var result = await service.GetByIdAsync(id, cancellationToken);
return result is null ? Results.NotFound() : Results.Ok(result);
})
.RequireAuthorization(IntegrationPolicies.Read)
.WithName("GetIntegration")
.WithDescription("Gets an integration by ID.");
.WithDescription("Returns the full integration record for the specified ID including provider, type, configuration metadata, and current status. Returns 404 if the ID is not found.");
// Create integration
group.MapPost("/", async (
[FromServices] IntegrationService service,
[FromServices] IStellaOpsTenantAccessor tenantAccessor,
[FromBody] CreateIntegrationRequest request,
CancellationToken cancellationToken) =>
{
var result = await service.CreateAsync(request, null, null, cancellationToken);
var result = await service.CreateAsync(request, tenantAccessor.TenantId, null, cancellationToken);
return Results.Created($"/api/v1/integrations/{result.Id}", result);
})
.RequireAuthorization(IntegrationPolicies.Write)
.WithName("CreateIntegration")
.WithDescription("Creates a new integration.");
.WithDescription("Registers a new integration with the catalog. The provider plugin is loaded and validated during creation. Returns 201 Created with the new integration record. Returns 400 if the provider is unsupported or required configuration is missing.");
// Update integration
group.MapPut("/{id:guid}", async (
[FromServices] IntegrationService service,
[FromServices] IStellaOpsTenantAccessor tenantAccessor,
Guid id,
[FromBody] UpdateIntegrationRequest request,
CancellationToken cancellationToken) =>
{
var result = await service.UpdateAsync(id, request, null, cancellationToken);
var result = await service.UpdateAsync(id, request, tenantAccessor.TenantId, cancellationToken);
return result is null ? Results.NotFound() : Results.Ok(result);
})
.RequireAuthorization(IntegrationPolicies.Write)
.WithName("UpdateIntegration")
.WithDescription("Updates an existing integration.");
.WithDescription("Updates the mutable configuration of an existing integration including display name, credentials reference, and provider-specific settings. Returns the updated integration record. Returns 404 if the ID is not found.");
// Delete integration
group.MapDelete("/{id:guid}", async (
[FromServices] IntegrationService service,
[FromServices] IStellaOpsTenantAccessor tenantAccessor,
Guid id,
CancellationToken cancellationToken) =>
{
var result = await service.DeleteAsync(id, null, cancellationToken);
var result = await service.DeleteAsync(id, tenantAccessor.TenantId, cancellationToken);
return result ? Results.NoContent() : Results.NotFound();
})
.RequireAuthorization(IntegrationPolicies.Write)
.WithName("DeleteIntegration")
.WithDescription("Soft-deletes an integration.");
.WithDescription("Soft-deletes an integration from the catalog, disabling it without removing audit history. Returns 204 No Content on success. Returns 404 if the ID is not found.");
// Test connection
group.MapPost("/{id:guid}/test", async (
[FromServices] IntegrationService service,
[FromServices] IStellaOpsTenantAccessor tenantAccessor,
Guid id,
CancellationToken cancellationToken) =>
{
var result = await service.TestConnectionAsync(id, null, cancellationToken);
var result = await service.TestConnectionAsync(id, tenantAccessor.TenantId, cancellationToken);
return result is null ? Results.NotFound() : Results.Ok(result);
})
.RequireAuthorization(IntegrationPolicies.Operate)
.WithName("TestIntegrationConnection")
.WithDescription("Tests connectivity and authentication for an integration.");
.WithDescription("Executes a live connectivity and authentication test against the external system for the specified integration. Returns a test result object with success status, latency, and any error details. Returns 404 if the integration ID is not found.");
// Health check
group.MapGet("/{id:guid}/health", async (
@@ -118,8 +134,9 @@ public static class IntegrationEndpoints
var result = await service.CheckHealthAsync(id, cancellationToken);
return result is null ? Results.NotFound() : Results.Ok(result);
})
.RequireAuthorization(IntegrationPolicies.Read)
.WithName("CheckIntegrationHealth")
.WithDescription("Performs a health check on an integration.");
.WithDescription("Performs a health check on the specified integration and returns the current health status, including reachability, authentication validity, and any degradation indicators. Returns 404 if the integration ID is not found.");
// Impact map
group.MapGet("/{id:guid}/impact", async (
@@ -130,8 +147,9 @@ public static class IntegrationEndpoints
var result = await service.GetImpactAsync(id, cancellationToken);
return result is null ? Results.NotFound() : Results.Ok(result);
})
.RequireAuthorization(IntegrationPolicies.Read)
.WithName("GetIntegrationImpact")
.WithDescription("Returns affected workflows and severity impact for an integration.");
.WithDescription("Returns an impact map for the specified integration showing which workflows, pipelines, and policy gates depend on it, grouped by severity. Use this before disabling or reconfiguring an integration to understand downstream effects. Returns 404 if the ID is not found.");
// Get supported providers
group.MapGet("/providers", ([FromServices] IntegrationService service) =>
@@ -139,7 +157,8 @@ public static class IntegrationEndpoints
var result = service.GetSupportedProviders();
return Results.Ok(result);
})
.RequireAuthorization(IntegrationPolicies.Read)
.WithName("GetSupportedProviders")
.WithDescription("Gets a list of supported integration providers.");
.WithDescription("Returns the list of integration provider types currently supported by the loaded plugin set. Use this to discover valid provider values before creating a new integration.");
}
}

View File

@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Integrations.Persistence;
using StellaOps.Integrations.Plugin.GitHubApp;
@@ -7,7 +8,9 @@ using StellaOps.Integrations.Plugin.InMemory;
using StellaOps.Integrations.WebService;
using StellaOps.Integrations.WebService.AiCodeGuard;
using StellaOps.Integrations.WebService.Infrastructure;
using StellaOps.Integrations.WebService.Security;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Router.AspNet;
var builder = WebApplication.CreateBuilder(args);
@@ -70,12 +73,22 @@ builder.Services.AddScoped<IAiCodeGuardRunService, AiCodeGuardRunService>();
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
// Authentication and authorization
builder.Services.AddStellaOpsResourceServerAuthentication(builder.Configuration);
builder.Services.AddAuthorization(options =>
{
options.AddStellaOpsScopePolicy(IntegrationPolicies.Read, StellaOpsScopes.IntegrationRead);
options.AddStellaOpsScopePolicy(IntegrationPolicies.Write, StellaOpsScopes.IntegrationWrite);
options.AddStellaOpsScopePolicy(IntegrationPolicies.Operate, StellaOpsScopes.IntegrationOperate);
});
// Stella Router integration
var routerEnabled = builder.Services.AddRouterMicroservice(
builder.Configuration,
serviceName: "integrations",
version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0",
routerOptionsSection: "Router");
builder.Services.AddStellaOpsTenantServices();
builder.TryAddStellaOpsLocalBinding("integrations");
var app = builder.Build();
app.LogStellaOpsLocalHostname("integrations");
@@ -88,6 +101,9 @@ if (app.Environment.IsDevelopment())
}
app.UseStellaOpsCors();
app.UseAuthentication();
app.UseAuthorization();
app.UseStellaOpsTenantMiddleware();
app.TryUseStellaRouter(routerEnabled);
// Map endpoints
@@ -96,7 +112,9 @@ app.MapIntegrationEndpoints();
// Health endpoint
app.MapGet("/health", () => Results.Ok(new { Status = "Healthy", Timestamp = DateTimeOffset.UtcNow }))
.WithTags("Health")
.WithName("HealthCheck");
.WithName("HealthCheck")
.WithDescription("Returns the liveness status and current UTC timestamp for the Integration Catalog service. Used by the Router gateway and container orchestrator for health polling.")
.AllowAnonymous();
// Ensure database is created (dev only)
if (app.Environment.IsDevelopment())

View File

@@ -0,0 +1,19 @@
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
namespace StellaOps.Integrations.WebService.Security;
/// <summary>
/// Named authorization policy constants for the Integration Catalog service.
/// Policies are registered via AddStellaOpsScopePolicy in Program.cs.
/// </summary>
internal static class IntegrationPolicies
{
/// <summary>Policy for listing integrations, providers, health, and impact. Requires integration:read scope.</summary>
public const string Read = "Integration.Read";
/// <summary>Policy for creating, updating, and deleting integrations. Requires integration:write scope.</summary>
public const string Write = "Integration.Write";
/// <summary>Policy for executing integration operations (test connections, AI Code Guard runs). Requires integration:operate scope.</summary>
public const string Operate = "Integration.Operate";
}

View File

@@ -0,0 +1,213 @@
// -----------------------------------------------------------------------------
// TenantIsolationTests.cs
// Module: Integrations
// Description: Unit tests verifying tenant isolation behaviour of the unified
// StellaOpsTenantResolver used by the Integrations WebService.
// Exercises claim resolution, header fallbacks, conflict detection,
// and full context resolution (actor + project).
// -----------------------------------------------------------------------------
using System.Security.Claims;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration.Tenancy;
using Xunit;
namespace StellaOps.Integrations.Tests;
/// <summary>
/// Tenant isolation tests for the Integrations module using the unified
/// <see cref="StellaOpsTenantResolver"/>. Pure unit tests -- no Postgres,
/// no WebApplicationFactory.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TenantIsolationTests
{
// ---------------------------------------------------------------
// 1. Missing tenant returns false with "tenant_missing"
// ---------------------------------------------------------------
[Fact]
public void TryResolveTenantId_MissingTenant_ReturnsFalseWithTenantMissing()
{
// Arrange -- no claims, no headers
var ctx = CreateHttpContext();
// Act
var resolved = StellaOpsTenantResolver.TryResolveTenantId(ctx, out var tenantId, out var error);
// Assert
resolved.Should().BeFalse("no tenant source is available");
tenantId.Should().BeEmpty();
error.Should().Be("tenant_missing");
}
// ---------------------------------------------------------------
// 2. Canonical claim resolves tenant
// ---------------------------------------------------------------
[Fact]
public void TryResolveTenantId_CanonicalClaim_ResolvesTenant()
{
// Arrange
var ctx = CreateHttpContext();
ctx.User = PrincipalWithClaims(
new Claim(StellaOpsClaimTypes.Tenant, "acme-corp"));
// Act
var resolved = StellaOpsTenantResolver.TryResolveTenantId(ctx, out var tenantId, out var error);
// Assert
resolved.Should().BeTrue();
tenantId.Should().Be("acme-corp");
error.Should().BeNull();
}
// ---------------------------------------------------------------
// 3. Legacy "tid" claim fallback
// ---------------------------------------------------------------
[Fact]
public void TryResolveTenantId_LegacyTidClaim_FallsBack()
{
// Arrange -- only the legacy "tid" claim, no canonical claim or header
var ctx = CreateHttpContext();
ctx.User = PrincipalWithClaims(
new Claim("tid", "Legacy-Tenant-42"));
// Act
var resolved = StellaOpsTenantResolver.TryResolveTenantId(ctx, out var tenantId, out var error);
// Assert
resolved.Should().BeTrue("legacy tid claim should be accepted as fallback");
tenantId.Should().Be("legacy-tenant-42", "tenant IDs are normalised to lower-case");
error.Should().BeNull();
}
// ---------------------------------------------------------------
// 4. Canonical header resolves tenant
// ---------------------------------------------------------------
[Fact]
public void TryResolveTenantId_CanonicalHeader_ResolvesTenant()
{
// Arrange -- no claims, only the canonical header
var ctx = CreateHttpContext();
ctx.Request.Headers[StellaOpsHttpHeaderNames.Tenant] = "header-tenant";
// Act
var resolved = StellaOpsTenantResolver.TryResolveTenantId(ctx, out var tenantId, out var error);
// Assert
resolved.Should().BeTrue();
tenantId.Should().Be("header-tenant");
error.Should().BeNull();
}
// ---------------------------------------------------------------
// 5. Full context resolves actor and project
// ---------------------------------------------------------------
[Fact]
public void TryResolve_FullContext_ResolvesActorAndProject()
{
// Arrange
var ctx = CreateHttpContext();
ctx.User = PrincipalWithClaims(
new Claim(StellaOpsClaimTypes.Tenant, "acme-corp"),
new Claim(StellaOpsClaimTypes.Subject, "user-42"),
new Claim(StellaOpsClaimTypes.Project, "project-alpha"));
// Act
var resolved = StellaOpsTenantResolver.TryResolve(ctx, out var tenantContext, out var error);
// Assert
resolved.Should().BeTrue();
error.Should().BeNull();
tenantContext.Should().NotBeNull();
tenantContext!.TenantId.Should().Be("acme-corp");
tenantContext.ActorId.Should().Be("user-42");
tenantContext.ProjectId.Should().Be("project-alpha");
tenantContext.Source.Should().Be(TenantSource.Claim);
}
// ---------------------------------------------------------------
// 6. Conflicting headers return tenant_conflict
// ---------------------------------------------------------------
[Fact]
public void TryResolveTenantId_ConflictingHeaders_ReturnsTenantConflict()
{
// Arrange -- canonical and legacy headers with different values
var ctx = CreateHttpContext();
ctx.Request.Headers[StellaOpsHttpHeaderNames.Tenant] = "tenant-a";
ctx.Request.Headers["X-Stella-Tenant"] = "tenant-b";
// Act
var resolved = StellaOpsTenantResolver.TryResolveTenantId(ctx, out var tenantId, out var error);
// Assert
resolved.Should().BeFalse("conflicting headers must be rejected");
error.Should().Be("tenant_conflict");
}
// ---------------------------------------------------------------
// 7. Claim-header mismatch returns tenant_conflict
// ---------------------------------------------------------------
[Fact]
public void TryResolveTenantId_ClaimHeaderMismatch_ReturnsTenantConflict()
{
// Arrange -- claim says "tenant-claim" but header says "tenant-header"
var ctx = CreateHttpContext();
ctx.User = PrincipalWithClaims(
new Claim(StellaOpsClaimTypes.Tenant, "tenant-claim"));
ctx.Request.Headers[StellaOpsHttpHeaderNames.Tenant] = "tenant-header";
// Act
var resolved = StellaOpsTenantResolver.TryResolveTenantId(ctx, out var tenantId, out var error);
// Assert
resolved.Should().BeFalse("claim-header mismatch must be rejected");
error.Should().Be("tenant_conflict");
}
// ---------------------------------------------------------------
// 8. Matching claim and header -- no conflict
// ---------------------------------------------------------------
[Fact]
public void TryResolveTenantId_MatchingClaimAndHeader_NoConflict()
{
// Arrange -- claim and header agree on the same tenant
var ctx = CreateHttpContext();
ctx.User = PrincipalWithClaims(
new Claim(StellaOpsClaimTypes.Tenant, "same-tenant"));
ctx.Request.Headers[StellaOpsHttpHeaderNames.Tenant] = "same-tenant";
// Act
var resolved = StellaOpsTenantResolver.TryResolveTenantId(ctx, out var tenantId, out var error);
// Assert
resolved.Should().BeTrue("matching claim and header should not conflict");
tenantId.Should().Be("same-tenant");
error.Should().BeNull();
}
// ---------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------
private static DefaultHttpContext CreateHttpContext()
{
var ctx = new DefaultHttpContext();
ctx.Response.Body = new MemoryStream();
return ctx;
}
private static ClaimsPrincipal PrincipalWithClaims(params Claim[] claims)
{
return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
}
}