diff --git a/CHANGELOG.md b/CHANGELOG.md index 3042dc1fb..e859219dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -149,10 +149,11 @@ follow [Semantic Versioning](https://semver.org). Ongoing work is collected unde succeeded and returned an empty list. The callers who hit this were the ones with no reason to name a project in the first place — a REST API key, which is confined to a single project, and integrations driving `/api/*` directly — so a service could ask for its traces, agents, suites, - runs or evaluators and be told, with a perfectly successful response, that there were none. An - unfiltered request is now answered with the caller's own projects, and a caller who belongs to - several gets all of them as one correctly paged list. The web app was never affected, because it - always names the current project. + runs, evaluators or optimization proposals and be told, with a perfectly successful response, that + there were none. An unfiltered request is now answered with the caller's own projects, and a + caller who belongs to several gets all of them as one correctly paged list. This now also covers + the two lists that were still left out: individual test runs and optimization proposals. The web + app was never affected, because it always names the current project. - **The Agent Playground no longer asks for an agent that is gone.** The playground remembers which agent you had selected, and it kept asking the server for that agent even after it had been diff --git a/Proxytrace.Api.Tests/ProposalsControllerTests.cs b/Proxytrace.Api.Tests/ProposalsControllerTests.cs index 1d50a6fe5..2577655b7 100644 --- a/Proxytrace.Api.Tests/ProposalsControllerTests.cs +++ b/Proxytrace.Api.Tests/ProposalsControllerTests.cs @@ -312,6 +312,106 @@ public async Task Get_ExistingProposal_ReturnsDto() dto.AgentId.Should().Be(proposal.Agent.Id); } + [TestMethod] + public async Task GetAll_AsNonAdminWithoutFilter_ReturnsOwnProjectsProposalsOnly() + { + // #482: an unfiltered list from a non-admin used to short-circuit to an empty list, so a + // REST API key — confined to one project, and with no reason to send a filter — was told its + // own project had no proposals. + IServiceProvider services = GetServices(); + var mine = await SeedProposalInNewProjectAsync(services, "mine"); + await SeedProposalInNewProjectAsync(services, "theirs"); + + var controller = ResolveController(services, ScopedGuard(mine.Agent.Project.Id)); + var result = await controller.GetAll(cancellationToken: CancellationToken); + + result.Should().ContainSingle().Which.Id.Should().Be(mine.Id); + } + + [TestMethod] + public async Task GetAll_AsNonAdminInSeveralProjectsWithoutFilter_ReturnsTheUnion() + { + IServiceProvider services = GetServices(); + var first = await SeedProposalInNewProjectAsync(services, "first"); + var second = await SeedProposalInNewProjectAsync(services, "second"); + await SeedProposalInNewProjectAsync(services, "outsider"); + + var controller = ResolveController( + services, ScopedGuard(first.Agent.Project.Id, second.Agent.Project.Id)); + var result = await controller.GetAll(cancellationToken: CancellationToken); + + result.Select(p => p.Id).Should().BeEquivalentTo([first.Id, second.Id]); + } + + [TestMethod] + public async Task GetAll_AsNonAdminWithoutAccessibleProjects_ReturnsEmpty() + { + IServiceProvider services = GetServices(); + await SeedProposalInNewProjectAsync(services, "theirs"); + + var controller = ResolveController(services, ScopedGuard()); + var result = await controller.GetAll(cancellationToken: CancellationToken); + + result.Should().BeEmpty(); + } + + [TestMethod] + public async Task GetAll_AsNonAdminFilteredByInaccessibleProject_ReturnsEmpty() + { + IServiceProvider services = GetServices(); + var mine = await SeedProposalInNewProjectAsync(services, "mine"); + var theirs = await SeedProposalInNewProjectAsync(services, "theirs"); + + var controller = ResolveController(services, ScopedGuard(mine.Agent.Project.Id)); + var result = await controller.GetAll(projectId: theirs.Agent.Project.Id, cancellationToken: CancellationToken); + + result.Should().BeEmpty(); + } + + // A proposal whose agent lives in a freshly created project, so the project id is unique to it + // and can be handed to ScopedGuard. + private async Task SeedProposalInNewProjectAsync( + IServiceProvider services, string name) + { + var endpoint = await services.GetRequiredService>() + .GetOrCreateAsync(CancellationToken); + var project = await services.GetRequiredService().AddAsync( + services.GetRequiredService()( + $"P-{name}-{Guid.NewGuid():N}", endpoint, []), + CancellationToken); + + var agent = await services.GetRequiredService().AddAsync( + services.GetRequiredService()( + $"A-{name}", + services.GetRequiredService()( + $"T-{name}", "You are a test agent."), + [], + endpoint, + project, + services.GetRequiredService()( + null, null, null, null, null)), + CancellationToken); + + var abRun = await services.GetRequiredService>() + .CreateAsync(CancellationToken); + return await services.GetRequiredService().AddAsync( + services.GetRequiredService()( + agent, Priority.Medium, "r", $"proposed-{name}", null, null, [], abRun), + CancellationToken); + } + + // A non-admin scoped to a specific set of projects: the scope set is non-null (not admin) and + // contains exactly those projects. No arguments means a member of nothing. + private static Proxytrace.Api.Auth.IProjectAccessGuard ScopedGuard(params Guid[] projectIds) + { + var guard = Substitute.For(); + guard.CanAccessProjectAsync(Arg.Any(), Arg.Any()) + .Returns(ci => projectIds.Contains(ci.Arg())); + guard.GetAccessibleProjectIdsAsync(Arg.Any()) + .Returns(Task.FromResult?>(projectIds)); + return guard; + } + private async Task SeedSystemPromptProposalAsync( IServiceProvider services, string proposedPrompt = "proposed") { @@ -324,7 +424,11 @@ private async Task SeedSystemPromptProposalAsync( CancellationToken); } - private static ProposalsController ResolveController(IServiceProvider services) => new( + private static ProposalsController ResolveController(IServiceProvider services) + => ResolveController(services, services.GetRequiredService()); + + private static ProposalsController ResolveController( + IServiceProvider services, Proxytrace.Api.Auth.IProjectAccessGuard guard) => new( services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), @@ -340,5 +444,5 @@ private async Task SeedSystemPromptProposalAsync( services.GetRequiredService(), services.GetRequiredService(), Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, - services.GetRequiredService()); + guard); } diff --git a/Proxytrace.Api.Tests/TestRunsControllerTests.cs b/Proxytrace.Api.Tests/TestRunsControllerTests.cs index 4697b0800..89cb3b9eb 100644 --- a/Proxytrace.Api.Tests/TestRunsControllerTests.cs +++ b/Proxytrace.Api.Tests/TestRunsControllerTests.cs @@ -7,9 +7,16 @@ using Proxytrace.Api.Controllers; using Proxytrace.Api.Dto.TestRuns; using Proxytrace.Application.Streaming; +using NSubstitute; using Proxytrace.Domain; using Proxytrace.Domain.Agent; +using Proxytrace.Domain.Inference; +using Proxytrace.Domain.ModelEndpoint; +using Proxytrace.Domain.Project; +using Proxytrace.Domain.Prompt; using Proxytrace.Domain.TestRun; +using Proxytrace.Domain.TestRunGroup; +using Proxytrace.Domain.TestSuite; using Proxytrace.Testing; namespace Proxytrace.Api.Tests; @@ -160,11 +167,108 @@ public async Task Stream_UnknownRun_Returns404() controller.Response.StatusCode.Should().Be(404); } - private static TestRunsController ResolveController(IServiceProvider services) => new( + [TestMethod] + public async Task GetAll_AsNonAdminWithoutAgentFilter_ReturnsOwnProjectsRunsOnly() + { + // #482: an unfiltered list from a non-admin used to short-circuit to an empty page, so a + // REST API key — confined to one project, and with no reason to send an agent filter — was + // told its own project had no runs. + IServiceProvider services = GetServices(); + var mine = await SeedRunInNewProjectAsync(services, "mine"); + await SeedRunInNewProjectAsync(services, "theirs"); + + var controller = ResolveController(services, ScopedGuard(mine.Group.Suite.Agent.Project.Id)); + var result = await controller.GetAll(cancellationToken: CancellationToken); + + result.Items.Should().ContainSingle().Which.Id.Should().Be(mine.Id); + result.Total.Should().Be(1); + } + + [TestMethod] + public async Task GetAll_AsNonAdminInSeveralProjectsWithoutAgentFilter_ReturnsTheUnion() + { + IServiceProvider services = GetServices(); + var first = await SeedRunInNewProjectAsync(services, "first"); + var second = await SeedRunInNewProjectAsync(services, "second"); + await SeedRunInNewProjectAsync(services, "outsider"); + + // A member of two projects: the page must be computed over the union of both, not one of + // them and not the whole instance. + var controller = ResolveController( + services, + ScopedGuard(first.Group.Suite.Agent.Project.Id, second.Group.Suite.Agent.Project.Id)); + var result = await controller.GetAll(cancellationToken: CancellationToken); + + result.Items.Select(i => i.Id).Should().BeEquivalentTo([first.Id, second.Id]); + result.Total.Should().Be(2); + } + + [TestMethod] + public async Task GetAll_AsNonAdminWithoutAccessibleProjects_ReturnsEmpty() + { + IServiceProvider services = GetServices(); + await SeedRunInNewProjectAsync(services, "theirs"); + + var controller = ResolveController(services, ScopedGuard()); + var result = await controller.GetAll(cancellationToken: CancellationToken); + + result.Items.Should().BeEmpty(); + result.Total.Should().Be(0); + } + + // A run whose whole chain (project → agent → suite → group → run) is freshly created, so the + // project id is unique to it and can be handed to ScopedGuard. + private async Task SeedRunInNewProjectAsync(IServiceProvider services, string name) + { + var endpoint = await services.GetRequiredService>() + .GetOrCreateAsync(CancellationToken); + var project = await services.GetRequiredService().AddAsync( + services.GetRequiredService()($"P-{name}-{Guid.NewGuid():N}", endpoint, []), + CancellationToken); + + var agent = await services.GetRequiredService().AddAsync( + services.GetRequiredService()( + $"A-{name}", + services.GetRequiredService()($"T-{name}", "You are a test agent."), + [], + endpoint, + project, + services.GetRequiredService()(null, null, null, null, null)), + CancellationToken); + + var suite = await services.GetRequiredService>().AddAsync( + services.GetRequiredService()($"S-{name}", agent, [], []), + CancellationToken); + var group = await services.GetRequiredService>().AddAsync( + services.GetRequiredService()(suite, isSystemRun: false, null, sampleCount: 1), + CancellationToken); + + return await services.GetRequiredService().AddAsync( + services.GetRequiredService()(group, endpoint, sampleIndex: 0), + CancellationToken); + } + + // A non-admin scoped to a specific set of projects: the scope set is non-null (not admin) and + // contains exactly those projects. No arguments means a member of nothing. + private static Proxytrace.Api.Auth.IProjectAccessGuard ScopedGuard(params Guid[] projectIds) + { + var guard = Substitute.For(); + guard.CanAccessProjectAsync(Arg.Any(), Arg.Any()) + .Returns(ci => projectIds.Contains(ci.Arg())); + guard.GetAccessibleProjectIdsAsync(Arg.Any()) + .Returns(Task.FromResult?>(projectIds)); + return guard; + } + + private static TestRunsController ResolveController(IServiceProvider services) + => ResolveController(services, services.GetRequiredService()); + + private static TestRunsController ResolveController( + IServiceProvider services, Proxytrace.Api.Auth.IProjectAccessGuard guard) => new( services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), - services.GetRequiredService(), + guard, NullLogger.Instance); } diff --git a/Proxytrace.Api/Controllers/ProposalsController.cs b/Proxytrace.Api/Controllers/ProposalsController.cs index bfe2e5650..b281a2471 100644 --- a/Proxytrace.Api/Controllers/ProposalsController.cs +++ b/Proxytrace.Api/Controllers/ProposalsController.cs @@ -78,23 +78,24 @@ public ProposalsController( this.accessGuard = accessGuard; } - // Resolve the effective owning project of a list query and verify access. Admins - // (accessible == null) pass for any scope. Non-admins must scope to a project they belong to — - // directly via projectId or via the agent's project — otherwise the query returns nothing rather - // than leaking other tenants' rows. - private async Task CanListAsync(Guid? agentId, Guid? projectId, CancellationToken cancellationToken) + // The projects this list request may read: null for an admin, the caller's own projects when no + // narrower filter is given (#482), and [] when a named agent/project is out of reach. + private async Task?> ListScopeAsync( + Guid? agentId, + Guid? projectId, + CancellationToken cancellationToken) { - var accessible = await accessGuard.GetAccessibleProjectIdsAsync(cancellationToken); - if (accessible is null) - return true; - if (projectId is { } pid) - return accessible.Contains(pid); + var scope = await accessGuard.ResolveListScopeAsync(projectId, cancellationToken); + if (scope is null || scope.IsEmpty()) + return scope; + if (agentId is { } aid) { var agent = await agents.FindAsync(aid, cancellationToken); - return agent is not null && accessible.Contains(agent.Project.Id); + return agent is not null && scope.Contains(agent.Project.Id) ? scope : []; } - return false; + + return scope; } [HttpGet] @@ -103,16 +104,17 @@ public async Task> GetAll( [FromQuery] Guid? projectId = null, CancellationToken cancellationToken = default) { - if (!await CanListAsync(agentId, projectId, cancellationToken)) + var scope = await ListScopeAsync(agentId, projectId, cancellationToken); + if (scope.IsEmpty()) return []; IReadOnlyList proposals; if (agentId.HasValue) proposals = await repository.GetByAgentAsync(agentId.Value, cancellationToken); - else if (projectId.HasValue) - proposals = await repository.GetByProjectAsync(projectId.Value, cancellationToken); - else + else if (scope is null) proposals = await repository.GetAllAsync(cancellationToken); + else + proposals = await repository.GetByProjectsAsync(scope, cancellationToken); return proposals.Select(mapper.ToDto).ToList(); } diff --git a/Proxytrace.Api/Controllers/TestRunsController.cs b/Proxytrace.Api/Controllers/TestRunsController.cs index 148544fb5..b6a42bfa7 100644 --- a/Proxytrace.Api/Controllers/TestRunsController.cs +++ b/Proxytrace.Api/Controllers/TestRunsController.cs @@ -60,11 +60,16 @@ public async Task> GetAll( return pagedByAgent.Map(mapper.ToDto); } - // No agent filter enumerates across all tenants — admins only; non-admins get nothing. - if (await accessGuard.GetAccessibleProjectIdsAsync(cancellationToken) is not null) + // No agent filter: the caller's own reach is the scope — every project for an admin (null), + // their memberships otherwise, so an unfiltered request answers with that caller's runs + // instead of an empty page (#482). + var scope = await accessGuard.ResolveListScopeAsync(requestedProjectId: null, cancellationToken); + if (scope.IsEmpty()) return new PagedResult([], 0, page, pageSize); - var paged = await repository.GetAllPagedAsync(page, pageSize, includeSystem, cancellationToken); + var paged = scope is null + ? await repository.GetAllPagedAsync(page, pageSize, includeSystem, cancellationToken) + : await repository.GetByProjectsPagedAsync(scope, page, pageSize, includeSystem, cancellationToken); return paged.Map(mapper.ToDto); } diff --git a/Proxytrace.Domain/OptimizationProposal/IOptimizationProposalRepository.cs b/Proxytrace.Domain/OptimizationProposal/IOptimizationProposalRepository.cs index 89dc18f51..0291a91cd 100644 --- a/Proxytrace.Domain/OptimizationProposal/IOptimizationProposalRepository.cs +++ b/Proxytrace.Domain/OptimizationProposal/IOptimizationProposalRepository.cs @@ -16,6 +16,15 @@ Task> GetByProjectAsync( Guid projectId, CancellationToken cancellationToken = default); + /// + /// Proposals across several projects as one list, newest first. Backs an unfiltered list request + /// from a caller who may read more than one project (#482), so the set is applied in SQL rather + /// than by concatenating per-project reads. + /// + Task> GetByProjectsAsync( + IReadOnlyCollection projectIds, + CancellationToken cancellationToken = default); + /// /// Returns the most-recently-updated proposal for the given agent with the specified /// , or null if none exists. diff --git a/Proxytrace.Domain/TestRun/ITestRunRepository.cs b/Proxytrace.Domain/TestRun/ITestRunRepository.cs index a597e3f73..2655369ba 100644 --- a/Proxytrace.Domain/TestRun/ITestRunRepository.cs +++ b/Proxytrace.Domain/TestRun/ITestRunRepository.cs @@ -44,4 +44,17 @@ Task> GetAllPagedAsync( int pageSize, bool includeSystem = false, CancellationToken cancellationToken = default); + + /// + /// Runs across several projects, paged as one list, newest first. Backs an unfiltered list + /// request from a caller who may read more than one project: the page has to be computed over + /// the union, so merging per-project pages afterwards would not produce a correct page (#482). + /// behaves as in . + /// + Task> GetByProjectsPagedAsync( + IReadOnlyCollection projectIds, + int page, + int pageSize, + bool includeSystem = false, + CancellationToken cancellationToken = default); } diff --git a/Proxytrace.Storage.Tests/OptimizationProposalRepositoryScopeTests.cs b/Proxytrace.Storage.Tests/OptimizationProposalRepositoryScopeTests.cs new file mode 100644 index 000000000..2d03ebc29 --- /dev/null +++ b/Proxytrace.Storage.Tests/OptimizationProposalRepositoryScopeTests.cs @@ -0,0 +1,93 @@ +using AwesomeAssertions; +using Microsoft.Extensions.DependencyInjection; +using Proxytrace.Domain; +using Proxytrace.Domain.Agent; +using Proxytrace.Domain.Inference; +using Proxytrace.Domain.ModelEndpoint; +using Proxytrace.Domain.OptimizationProposal; +using Proxytrace.Domain.Project; +using Proxytrace.Domain.Prompt; +using Proxytrace.Domain.Proposal; +using Proxytrace.Domain.TestRun; +using Proxytrace.Testing; + +namespace Proxytrace.Storage.Tests; + +/// +/// The project-scoped lookups behind an unfiltered proposals list (#482): a caller who may read +/// several projects is answered with the union of exactly those, resolved in the query. +/// +[TestClass] +public sealed class OptimizationProposalRepositoryScopeTests : BaseTest +{ + [TestMethod] + public async Task GetByProjects_ReturnsOnlyProposalsOfTheGivenProjects() + { + IServiceProvider services = GetServices(); + var repo = services.GetRequiredService(); + var first = await PersistProposalInNewProject(services); + var second = await PersistProposalInNewProject(services); + var outsider = await PersistProposalInNewProject(services); + + var proposals = await repo.GetByProjectsAsync([first.ProjectId, second.ProjectId], CancellationToken); + + proposals.Select(p => p.Id).Should().BeEquivalentTo([first.Proposal.Id, second.Proposal.Id]); + proposals.Select(p => p.Id).Should().NotContain(outsider.Proposal.Id); + } + + [TestMethod] + public async Task GetByProjects_WithNoProjects_ReturnsEmpty() + { + IServiceProvider services = GetServices(); + var repo = services.GetRequiredService(); + await PersistProposalInNewProject(services); + + var proposals = await repo.GetByProjectsAsync([], CancellationToken); + + proposals.Should().BeEmpty(); + } + + [TestMethod] + public async Task GetByProject_StillReturnsOnlyThatProject() + { + // GetByProjectAsync now delegates to the set overload — the single-project contract holds. + IServiceProvider services = GetServices(); + var repo = services.GetRequiredService(); + var mine = await PersistProposalInNewProject(services); + await PersistProposalInNewProject(services); + + var proposals = await repo.GetByProjectAsync(mine.ProjectId, CancellationToken); + + proposals.Should().ContainSingle().Which.Id.Should().Be(mine.Proposal.Id); + } + + // A proposal whose agent lives in a freshly created project, so each call yields a distinct + // project — the generators reuse one project and cannot express this. + private async Task<(Guid ProjectId, IOptimizationProposal Proposal)> PersistProposalInNewProject( + IServiceProvider services) + { + var endpoint = await services.GetRequiredService>() + .GetOrCreateAsync(CancellationToken); + var project = await services.GetRequiredService>() + .CreateAsync(CancellationToken); + + var agent = await services.GetRequiredService().CreateWithInitialVersionAsync( + name: $"A-{Guid.NewGuid():N}", + systemPrompt: services.GetRequiredService()("T", "You are a test agent."), + tools: [], + project: project, + endpoint: endpoint, + modelParameters: services.GetRequiredService()(null, null, null, null, null), + isSystemAgent: false, + cancellationToken: CancellationToken); + + var abRun = await services.GetRequiredService>() + .CreateAsync(CancellationToken); + var proposal = await services.GetRequiredService().AddAsync( + services.GetRequiredService()( + agent, Priority.Medium, "r", "proposed", null, null, [], abRun), + CancellationToken); + + return (project.Id, proposal); + } +} diff --git a/Proxytrace.Storage.Tests/TestRunRepositoryTests.cs b/Proxytrace.Storage.Tests/TestRunRepositoryTests.cs index 7aa5e144b..3c2e0b450 100644 --- a/Proxytrace.Storage.Tests/TestRunRepositoryTests.cs +++ b/Proxytrace.Storage.Tests/TestRunRepositoryTests.cs @@ -1,7 +1,11 @@ using AwesomeAssertions; using Microsoft.Extensions.DependencyInjection; using Proxytrace.Domain; +using Proxytrace.Domain.Agent; +using Proxytrace.Domain.Inference; using Proxytrace.Domain.ModelEndpoint; +using Proxytrace.Domain.Project; +using Proxytrace.Domain.Prompt; using Proxytrace.Domain.TestResult; using Proxytrace.Domain.TestRun; using Proxytrace.Domain.TestRunGroup; @@ -119,6 +123,96 @@ public async Task GetRunIdsByResultIds_WhenEmptyInput_ReturnsEmpty() /// Seeds one user (non-system) run and one system run under the same suite/agent, so a test can /// assert the system-run filter on either listing method. /// + [TestMethod] + public async Task GetByProjectsPaged_ReturnsOnlyRunsOfTheGivenProjects() + { + // #482: an unfiltered list from a caller who may read several projects is paged over the + // union of exactly those projects — applied in the query, not by filtering afterwards. + IServiceProvider services = GetServices(); + var repo = services.GetRequiredService(); + var first = await PersistRunInNewProject(services); + var second = await PersistRunInNewProject(services); + var outsider = await PersistRunInNewProject(services); + + var page = await repo.GetByProjectsPagedAsync( + [first.ProjectId, second.ProjectId], page: 1, pageSize: 50, cancellationToken: CancellationToken); + + page.Items.Select(r => r.Id).Should().BeEquivalentTo([first.Run.Id, second.Run.Id]); + page.Items.Select(r => r.Id).Should().NotContain(outsider.Run.Id); + page.Total.Should().Be(2); + } + + [TestMethod] + public async Task GetByProjectsPaged_ByDefault_ExcludesRunsOfSystemGroups() + { + IServiceProvider services = GetServices(); + var repo = services.GetRequiredService(); + var (suite, userRun, _) = await PersistUserAndSystemRuns(services); + + var page = await repo.GetByProjectsPagedAsync( + [suite.Agent.Project.Id], page: 1, pageSize: 50, cancellationToken: CancellationToken); + + page.Items.Should().ContainSingle().Which.Id.Should().Be(userRun.Id); + } + + [TestMethod] + public async Task GetByProjectsPaged_WithIncludeSystem_ReturnsRunsOfSystemGroups() + { + IServiceProvider services = GetServices(); + var repo = services.GetRequiredService(); + var (suite, userRun, systemRun) = await PersistUserAndSystemRuns(services); + + var page = await repo.GetByProjectsPagedAsync( + [suite.Agent.Project.Id], page: 1, pageSize: 50, includeSystem: true, CancellationToken); + + page.Items.Select(r => r.Id).Should().BeEquivalentTo([userRun.Id, systemRun.Id]); + } + + [TestMethod] + public async Task GetByProjectsPaged_WithNoProjects_ReturnsEmptyPage() + { + IServiceProvider services = GetServices(); + var repo = services.GetRequiredService(); + await PersistRunInNewProject(services); + + var page = await repo.GetByProjectsPagedAsync([], page: 1, pageSize: 50, cancellationToken: CancellationToken); + + page.Items.Should().BeEmpty(); + page.Total.Should().Be(0); + } + + // A run whose whole chain (project → agent → suite → group → run) is freshly created, so each + // call yields a distinct project — the generators reuse one project and cannot express this. + private async Task<(Guid ProjectId, ITestRun Run)> PersistRunInNewProject(IServiceProvider services) + { + var endpoint = await services.GetRequiredService>() + .GetOrCreateAsync(CancellationToken); + var project = await services.GetRequiredService>() + .CreateAsync(CancellationToken); + + var agent = await services.GetRequiredService().CreateWithInitialVersionAsync( + name: $"A-{Guid.NewGuid():N}", + systemPrompt: services.GetRequiredService()("T", "You are a test agent."), + tools: [], + project: project, + endpoint: endpoint, + modelParameters: services.GetRequiredService()(null, null, null, null, null), + isSystemAgent: false, + cancellationToken: CancellationToken); + + var suite = await services.GetRequiredService>().AddAsync( + services.GetRequiredService()("S", agent, [], []), + CancellationToken); + var group = await services.GetRequiredService().AddAsync( + services.GetRequiredService()(suite, isSystemRun: false, null, sampleCount: 1), + CancellationToken); + var run = await services.GetRequiredService().AddAsync( + services.GetRequiredService()(group, endpoint, sampleIndex: 0), + CancellationToken); + + return (project.Id, run); + } + private async Task<(ITestSuite Suite, ITestRun UserRun, ITestRun SystemRun)> PersistUserAndSystemRuns( IServiceProvider services) { diff --git a/Proxytrace.Storage/Internal/Entities/OptimizationProposal/OptimizationProposalRepository.cs b/Proxytrace.Storage/Internal/Entities/OptimizationProposal/OptimizationProposalRepository.cs index fa77f5c1f..5656c41d8 100644 --- a/Proxytrace.Storage/Internal/Entities/OptimizationProposal/OptimizationProposalRepository.cs +++ b/Proxytrace.Storage/Internal/Entities/OptimizationProposal/OptimizationProposalRepository.cs @@ -35,8 +35,13 @@ public async Task> GetByAgentAsync( return await Map(stored, cancellationToken); } - public async Task> GetByProjectAsync( + public Task> GetByProjectAsync( Guid projectId, + CancellationToken cancellationToken = default) => + GetByProjectsAsync([projectId], cancellationToken); + + public async Task> GetByProjectsAsync( + IReadOnlyCollection projectIds, CancellationToken cancellationToken = default) { var context = contextFactory(); @@ -47,7 +52,7 @@ public async Task> GetByProjectAsync( p => p.Agent, a => a.Id, (p, a) => new { Proposal = p, Agent = a }) - .Where(x => x.Agent.Project == projectId) + .Where(x => projectIds.Contains(x.Agent.Project)) .OrderByDescending(x => x.Proposal.CreatedAt) .Select(x => x.Proposal) .ToListAsync(cancellationToken); diff --git a/Proxytrace.Storage/Internal/Entities/TestRun/TestRunRepository.cs b/Proxytrace.Storage/Internal/Entities/TestRun/TestRunRepository.cs index fe268aa07..a04f6664d 100644 --- a/Proxytrace.Storage/Internal/Entities/TestRun/TestRunRepository.cs +++ b/Proxytrace.Storage/Internal/Entities/TestRun/TestRunRepository.cs @@ -4,6 +4,7 @@ using Proxytrace.Domain.Events; using Proxytrace.Domain.Paging; using Proxytrace.Domain.TestRun; +using Proxytrace.Storage.Internal.Entities.Agent; using Proxytrace.Storage.Internal.Entities.TestRunGroup; using Proxytrace.Storage.Internal.Entities.TestSuite; @@ -103,6 +104,43 @@ public async Task> GetAllPagedAsync( return new PagedResult(await Map(stored, cancellationToken), total, page, pageSize); } + public async Task> GetByProjectsPagedAsync( + IReadOnlyCollection projectIds, + int page, + int pageSize, + bool includeSystem = false, + CancellationToken cancellationToken = default) + { + (page, pageSize) = Paging.Clamp(page, pageSize); + var context = contextFactory(); + var query = context + .Set() + .AsNoTracking() + .Join(context.Set(), + r => r.Group, + g => g.Id, + (r, g) => new { Run = r, Group = g }) + .Join(context.Set(), + x => x.Group.Suite, + s => s.Id, + (x, s) => new { x.Run, x.Group, Suite = s }) + .Join(context.Set(), + x => x.Suite.Agent, + a => a.Id, + (x, a) => new { x.Run, x.Group, Agent = a }) + .Where(x => projectIds.Contains(x.Agent.Project) && (includeSystem || !x.Group.IsSystemRun)) + .Select(x => x.Run); + + int total = await query.CountAsync(cancellationToken); + var stored = await query + .OrderByDescending(r => r.CreatedAt) + .Skip(Paging.Offset(page, pageSize)) + .Take(pageSize) + .ToListAsync(cancellationToken); + + return new PagedResult(await Map(stored, cancellationToken), total, page, pageSize); + } + public async Task> GetByGroupAsync(Guid groupId, CancellationToken cancellationToken = default) { var context = contextFactory(); diff --git a/docs/architecture.md b/docs/architecture.md index b338df4ac..d8b3c8912 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -105,6 +105,13 @@ repository method (`GetByProjectsPagedAsync`) rather than merging per-project pa splits a scope into the `(ProjectId, ProjectIds)` pair `AgentCallFilter` takes, so the hot traces query keeps its equality predicate whenever the scope names a single project. +An endpoint that can also be narrowed by a **related** entity (`agentId`, `suiteId`) resolves the +scope first and then checks that entity against it — it never replaces the scope, and a named entity +the caller may not reach collapses the scope to empty. `TestRunGroupsController.ListScopeAsync` and +`ProposalsController.ListScopeAsync` are the shape to copy. An endpoint with *no* `projectId` +parameter at all still resolves a scope (`ResolveListScopeAsync(requestedProjectId: null)`) — that is +how `GET /api/test-runs` answers a non-admin with their own runs instead of nothing. + Aggregates built on `StatisticsFilter` take the same `(ProjectId, ProjectIds)` pair (#483), so the traces overview aggregates over a multi-project scope like the lists beside it. `ToFilterScope()` feeds both filters, so a scope naming one project keeps the single-project predicate in both. The set diff --git a/manual/admin/providers-and-api-keys.md b/manual/admin/providers-and-api-keys.md index 3d47be733..e80b9d009 100644 --- a/manual/admin/providers-and-api-keys.md +++ b/manual/admin/providers-and-api-keys.md @@ -126,9 +126,10 @@ Each key also carries explicit **capabilities** (least privilege), chosen when y - **REST API write** — additionally create and change data over the REST API (`POST`/`PUT`/`PATCH`/ `DELETE`). A REST key acts as its owner and, like an MCP key, can never reach admin-only endpoints. -A REST key is confined to its own project, so list endpoints that take an optional `projectId` return -that project's rows whether or not you pass one — there is no need to repeat the project on every -call, and a key can never widen its reach by omitting it. The confinement holds for the projects +A REST key is confined to its own project, so list endpoints return that project's rows whether or +not you pass an optional `projectId` — there is no need to repeat the project on every call, and a +key can never widen its reach by omitting it. Endpoints without a project filter at all, such as +`GET /api/test-runs`, are scoped the same way. The confinement holds for the projects themselves: `GET /api/projects` lists only the key's project, and reading any other project (or its members) answers `404`, regardless of what the key's owner could see when signed in.