518 deactivating a tenant should bring down all the agents into an deactivated state - #530
Conversation
Disabling a tenant only flipped Tenant.Enabled and emitted tenant.disabled; its activations stayed active, Temporal workflows kept running and schedules kept firing. UpdateTenant now deactivates every active activation of the tenant whenever enabled=false is sent, one at a time, via ActivationService.DeactivateAgentAsync (cancel/terminate workflows, delete schedules, mark inactive).
|
🔍 PR Review in Progress Claude Code is analyzing this pull request. The review will be posted here shortly. PR Reviewer (1.9.9) |
hasith
left a comment
There was a problem hiding this comment.
PR Review Report
PR: 518 deactivating a tenant should bring down all the agents into an deactivated state
Author: Diyanada Gunawrdena
Files Changed: 6 | +185 / -3
Verdict: REQUEST CHANGES
Summary
This PR closes a real gap: disabling a tenant previously left its agents' Temporal workflows and schedules running. The fix introduces a background TenantAgentDeactivationService that queues and asynchronously deactivates a disabled tenant's active activations outside the HTTP request path, which is a sound design for avoiding request timeouts. The main concerns are (1) the new service — including a synthetic SysAdmin-privileged tenant context used to drive deactivation — ships with zero unit tests, and (2) a few correctness/robustness edges (tenant re-enabled mid-run, unchecked enqueue result, unvalidated input) that are worth tightening before merge.
Critical Issues (Must Fix)
-
XiansAi.Server.Src/Shared/Services/TenantAgentDeactivationService.cs:33— The newTenantAgentDeactivationServicehas no unit tests at all, despite containing the entire feature's logic:Enqueuededup via_pending, background job execution, the re-enabled-before-job-runs guard, and per-activation failure handling. It also constructs a syntheticITenantContextwith a globalSysAdminrole (line 111) to driveActivationService.DeactivateAgentAsync— untested code that fabricates elevated privileges is a higher-than-usual risk to ship without coverage.// Missing: XiansAi.Server.Tests/UnitTests/Shared/Services/TenantAgentDeactivationServiceTests.cs // covering: Enqueue returns false while a tenant is already pending; ExecuteAsync processes a // queued job and clears _pending; tenant re-enabled before the job runs is skipped; per-activation // failures are logged and counted without aborting the batch.
(1 critical issue found.)
Warnings (Should Fix)
-
XiansAi.Server.Tests/UnitTests/Shared/Services/TenantServiceMetadataTests.cs:247—UpdateTenant_WhenOnlyEnabledChanges_EmitsDisabledNotUpdateddisables a tenant but never asserts_agentDeactivationQueue.Enqueue(...)was called, and there's no test confirming a repeatenabled=falsequeues the tenant again (the behavior the new code comment explicitly documents). Add_agentDeactivationQueue.Verify(x => x.Enqueue(stored.TenantId, It.IsAny<string>()), Times.Once);to this test, plus a new test for the repeat-disable case. -
XiansAi.Server.Src/Shared/Services/TenantAgentDeactivationService.cs:131—tenant.Enabledis only checked once before the deactivation loop starts. For a tenant with many activations (each deactivation makes a multi-second Temporal call), an admin re-enabling the tenant mid-run has no effect — the loop keeps tearing down the remaining activations anyway. Re-checktenant.Enabled(or re-fetch) inside the loop and stop early if it flips back to enabled. -
XiansAi.Server.Src/Shared/Services/TenantAgentDeactivationService.cs:131— Activations are deactivated strictly sequentially inside the single-reader background worker. A tenant with a large activation count (or one slow/hung Temporal call) monopolizes the only worker loop and blocks every other tenant's queued disable job behind it. Consider bounded intra-job parallelism (e.g.Parallel.ForEachAsyncwith a cap) so one tenant's backlog can't starve others. -
XiansAi.Server.Src/Shared/Services/TenantAgentDeactivationService.cs:111— The synthetic job context is stamped with the globalSystemRoles.SysAdminrole andAuthorizedTenantIds = [tenantId]even though nothing in the current call path (GetActiveActivationsAsync,DeactivateAgentAsync) appears to require it. Granting the broadest system role by default is an unaudited privilege widening that becomes exploitable the moment any code later resolved from this scope checks forSysAdmin. Scope the context to the minimum needed (tenant id + actor for audit attribution) unless a specific downstream check is known to require the elevated role. -
XiansAi.Server.Src/Shared/Services/TenantService.cs:836—_agentDeactivationQueue.Enqueue(...)'s return value (false = already pending, or the channel write failed) is discarded. If the write genuinely fails, the tenant is disabled but its agents silently never get queued for deactivation, with nothing surfaced to the caller or logs at the call site. At minimum log a warning here whenEnqueuereturnsfalsefor a reason other than "already pending". -
XiansAi.Server.Src/Shared/Services/TenantAgentDeactivationService.cs:56—Enqueue'srequestedByparameter isn't validated the waytenantIdis, even though it's written straight intotenantContext.LoggedInUserfor the audit trail of every deactivation performed. A null/blank value flows through unnoticed. Guard it alongside the existingtenantIdcheck.if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(requestedBy) || !_pending.TryAdd(tenantId, 0))
(6 warnings found.)
Suggestions (Consider Improving)
-
XiansAi.Server.Src/Shared/Services/TenantAgentDeactivationService.cs:121—GetActiveActivationsAsyncloads the tenant's entire active-activation set into memory in one call, held for the whole (potentially long) sequential run. Not a problem at current scale, but consider batching/paging for tenants with very large activation counts. -
XiansAi.Server.Src/Shared/Services/TenantAgentDeactivationService.cs:110— Audit entries for each deactivation are attributed solely tojob.RequestedBy(the admin who disabled the tenant), with no indication the action actually executed asynchronously via the background service, possibly much later. Consider also recording that execution was system-driven (e.g. aperformedBy: "system:tenant-deactivation"field) alongsiderequestedBy, so the audit trail distinguishes "who asked" from "what executed".
Review Details
Code Quality
Overall clean: the new service is well documented (XML comments explain the queue/worker split and its best-effort nature), uses an appropriately configured Channel for the single-writer/multi-reader-producer pattern, and wires DI correctly (one singleton instance exposed under three interfaces). Minor gap: Enqueue's requestedBy isn't null/whitespace-validated like tenantId is (see warning above).
Security
The core design — queuing deactivation off the request path — is sound, but the synthetic ITenantContext built per job grants a full SysAdmin role and tenant-scoped authorization unconditionally (warning above). This isn't exploitable today as far as the current call graph shows, but it's an unaudited privilege grant that should be minimized rather than relied on to stay unused. No secrets, injection, or input-validation issues were found elsewhere in the diff; LogSanitizer.Sanitize is already applied to all logged identifiers.
Test Coverage
This is the biggest gap in the PR: the new TenantAgentDeactivationService — the entire feature — ships with no unit tests, and the one existing test touched by this change only adds the new mock dependency without asserting the new behavior it's supposed to protect. See the Critical and first Warning items above.
Performance
No N+1 queries or unbounded memory growth; GetActiveActivationsAsync is a single query per job and _pending/the channel scale with the number of tenants being disabled, not activations. The sequential, single-worker processing model is an intentional simplicity trade-off per the code's own comments, but it means a large or slow tenant can starve other tenants' queued deactivations (warning above).
Files Reviewed
| File | Lines Changed | Risk | Notes |
|---|---|---|---|
XiansAi.Server.Src/Shared/Services/TenantAgentDeactivationService.cs |
+160/-0 | 🔴 High | New background service; impersonated SysAdmin context; untested |
XiansAi.Server.Src/Shared/Services/TenantService.cs |
+14/-1 | 🟡 Medium | Wires new dependency; enqueues deactivation on disable |
XiansAi.Server.Src/Shared/Configuration/SharedServices.cs |
+5/-0 | 🟢 Low | DI registration |
XiansAi.Server.Tests/UnitTests/Shared/Services/TenantServiceMetadataTests.cs |
+2/-1 | 🟡 Medium | Mock wiring added, no new assertions |
XiansAi.Server.Src/Features/AdminApi/Endpoints/AdminTenantEndpoints.cs |
+2/-0 | 🟢 Low | Docs only |
XiansAi.Server.Src/Features/WebApi/Endpoints/TenantEndpoints.cs |
+1/-1 | 🟢 Low | Docs only |
| /// Best-effort: failures are logged only. The queue is in memory, so jobs not yet finished when the | ||
| /// server stops are lost; re-sending enabled=false for the tenant queues it again. | ||
| /// </summary> | ||
| public sealed class TenantAgentDeactivationService : BackgroundService, ITenantAgentDeactivationQueue |
There was a problem hiding this comment.
CRITICAL: The new TenantAgentDeactivationService has no unit tests at all, despite containing the entire feature's logic: Enqueue dedup via _pending, background job execution, the re-enabled-before-job-runs guard, and per-activation failure handling. It also constructs a synthetic ITenantContext with a global SysAdmin role to drive ActivationService.DeactivateAgentAsync (see line 111) — untested code that fabricates elevated privileges is a higher-than-usual risk to ship without coverage.
Fix: Add XiansAi.Server.Tests/UnitTests/Shared/Services/TenantAgentDeactivationServiceTests.cs covering: Enqueue returns false while a tenant is already pending; ExecuteAsync processes a queued job and clears _pending; a tenant re-enabled before the job runs is skipped; per-activation failures are logged and counted without aborting the batch.
| var deactivated = 0; | ||
| var failed = 0; | ||
|
|
||
| foreach (var activation in activations) |
There was a problem hiding this comment.
WARNING: tenant.Enabled is only checked once before the deactivation loop starts. For a tenant with many activations (each deactivation makes a multi-second Temporal call), an admin re-enabling the tenant mid-run has no effect — the loop keeps tearing down the remaining activations anyway.
Fix: Re-check tenant.Enabled (or re-fetch it) inside the loop and stop early if it has flipped back to enabled.
| var deactivated = 0; | ||
| var failed = 0; | ||
|
|
||
| foreach (var activation in activations) |
There was a problem hiding this comment.
WARNING: Activations are deactivated strictly sequentially inside the single-reader background worker (SingleReader = true on the channel, one ExecuteAsync loop). A tenant with a large activation count, or one slow/hung Temporal call, monopolizes the only worker loop and blocks every other tenant's queued disable job behind it.
Fix: Consider bounded intra-job parallelism (e.g. Parallel.ForEachAsync with a capped degree of parallelism) so one tenant's backlog can't starve others, while still keeping cross-tenant jobs serialized or on a small worker pool.
| var tenantContext = services.GetRequiredService<ITenantContext>(); | ||
| tenantContext.TenantId = job.TenantId; | ||
| tenantContext.LoggedInUser = job.RequestedBy; | ||
| tenantContext.UserRoles = new[] { SystemRoles.SysAdmin }; |
There was a problem hiding this comment.
WARNING: The synthetic job context is stamped with the global SystemRoles.SysAdmin role and AuthorizedTenantIds = [tenantId] even though nothing in the current call path (GetActiveActivationsAsync, DeactivateAgentAsync) appears to require it. Granting the broadest system role by default is an unaudited privilege widening that becomes exploitable the moment any code later resolved from this scope checks for SysAdmin.
Fix: Scope the context to the minimum needed (tenant id + actor for audit attribution) unless a specific downstream check is known to require the elevated role.
| // Re-sending enabled=false queues it again. Re-enabling does not bring agents back. | ||
| if (result.IsSuccess && requestedEnabled == false) | ||
| { | ||
| _agentDeactivationQueue.Enqueue(existingTenant.TenantId, _tenantContext.LoggedInUser); |
There was a problem hiding this comment.
WARNING: _agentDeactivationQueue.Enqueue(...)'s return value (false = already pending, or the channel write failed) is discarded. If the write genuinely fails, the tenant is disabled but its agents silently never get queued for deactivation, with nothing surfaced to the caller or logs at the call site.
Fix: At minimum, log a warning here when Enqueue returns false for a reason other than "already pending".
|
|
||
| public bool Enqueue(string tenantId, string requestedBy) | ||
| { | ||
| if (string.IsNullOrWhiteSpace(tenantId) || !_pending.TryAdd(tenantId, 0)) |
There was a problem hiding this comment.
WARNING: Enqueue's requestedBy parameter isn't validated the way tenantId is, even though it's written straight into tenantContext.LoggedInUser for the audit trail of every deactivation performed. A null/blank value flows through unnoticed.
Fix: Guard it alongside the existing tenantId check.
| if (string.IsNullOrWhiteSpace(tenantId) || !_pending.TryAdd(tenantId, 0)) | |
| if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(requestedBy) || !_pending.TryAdd(tenantId, 0)) |
| return; | ||
| } | ||
|
|
||
| var activations = await services.GetRequiredService<IActivationRepository>().GetActiveActivationsAsync(job.TenantId); |
There was a problem hiding this comment.
SUGGESTION: GetActiveActivationsAsync loads the tenant's entire active-activation set into memory in one call, held for the whole (potentially long) sequential run. Not a problem at current scale, but it doesn't scale for tenants with very large activation counts.
Fix: Consider batching/paging the activation list for very large tenants rather than materializing it all up front.
| // actor and tenant from the scoped tenant context. | ||
| var tenantContext = services.GetRequiredService<ITenantContext>(); | ||
| tenantContext.TenantId = job.TenantId; | ||
| tenantContext.LoggedInUser = job.RequestedBy; |
There was a problem hiding this comment.
SUGGESTION: Audit entries for each deactivation are attributed solely to job.RequestedBy (the admin who disabled the tenant), with no indication the action actually executed asynchronously via the background service, possibly much later.
Fix: Consider also recording that execution was system-driven (e.g. a performedBy: "system:tenant-deactivation" field) alongside requestedBy, so the audit trail distinguishes "who asked" from "what executed".
| _knowledgeRepository.Object, | ||
| _audit.Object); | ||
| _audit.Object, | ||
| _agentDeactivationQueue.Object); |
There was a problem hiding this comment.
WARNING: UpdateTenant_WhenOnlyEnabledChanges_EmitsDisabledNotUpdated (the test touched by this diff) disables a tenant but never asserts _agentDeactivationQueue.Enqueue(...) was called, and there's no test confirming a repeat enabled=false queues the tenant again (the behavior the new code comment explicitly documents).
Fix: After wiring _agentDeactivationQueue into the constructor here, add _agentDeactivationQueue.Verify(x => x.Enqueue(stored.TenantId, It.IsAny<string>()), Times.Once); to that test, plus a new test for the repeat-disable case.
No description provided.