Skip to content

feat: let tenants configure executor approval gates via the API - #1

Merged
Nshai merged 1 commit into
masterfrom
feat/tenant-executor-gate-configuration
Aug 2, 2026
Merged

feat: let tenants configure executor approval gates via the API#1
Nshai merged 1 commit into
masterfrom
feat/tenant-executor-gate-configuration

Conversation

@Nshai

@Nshai Nshai commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Executor nodes default to autonomous, as before. Tenants can now inspect a workflow version's nodes and set, per executor, whether it runs autonomously or waits for approval, without a redeploy.

  • GET/PUT/DELETE /workflows/{name}/versions/{version}/nodes[/{executorId}] list nodes with their declared, tenant, and effective gates, and write or drop the calling tenant's policy. Bulk writes are all-or-nothing.
  • IGatePolicyStore gains a tenant dimension (null = host-wide) plus List and Remove; the runner resolves gates for instance.TenantId. Precedence is instance override, tenant policy, host policy, definition, autonomous.
  • ApprovalGate.Locked marks a declared gate as the author's floor. Tenant config may tighten it; weakening is refused with 409 and re-tightened on read, so a policy stored by another route cannot un-gate an executor.
  • WorkflowInspector builds a definition once per version against an inspection context to read its nodes; results are cached.

Also fixes SqlServerGatePolicyStore.FindAsync returning only the instance-scoped key instead of falling through to the workflow scope, and marks ApprovalGate.Predicate [JsonIgnore] - that store round-trips gates through JsonSerializer, which would have thrown on the delegate.

Executor nodes default to autonomous, as before. Tenants can now inspect a
workflow version's nodes and set, per executor, whether it runs autonomously
or waits for approval, without a redeploy.

- GET/PUT/DELETE /workflows/{name}/versions/{version}/nodes[/{executorId}]
  list nodes with their declared, tenant, and effective gates, and write or
  drop the calling tenant's policy. Bulk writes are all-or-nothing.
- IGatePolicyStore gains a tenant dimension (null = host-wide) plus List and
  Remove; the runner resolves gates for instance.TenantId. Precedence is
  instance override, tenant policy, host policy, definition, autonomous.
- ApprovalGate.Locked marks a declared gate as the author's floor. Tenant
  config may tighten it; weakening is refused with 409 and re-tightened on
  read, so a policy stored by another route cannot un-gate an executor.
- WorkflowInspector builds a definition once per version against an
  inspection context to read its nodes; results are cached.

Also fixes SqlServerGatePolicyStore.FindAsync returning only the
instance-scoped key instead of falling through to the workflow scope, and
marks ApprovalGate.Predicate [JsonIgnore] - that store round-trips gates
through JsonSerializer, which would have thrown on the delegate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Nshai
Nshai requested a review from Copilot August 2, 2026 22:57
@Nshai
Nshai merged commit 651905e into master Aug 2, 2026
5 checks passed

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces per-tenant configuration of executor approval gates via a new API surface, allowing tenants to override (or remove) approval requirements for specific workflow executors at runtime, while preserving author-declared “locked” gate floors. It also extends gate policy storage to be tenant-aware (with host-wide fallback), adds workflow graph inspection/caching to support node discovery, and updates tests/docs accordingly.

Changes:

  • Add node-configuration endpoints (GET/PUT/DELETE /workflows/{name}/versions/{version}/nodes...) backed by GateConfigurationService.
  • Extend IGatePolicyStore with a tenant dimension plus ListAsync/RemoveAsync, and update runner gate resolution to use instance.TenantId.
  • Add locked-gate reconciliation rules (GatePolicyRules) and workflow node inspection (WorkflowInspector) with caching.

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
tests/Abacus.Run.UnitTests/GateEvaluatorTests.cs Extends evaluator tests for tenant-vs-host precedence and locked-gate behavior.
tests/Abacus.Run.UnitTests/GateConfigurationTests.cs Adds unit coverage for node discovery, policy validation, locked-floor enforcement, auditing, and version scoping.
tests/Abacus.Run.UnitTests/CheckpointAndStoreTests.cs Updates/extends in-memory gate policy store tests for tenant scoping, listing, and removal fallback.
tests/Abacus.Run.IntegrationTests/TenantGateConfigurationTests.cs Adds integration coverage for node configuration API and tenant-scoped runtime effects.
tests/Abacus.Run.IntegrationTests/HostFixture.cs Registers a tenant-configurable workflow and adds tenant header support to the test start helper.
src/Abacus.Run/Persistence/InMemoryStores.cs Updates InMemoryGatePolicyStore to support tenant scope + list/remove and host fallback.
src/Abacus.Run/Core/WorkflowRunner.cs Passes instance.TenantId into GateEvaluator for tenant-scoped resolution.
src/Abacus.Run/Core/WorkflowInspector.cs New inspector that builds workflow definitions in an inspection context and caches node descriptors.
src/Abacus.Run/Core/Stores.cs Updates IGatePolicyStore contract with tenant dimension + list/remove semantics.
src/Abacus.Run/Core/GatePolicyRules.cs New logic to validate and reconcile tenant policies against locked author declarations.
src/Abacus.Run/Core/GateEvaluator.cs Updates gate precedence to include tenant + host scopes and applies locked-floor reconciliation.
src/Abacus.Run/Api/HostBuilderExtensions.cs Registers WorkflowInspector and GateConfigurationService in DI.
src/Abacus.Run/Api/GateConfigurationService.cs Implements node projection, validation, all-or-nothing writes, and audit logging.
src/Abacus.Run/Api/Endpoints.cs Maps node-configuration endpoints and adds GateConfigResult → HTTP result translation.
src/Abacus.Run/Abstractions/WorkflowDefinition.cs Introduces WorkflowNodeDescriptor and records nodes during workflow build (incl. raw nodes).
src/Abacus.Run/Abstractions/Approvals.cs Adds ApprovalGate.Locked and marks Predicate as [JsonIgnore].
src/Abacus.Run.Service/Infrastructure/SqlServerInfrastructureStores.cs Extends SQL gate policy store to support tenant/host scoping and list/remove.
README.md Documents gate declaration and new tenant node-configuration API surface.
docs/wiki.md Adds design/operational documentation for tenant executor configuration, precedence, and locked floors.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +250 to +252
/// <summary>Host-wide policies are stored under a scope no tenant id can collide with.</summary>
private const string HostScope = "host";

Comment on lines +278 to +281
Dictionary<string, ApprovalGate> matches = _policies
.Where(entry => entry.Key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
.ToDictionary(entry => entry.Key[prefix.Length..], entry => entry.Value, StringComparer.Ordinal);

Comment on lines +24 to +47
private readonly IServiceProvider? _services;
private readonly ConcurrentDictionary<string, IReadOnlyList<WorkflowNodeDescriptor>> _cache =
new(StringComparer.OrdinalIgnoreCase);

public WorkflowInspector(IServiceProvider? services = null) => _services = services;

public async ValueTask<IReadOnlyList<WorkflowNodeDescriptor>> InspectAsync(
WorkflowDescriptor descriptor, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(descriptor);

string key = $"{descriptor.Name}|{descriptor.Version}";
if (_cache.TryGetValue(key, out IReadOnlyList<WorkflowNodeDescriptor>? cached))
{
return cached;
}

var context = WorkflowBuildContext.ForInspection(descriptor.Name, descriptor.Version, _services);
await descriptor.Definition.BuildAsync(context, cancellationToken).ConfigureAwait(false);

IReadOnlyList<WorkflowNodeDescriptor> nodes = [.. context.Nodes];
_cache[key] = nodes;
return nodes;
}
Comment on lines +106 to +111
// Highest scope first, falling through to the next when nothing is stored at that level.
var keys = new List<string>(3);
if (instanceId is { Length: > 0 }) keys.Add($"gate-instance:{instanceId}:{executorId}");
if (tenantId is { Length: > 0 }) keys.Add(PolicyKey(tenantId, workflowName, workflowVersion, executorId));
keys.Add(PolicyKey(null, workflowName, workflowVersion, executorId));

Comment on lines 100 to +105
public sealed class SqlServerGatePolicyStore(IDbContextFactory<AbacusDbContext> factory) : IGatePolicyStore
{
public async ValueTask<ApprovalGate?> FindAsync(string workflowName, string workflowVersion, string executorId, string? instanceId, CancellationToken cancellationToken)
private const string HostScope = "*";

public async ValueTask<ApprovalGate?> FindAsync(string? tenantId, string workflowName, string workflowVersion, string executorId, string? instanceId, CancellationToken cancellationToken)
{
Comment on lines +318 to +321
if (tenantId is not null)
{
request.Headers.Add("X-Tenant-Id", tenantId);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants