up the blokcing tasks
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Risk Bundle CI / risk-bundle-build (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Risk Bundle CI / risk-bundle-offline-kit (push) Has been cancelled
Risk Bundle CI / publish-checksums (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-11 02:32:18 +02:00
parent 92bc4d3a07
commit 49922dff5a
474 changed files with 76071 additions and 12411 deletions

View File

@@ -0,0 +1,401 @@
using System.Collections.Concurrent;
using System.Net;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
namespace StellaOps.TaskRunner.Core.Tenancy;
/// <summary>
/// Interface for tenant egress policy enforcement per TASKRUN-TEN-48-001.
/// Controls outbound network access based on tenant restrictions.
/// </summary>
public interface ITenantEgressPolicy
{
/// <summary>
/// Checks whether egress to a given URI is allowed for the tenant.
/// </summary>
ValueTask<EgressPolicyResult> CheckEgressAsync(
TenantContext tenant,
Uri targetUri,
CancellationToken cancellationToken = default);
/// <summary>
/// Checks whether egress to a given host and port is allowed for the tenant.
/// </summary>
ValueTask<EgressPolicyResult> CheckEgressAsync(
TenantContext tenant,
string host,
int port,
CancellationToken cancellationToken = default);
/// <summary>
/// Records an egress attempt for auditing.
/// </summary>
ValueTask RecordEgressAttemptAsync(
TenantContext tenant,
string runId,
Uri targetUri,
EgressPolicyResult result,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of an egress policy check.
/// </summary>
public sealed record EgressPolicyResult
{
public static EgressPolicyResult Allowed { get; } = new() { IsAllowed = true };
public static EgressPolicyResult BlockedByTenant(string reason) => new()
{
IsAllowed = false,
BlockReason = EgressBlockReason.TenantRestriction,
Message = reason
};
public static EgressPolicyResult BlockedByGlobalPolicy(string reason) => new()
{
IsAllowed = false,
BlockReason = EgressBlockReason.GlobalPolicy,
Message = reason
};
public static EgressPolicyResult BlockedBySuspension(string reason) => new()
{
IsAllowed = false,
BlockReason = EgressBlockReason.TenantSuspended,
Message = reason
};
public bool IsAllowed { get; init; }
public EgressBlockReason? BlockReason { get; init; }
public string? Message { get; init; }
public DateTimeOffset? CheckedAt { get; init; } = DateTimeOffset.UtcNow;
}
/// <summary>
/// Reason for egress being blocked.
/// </summary>
public enum EgressBlockReason
{
/// <summary>
/// Blocked by tenant-specific restrictions.
/// </summary>
TenantRestriction,
/// <summary>
/// Blocked by global policy (blocklist).
/// </summary>
GlobalPolicy,
/// <summary>
/// Blocked because tenant is suspended.
/// </summary>
TenantSuspended,
/// <summary>
/// Blocked because egress is disabled for this environment.
/// </summary>
EgressDisabled
}
/// <summary>
/// Record of an egress attempt for auditing.
/// </summary>
public sealed record EgressAttemptRecord(
string TenantId,
string ProjectId,
string RunId,
Uri TargetUri,
bool WasAllowed,
EgressBlockReason? BlockReason,
string? BlockMessage,
DateTimeOffset Timestamp);
/// <summary>
/// Default implementation of tenant egress policy.
/// </summary>
public sealed partial class TenantEgressPolicy : ITenantEgressPolicy
{
private readonly TenantEgressPolicyOptions _options;
private readonly IEgressAuditLog _auditLog;
private readonly ILogger<TenantEgressPolicy> _logger;
private readonly HashSet<string> _globalAllowlist;
private readonly HashSet<string> _globalBlocklist;
public TenantEgressPolicy(
TenantEgressPolicyOptions options,
IEgressAuditLog auditLog,
ILogger<TenantEgressPolicy> logger)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_auditLog = auditLog ?? throw new ArgumentNullException(nameof(auditLog));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_globalAllowlist = new HashSet<string>(
options.GlobalAllowlist.Select(NormalizeHost),
StringComparer.OrdinalIgnoreCase);
_globalBlocklist = new HashSet<string>(
options.GlobalBlocklist.Select(NormalizeHost),
StringComparer.OrdinalIgnoreCase);
}
public ValueTask<EgressPolicyResult> CheckEgressAsync(
TenantContext tenant,
Uri targetUri,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(tenant);
ArgumentNullException.ThrowIfNull(targetUri);
return CheckEgressAsync(tenant, targetUri.Host, targetUri.Port, cancellationToken);
}
public ValueTask<EgressPolicyResult> CheckEgressAsync(
TenantContext tenant,
string host,
int port,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(host);
var normalizedHost = NormalizeHost(host);
// Check if tenant is suspended
if (tenant.Restrictions.Suspended)
{
_logger.LogWarning(
"Egress blocked for suspended tenant {TenantId} to {Host}:{Port}.",
tenant.TenantId,
host,
port);
return ValueTask.FromResult(
EgressPolicyResult.BlockedBySuspension("Tenant is suspended."));
}
// Check global blocklist first
if (IsInList(_globalBlocklist, normalizedHost))
{
_logger.LogWarning(
"Egress blocked by global blocklist for tenant {TenantId} to {Host}:{Port}.",
tenant.TenantId,
host,
port);
return ValueTask.FromResult(
EgressPolicyResult.BlockedByGlobalPolicy($"Host {host} is in global blocklist."));
}
// Check if tenant egress is completely blocked
if (tenant.Restrictions.EgressBlocked)
{
// Check tenant-specific allowlist
if (!tenant.Restrictions.AllowedEgressDomains.IsDefaultOrEmpty)
{
var tenantAllowlist = new HashSet<string>(
tenant.Restrictions.AllowedEgressDomains.Select(NormalizeHost),
StringComparer.OrdinalIgnoreCase);
if (IsInList(tenantAllowlist, normalizedHost))
{
_logger.LogDebug(
"Egress allowed via tenant allowlist for {TenantId} to {Host}:{Port}.",
tenant.TenantId,
host,
port);
return ValueTask.FromResult(EgressPolicyResult.Allowed);
}
}
_logger.LogWarning(
"Egress blocked by tenant restriction for {TenantId} to {Host}:{Port}.",
tenant.TenantId,
host,
port);
return ValueTask.FromResult(
EgressPolicyResult.BlockedByTenant($"Egress blocked for tenant {tenant.TenantId}."));
}
// Check global allowlist (if not allowing by default)
if (!_options.AllowByDefault)
{
if (!IsInList(_globalAllowlist, normalizedHost))
{
_logger.LogWarning(
"Egress blocked (not in allowlist) for tenant {TenantId} to {Host}:{Port}.",
tenant.TenantId,
host,
port);
return ValueTask.FromResult(
EgressPolicyResult.BlockedByGlobalPolicy($"Host {host} is not in allowlist."));
}
}
_logger.LogDebug(
"Egress allowed for tenant {TenantId} to {Host}:{Port}.",
tenant.TenantId,
host,
port);
return ValueTask.FromResult(EgressPolicyResult.Allowed);
}
public async ValueTask RecordEgressAttemptAsync(
TenantContext tenant,
string runId,
Uri targetUri,
EgressPolicyResult result,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
ArgumentNullException.ThrowIfNull(targetUri);
ArgumentNullException.ThrowIfNull(result);
var record = new EgressAttemptRecord(
TenantId: tenant.TenantId,
ProjectId: tenant.ProjectId,
RunId: runId,
TargetUri: targetUri,
WasAllowed: result.IsAllowed,
BlockReason: result.BlockReason,
BlockMessage: result.Message,
Timestamp: DateTimeOffset.UtcNow);
await _auditLog.RecordAsync(record, cancellationToken).ConfigureAwait(false);
if (!result.IsAllowed && _options.LogBlockedAttempts)
{
_logger.LogWarning(
"Egress attempt blocked: Tenant={TenantId}, Run={RunId}, Target={TargetUri}, Reason={Reason}",
tenant.TenantId,
runId,
targetUri,
result.Message);
}
}
private static string NormalizeHost(string host)
{
var normalized = host.Trim().ToLowerInvariant();
if (normalized.StartsWith("*."))
{
return normalized; // Keep wildcard prefix
}
return normalized;
}
private static bool IsInList(HashSet<string> list, string host)
{
// Exact match
if (list.Contains(host))
{
return true;
}
// Wildcard match (*.example.com matches sub.example.com)
var parts = host.Split('.');
for (var i = 1; i < parts.Length; i++)
{
var wildcard = "*." + string.Join('.', parts[i..]);
if (list.Contains(wildcard))
{
return true;
}
}
return false;
}
}
/// <summary>
/// Interface for egress audit logging.
/// </summary>
public interface IEgressAuditLog
{
ValueTask RecordAsync(EgressAttemptRecord record, CancellationToken cancellationToken = default);
IAsyncEnumerable<EgressAttemptRecord> GetRecordsAsync(
string tenantId,
string? runId = null,
DateTimeOffset? since = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// In-memory implementation of egress audit log for testing.
/// </summary>
public sealed class InMemoryEgressAuditLog : IEgressAuditLog
{
private readonly ConcurrentBag<EgressAttemptRecord> _records = [];
public ValueTask RecordAsync(EgressAttemptRecord record, CancellationToken cancellationToken = default)
{
_records.Add(record);
return ValueTask.CompletedTask;
}
public async IAsyncEnumerable<EgressAttemptRecord> GetRecordsAsync(
string tenantId,
string? runId = null,
DateTimeOffset? since = null,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.Yield();
var query = _records
.Where(r => r.TenantId.Equals(tenantId, StringComparison.Ordinal));
if (runId is not null)
{
query = query.Where(r => r.RunId.Equals(runId, StringComparison.Ordinal));
}
if (since.HasValue)
{
query = query.Where(r => r.Timestamp >= since.Value);
}
foreach (var record in query.OrderBy(r => r.Timestamp))
{
cancellationToken.ThrowIfCancellationRequested();
yield return record;
}
}
/// <summary>
/// Gets all records (for testing).
/// </summary>
public IReadOnlyList<EgressAttemptRecord> GetAllRecords() => [.. _records];
}
/// <summary>
/// Null implementation of egress audit log.
/// </summary>
public sealed class NullEgressAuditLog : IEgressAuditLog
{
public static NullEgressAuditLog Instance { get; } = new();
public ValueTask RecordAsync(EgressAttemptRecord record, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
public async IAsyncEnumerable<EgressAttemptRecord> GetRecordsAsync(
string tenantId,
string? runId = null,
DateTimeOffset? since = null,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.Yield();
yield break;
}
}

View File

@@ -0,0 +1,261 @@
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.TaskRunner.Core.Tenancy;
/// <summary>
/// Interface for resolving tenant-scoped storage paths per TASKRUN-TEN-48-001.
/// Ensures all pack run storage (state, logs, artifacts) uses tenant-prefixed paths.
/// </summary>
public interface ITenantScopedStoragePathResolver
{
/// <summary>
/// Gets the tenant-prefixed path for run state storage.
/// </summary>
string GetStatePath(TenantContext tenant, string runId);
/// <summary>
/// Gets the tenant-prefixed path for run logs storage.
/// </summary>
string GetLogsPath(TenantContext tenant, string runId);
/// <summary>
/// Gets the tenant-prefixed path for run artifacts storage.
/// </summary>
string GetArtifactsPath(TenantContext tenant, string runId);
/// <summary>
/// Gets the tenant-prefixed path for approval records storage.
/// </summary>
string GetApprovalsPath(TenantContext tenant, string runId);
/// <summary>
/// Gets the tenant-prefixed path for provenance records storage.
/// </summary>
string GetProvenancePath(TenantContext tenant, string runId);
/// <summary>
/// Gets the tenant prefix for database collection/table queries.
/// </summary>
string GetDatabasePrefix(TenantContext tenant);
/// <summary>
/// Gets the base directory for a tenant's storage.
/// </summary>
string GetTenantBasePath(TenantContext tenant);
/// <summary>
/// Validates that a given path belongs to the specified tenant.
/// </summary>
bool ValidatePathBelongsToTenant(TenantContext tenant, string path);
}
/// <summary>
/// Default implementation of tenant-scoped storage path resolver.
/// </summary>
public sealed class TenantScopedStoragePathResolver : ITenantScopedStoragePathResolver
{
private readonly TenantStoragePathOptions _options;
private readonly string _rootPath;
public TenantScopedStoragePathResolver(
TenantStoragePathOptions options,
string rootPath)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
_rootPath = Path.GetFullPath(rootPath);
}
public string GetStatePath(TenantContext tenant, string runId)
{
ArgumentNullException.ThrowIfNull(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
return BuildPath(_options.StateBasePath, tenant, runId);
}
public string GetLogsPath(TenantContext tenant, string runId)
{
ArgumentNullException.ThrowIfNull(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
return BuildPath(_options.LogsBasePath, tenant, runId);
}
public string GetArtifactsPath(TenantContext tenant, string runId)
{
ArgumentNullException.ThrowIfNull(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
return BuildPath(_options.ArtifactsBasePath, tenant, runId);
}
public string GetApprovalsPath(TenantContext tenant, string runId)
{
ArgumentNullException.ThrowIfNull(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
return BuildPath(_options.ApprovalsBasePath, tenant, runId);
}
public string GetProvenancePath(TenantContext tenant, string runId)
{
ArgumentNullException.ThrowIfNull(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
return BuildPath(_options.ProvenanceBasePath, tenant, runId);
}
public string GetDatabasePrefix(TenantContext tenant)
{
ArgumentNullException.ThrowIfNull(tenant);
return _options.PathStrategy switch
{
TenantPathStrategy.Flat => tenant.FlatPrefix,
TenantPathStrategy.Hashed => ComputeHash(tenant.TenantId),
_ => $"{Sanitize(tenant.TenantId)}:{Sanitize(tenant.ProjectId)}"
};
}
public string GetTenantBasePath(TenantContext tenant)
{
ArgumentNullException.ThrowIfNull(tenant);
return _options.PathStrategy switch
{
TenantPathStrategy.Hierarchical => Path.Combine(
_rootPath,
Sanitize(tenant.TenantId),
Sanitize(tenant.ProjectId)),
TenantPathStrategy.Flat => Path.Combine(
_rootPath,
tenant.FlatPrefix),
TenantPathStrategy.Hashed => Path.Combine(
_rootPath,
ComputeHash(tenant.TenantId),
Sanitize(tenant.ProjectId)),
_ => Path.Combine(_rootPath, tenant.StoragePrefix)
};
}
public bool ValidatePathBelongsToTenant(TenantContext tenant, string path)
{
ArgumentNullException.ThrowIfNull(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(path);
var normalizedPath = Path.GetFullPath(path);
// For hierarchical paths, check that the tenant segment is in the path
return _options.PathStrategy switch
{
TenantPathStrategy.Hierarchical => ContainsTenantSegments(normalizedPath, tenant),
TenantPathStrategy.Flat => normalizedPath.Contains(tenant.FlatPrefix, StringComparison.OrdinalIgnoreCase),
TenantPathStrategy.Hashed => normalizedPath.Contains(ComputeHash(tenant.TenantId), StringComparison.OrdinalIgnoreCase)
&& normalizedPath.Contains(Sanitize(tenant.ProjectId), StringComparison.OrdinalIgnoreCase),
_ => ContainsTenantSegments(normalizedPath, tenant)
};
}
private bool ContainsTenantSegments(string path, TenantContext tenant)
{
// Check that path contains the tenant and project segments in order
var tenantSegment = Path.DirectorySeparatorChar + Sanitize(tenant.TenantId) + Path.DirectorySeparatorChar;
var projectSegment = Path.DirectorySeparatorChar + Sanitize(tenant.ProjectId) + Path.DirectorySeparatorChar;
var tenantIndex = path.IndexOf(tenantSegment, StringComparison.OrdinalIgnoreCase);
if (tenantIndex < 0)
{
return false;
}
var projectIndex = path.IndexOf(projectSegment, tenantIndex + tenantSegment.Length - 1, StringComparison.OrdinalIgnoreCase);
return projectIndex > tenantIndex;
}
private string BuildPath(string basePath, TenantContext tenant, string runId)
{
var safeRunId = Sanitize(runId);
return _options.PathStrategy switch
{
TenantPathStrategy.Hierarchical => Path.Combine(
_rootPath,
basePath,
Sanitize(tenant.TenantId),
Sanitize(tenant.ProjectId),
safeRunId),
TenantPathStrategy.Flat => Path.Combine(
_rootPath,
basePath,
$"{tenant.FlatPrefix}_{safeRunId}"),
TenantPathStrategy.Hashed => Path.Combine(
_rootPath,
basePath,
ComputeHash(tenant.TenantId),
Sanitize(tenant.ProjectId),
safeRunId),
_ => Path.Combine(_rootPath, basePath, tenant.StoragePrefix, safeRunId)
};
}
private static string Sanitize(string value)
{
var result = value.Trim().ToLowerInvariant();
foreach (var invalid in Path.GetInvalidFileNameChars())
{
result = result.Replace(invalid, '_');
}
result = result.Replace('/', '_').Replace('\\', '_');
return string.IsNullOrWhiteSpace(result) ? "unknown" : result;
}
private static string ComputeHash(string value)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value));
return Convert.ToHexStringLower(bytes)[..16]; // First 16 chars of hex hash
}
}
/// <summary>
/// Storage path context for a specific pack run with tenant scoping.
/// </summary>
public sealed record TenantScopedStoragePaths(
string StatePath,
string LogsPath,
string ArtifactsPath,
string ApprovalsPath,
string ProvenancePath,
string DatabasePrefix,
string TenantBasePath)
{
/// <summary>
/// Creates storage paths from resolver and tenant context.
/// </summary>
public static TenantScopedStoragePaths Create(
ITenantScopedStoragePathResolver resolver,
TenantContext tenant,
string runId)
{
ArgumentNullException.ThrowIfNull(resolver);
ArgumentNullException.ThrowIfNull(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
return new TenantScopedStoragePaths(
StatePath: resolver.GetStatePath(tenant, runId),
LogsPath: resolver.GetLogsPath(tenant, runId),
ArtifactsPath: resolver.GetArtifactsPath(tenant, runId),
ApprovalsPath: resolver.GetApprovalsPath(tenant, runId),
ProvenancePath: resolver.GetProvenancePath(tenant, runId),
DatabasePrefix: resolver.GetDatabasePrefix(tenant),
TenantBasePath: resolver.GetTenantBasePath(tenant));
}
}

View File

@@ -0,0 +1,426 @@
using Microsoft.Extensions.Logging;
using StellaOps.TaskRunner.Core.Execution;
namespace StellaOps.TaskRunner.Core.Tenancy;
/// <summary>
/// Enforces tenant context requirements for pack runs per TASKRUN-TEN-48-001.
/// Validates tenant context, enforces concurrent run limits, and propagates context.
/// </summary>
public interface IPackRunTenantEnforcer
{
/// <summary>
/// Validates that a pack run request has valid tenant context.
/// </summary>
ValueTask<TenantEnforcementResult> ValidateRequestAsync(
PackRunTenantRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Creates tenant-scoped execution context for a pack run.
/// </summary>
ValueTask<TenantScopedExecutionContext> CreateExecutionContextAsync(
PackRunTenantRequest request,
string runId,
CancellationToken cancellationToken = default);
/// <summary>
/// Records the start of a pack run for concurrent run tracking.
/// </summary>
ValueTask RecordRunStartAsync(
TenantContext tenant,
string runId,
CancellationToken cancellationToken = default);
/// <summary>
/// Records the completion of a pack run for concurrent run tracking.
/// </summary>
ValueTask RecordRunCompletionAsync(
TenantContext tenant,
string runId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the current concurrent run count for a tenant.
/// </summary>
ValueTask<int> GetConcurrentRunCountAsync(
TenantContext tenant,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Request for a tenant-scoped pack run.
/// </summary>
public sealed record PackRunTenantRequest(
string TenantId,
string ProjectId,
IReadOnlyDictionary<string, string>? Labels = null);
/// <summary>
/// Result of tenant enforcement validation.
/// </summary>
public sealed record TenantEnforcementResult
{
public static TenantEnforcementResult Success(TenantContext tenant) => new()
{
IsValid = true,
Tenant = tenant
};
public static TenantEnforcementResult Failure(string reason, TenantEnforcementFailureKind kind) => new()
{
IsValid = false,
FailureReason = reason,
FailureKind = kind
};
public bool IsValid { get; init; }
public TenantContext? Tenant { get; init; }
public string? FailureReason { get; init; }
public TenantEnforcementFailureKind? FailureKind { get; init; }
}
/// <summary>
/// Kind of tenant enforcement failure.
/// </summary>
public enum TenantEnforcementFailureKind
{
/// <summary>
/// Tenant ID is missing or invalid.
/// </summary>
MissingTenantId,
/// <summary>
/// Project ID is missing or invalid.
/// </summary>
MissingProjectId,
/// <summary>
/// Tenant does not exist or is not found.
/// </summary>
TenantNotFound,
/// <summary>
/// Tenant is suspended.
/// </summary>
TenantSuspended,
/// <summary>
/// Tenant is in read-only mode.
/// </summary>
TenantReadOnly,
/// <summary>
/// Tenant has reached maximum concurrent runs.
/// </summary>
MaxConcurrentRunsReached,
/// <summary>
/// Tenant validation failed for another reason.
/// </summary>
ValidationFailed
}
/// <summary>
/// Tenant-scoped execution context for a pack run.
/// </summary>
public sealed record TenantScopedExecutionContext(
TenantContext Tenant,
TenantScopedStoragePaths StoragePaths,
IReadOnlyDictionary<string, object> LoggingScope);
/// <summary>
/// Default implementation of pack run tenant enforcer.
/// </summary>
public sealed class PackRunTenantEnforcer : IPackRunTenantEnforcer
{
private readonly ITenantContextProvider _tenantProvider;
private readonly ITenantScopedStoragePathResolver _pathResolver;
private readonly TenancyEnforcementOptions _options;
private readonly IConcurrentRunTracker _runTracker;
private readonly ILogger<PackRunTenantEnforcer> _logger;
public PackRunTenantEnforcer(
ITenantContextProvider tenantProvider,
ITenantScopedStoragePathResolver pathResolver,
TenancyEnforcementOptions options,
IConcurrentRunTracker runTracker,
ILogger<PackRunTenantEnforcer> logger)
{
_tenantProvider = tenantProvider ?? throw new ArgumentNullException(nameof(tenantProvider));
_pathResolver = pathResolver ?? throw new ArgumentNullException(nameof(pathResolver));
_options = options ?? throw new ArgumentNullException(nameof(options));
_runTracker = runTracker ?? throw new ArgumentNullException(nameof(runTracker));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask<TenantEnforcementResult> ValidateRequestAsync(
PackRunTenantRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
// Validate tenant ID
if (string.IsNullOrWhiteSpace(request.TenantId))
{
_logger.LogWarning("Pack run request rejected: missing tenant ID.");
return TenantEnforcementResult.Failure(
"Tenant ID is required for pack runs.",
TenantEnforcementFailureKind.MissingTenantId);
}
// Validate project ID (if required)
if (_options.RequireProjectId && string.IsNullOrWhiteSpace(request.ProjectId))
{
_logger.LogWarning(
"Pack run request rejected for tenant {TenantId}: missing project ID.",
request.TenantId);
return TenantEnforcementResult.Failure(
"Project ID is required for pack runs.",
TenantEnforcementFailureKind.MissingProjectId);
}
// Get tenant context
var tenant = await _tenantProvider.GetContextAsync(
request.TenantId,
request.ProjectId,
cancellationToken).ConfigureAwait(false);
if (tenant is null && _options.ValidateTenantExists)
{
_logger.LogWarning(
"Pack run request rejected: tenant {TenantId}/{ProjectId} not found.",
request.TenantId,
request.ProjectId);
return TenantEnforcementResult.Failure(
$"Tenant {request.TenantId}/{request.ProjectId} not found.",
TenantEnforcementFailureKind.TenantNotFound);
}
// Create tenant context if provider didn't return one
tenant ??= new TenantContext(request.TenantId, request.ProjectId, request.Labels);
// Validate tenant status
if (_options.BlockSuspendedTenants)
{
var validation = await _tenantProvider.ValidateAsync(tenant, cancellationToken)
.ConfigureAwait(false);
if (!validation.IsValid)
{
_logger.LogWarning(
"Pack run request rejected for tenant {TenantId}: {Reason}",
request.TenantId,
validation.Reason);
var kind = validation.IsSuspended
? TenantEnforcementFailureKind.TenantSuspended
: TenantEnforcementFailureKind.ValidationFailed;
return TenantEnforcementResult.Failure(
validation.Reason ?? "Tenant validation failed.",
kind);
}
}
// Check read-only mode
if (tenant.Restrictions.ReadOnly)
{
_logger.LogWarning(
"Pack run request rejected: tenant {TenantId} is in read-only mode.",
request.TenantId);
return TenantEnforcementResult.Failure(
"Tenant is in read-only mode.",
TenantEnforcementFailureKind.TenantReadOnly);
}
// Check concurrent run limit
var maxConcurrent = tenant.Restrictions.MaxConcurrentRuns ?? _options.DefaultMaxConcurrentRuns;
var currentCount = await _runTracker.GetCountAsync(tenant.TenantId, cancellationToken)
.ConfigureAwait(false);
if (currentCount >= maxConcurrent)
{
_logger.LogWarning(
"Pack run request rejected: tenant {TenantId} has reached max concurrent runs ({Count}/{Max}).",
request.TenantId,
currentCount,
maxConcurrent);
return TenantEnforcementResult.Failure(
$"Maximum concurrent runs ({maxConcurrent}) reached for tenant.",
TenantEnforcementFailureKind.MaxConcurrentRunsReached);
}
_logger.LogInformation(
"Pack run request validated for tenant {TenantId}/{ProjectId}.",
request.TenantId,
request.ProjectId);
return TenantEnforcementResult.Success(tenant);
}
public async ValueTask<TenantScopedExecutionContext> CreateExecutionContextAsync(
PackRunTenantRequest request,
string runId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
var validationResult = await ValidateRequestAsync(request, cancellationToken)
.ConfigureAwait(false);
if (!validationResult.IsValid)
{
throw new TenantEnforcementException(
validationResult.FailureReason ?? "Tenant validation failed.",
validationResult.FailureKind ?? TenantEnforcementFailureKind.ValidationFailed);
}
var tenant = validationResult.Tenant!;
var storagePaths = TenantScopedStoragePaths.Create(_pathResolver, tenant, runId);
var loggingScope = new Dictionary<string, object>(tenant.ToLoggingScope())
{
["RunId"] = runId
};
return new TenantScopedExecutionContext(tenant, storagePaths, loggingScope);
}
public async ValueTask RecordRunStartAsync(
TenantContext tenant,
string runId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
await _runTracker.IncrementAsync(tenant.TenantId, runId, cancellationToken)
.ConfigureAwait(false);
_logger.LogDebug(
"Recorded run start for tenant {TenantId}, run {RunId}.",
tenant.TenantId,
runId);
}
public async ValueTask RecordRunCompletionAsync(
TenantContext tenant,
string runId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
await _runTracker.DecrementAsync(tenant.TenantId, runId, cancellationToken)
.ConfigureAwait(false);
_logger.LogDebug(
"Recorded run completion for tenant {TenantId}, run {RunId}.",
tenant.TenantId,
runId);
}
public async ValueTask<int> GetConcurrentRunCountAsync(
TenantContext tenant,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(tenant);
return await _runTracker.GetCountAsync(tenant.TenantId, cancellationToken)
.ConfigureAwait(false);
}
}
/// <summary>
/// Exception thrown when tenant enforcement fails.
/// </summary>
public sealed class TenantEnforcementException : Exception
{
public TenantEnforcementException(string message, TenantEnforcementFailureKind kind)
: base(message)
{
Kind = kind;
}
public TenantEnforcementFailureKind Kind { get; }
}
/// <summary>
/// Interface for tracking concurrent pack runs per tenant.
/// </summary>
public interface IConcurrentRunTracker
{
ValueTask<int> GetCountAsync(string tenantId, CancellationToken cancellationToken = default);
ValueTask IncrementAsync(string tenantId, string runId, CancellationToken cancellationToken = default);
ValueTask DecrementAsync(string tenantId, string runId, CancellationToken cancellationToken = default);
}
/// <summary>
/// In-memory implementation of concurrent run tracker for testing.
/// </summary>
public sealed class InMemoryConcurrentRunTracker : IConcurrentRunTracker
{
private readonly Dictionary<string, HashSet<string>> _runsByTenant = new(StringComparer.Ordinal);
private readonly object _lock = new();
public ValueTask<int> GetCountAsync(string tenantId, CancellationToken cancellationToken = default)
{
lock (_lock)
{
return ValueTask.FromResult(
_runsByTenant.TryGetValue(tenantId, out var runs) ? runs.Count : 0);
}
}
public ValueTask IncrementAsync(string tenantId, string runId, CancellationToken cancellationToken = default)
{
lock (_lock)
{
if (!_runsByTenant.TryGetValue(tenantId, out var runs))
{
runs = new HashSet<string>(StringComparer.Ordinal);
_runsByTenant[tenantId] = runs;
}
runs.Add(runId);
}
return ValueTask.CompletedTask;
}
public ValueTask DecrementAsync(string tenantId, string runId, CancellationToken cancellationToken = default)
{
lock (_lock)
{
if (_runsByTenant.TryGetValue(tenantId, out var runs))
{
runs.Remove(runId);
if (runs.Count == 0)
{
_runsByTenant.Remove(tenantId);
}
}
}
return ValueTask.CompletedTask;
}
/// <summary>
/// Gets all active runs for a tenant (for testing).
/// </summary>
public IReadOnlySet<string> GetActiveRuns(string tenantId)
{
lock (_lock)
{
return _runsByTenant.TryGetValue(tenantId, out var runs)
? new HashSet<string>(runs)
: new HashSet<string>();
}
}
}

View File

@@ -0,0 +1,153 @@
namespace StellaOps.TaskRunner.Core.Tenancy;
/// <summary>
/// Configuration options for tenancy enforcement per TASKRUN-TEN-48-001.
/// </summary>
public sealed class TenancyEnforcementOptions
{
/// <summary>
/// Whether tenancy enforcement is enabled. When true, all pack runs
/// must have valid tenant context.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Whether to require project ID in addition to tenant ID.
/// </summary>
public bool RequireProjectId { get; set; } = true;
/// <summary>
/// Whether to enforce tenant-prefixed storage paths.
/// </summary>
public bool EnforceStoragePrefixes { get; set; } = true;
/// <summary>
/// Whether to enforce egress policies for restricted tenants.
/// </summary>
public bool EnforceEgressPolicies { get; set; } = true;
/// <summary>
/// Whether to propagate tenant context to step logs.
/// </summary>
public bool PropagateToLogs { get; set; } = true;
/// <summary>
/// Whether to block runs for suspended tenants.
/// </summary>
public bool BlockSuspendedTenants { get; set; } = true;
/// <summary>
/// Whether to validate tenant exists before starting run.
/// </summary>
public bool ValidateTenantExists { get; set; } = true;
/// <summary>
/// Default maximum concurrent runs per tenant when not specified
/// in tenant restrictions.
/// </summary>
public int DefaultMaxConcurrentRuns { get; set; } = 10;
/// <summary>
/// Default retention period in days for run artifacts when not specified
/// in tenant restrictions.
/// </summary>
public int DefaultRetentionDays { get; set; } = 30;
/// <summary>
/// Storage path configuration for tenant scoping.
/// </summary>
public TenantStoragePathOptions Storage { get; set; } = new();
/// <summary>
/// Egress policy configuration.
/// </summary>
public TenantEgressPolicyOptions Egress { get; set; } = new();
}
/// <summary>
/// Storage path options for tenant scoping.
/// </summary>
public sealed class TenantStoragePathOptions
{
/// <summary>
/// Path segment strategy for tenant prefixes.
/// </summary>
public TenantPathStrategy PathStrategy { get; set; } = TenantPathStrategy.Hierarchical;
/// <summary>
/// Base path for run state storage.
/// </summary>
public string StateBasePath { get; set; } = "runs";
/// <summary>
/// Base path for run logs storage.
/// </summary>
public string LogsBasePath { get; set; } = "logs";
/// <summary>
/// Base path for run artifacts storage.
/// </summary>
public string ArtifactsBasePath { get; set; } = "artifacts";
/// <summary>
/// Base path for approval records storage.
/// </summary>
public string ApprovalsBasePath { get; set; } = "approvals";
/// <summary>
/// Base path for provenance records storage.
/// </summary>
public string ProvenanceBasePath { get; set; } = "provenance";
}
/// <summary>
/// Tenant path strategy for storage prefixes.
/// </summary>
public enum TenantPathStrategy
{
/// <summary>
/// Hierarchical paths: {base}/{tenantId}/{projectId}/{runId}
/// </summary>
Hierarchical,
/// <summary>
/// Flat paths with prefix: {base}/{tenantId}_{projectId}_{runId}
/// </summary>
Flat,
/// <summary>
/// Hashed tenant prefixes for privacy: {base}/{hash(tenantId)}/{projectId}/{runId}
/// </summary>
Hashed
}
/// <summary>
/// Egress policy options for tenant scoping.
/// </summary>
public sealed class TenantEgressPolicyOptions
{
/// <summary>
/// Whether to allow egress by default when not restricted.
/// </summary>
public bool AllowByDefault { get; set; } = true;
/// <summary>
/// Global egress allowlist applied to all tenants.
/// </summary>
public List<string> GlobalAllowlist { get; set; } = [];
/// <summary>
/// Global egress blocklist applied to all tenants.
/// </summary>
public List<string> GlobalBlocklist { get; set; } = [];
/// <summary>
/// Whether to log blocked egress attempts.
/// </summary>
public bool LogBlockedAttempts { get; set; } = true;
/// <summary>
/// Whether to fail the run on blocked egress attempts.
/// </summary>
public bool FailOnBlockedAttempts { get; set; } = false;
}

View File

@@ -0,0 +1,228 @@
using System.Collections.Immutable;
namespace StellaOps.TaskRunner.Core.Tenancy;
/// <summary>
/// Tenant context for pack runs per TASKRUN-TEN-48-001.
/// Provides required tenant/project context for every pack run, enabling
/// tenant-scoped storage prefixes and egress policy enforcement.
/// </summary>
public sealed record TenantContext
{
/// <summary>
/// Creates a new tenant context. Both tenant and project IDs are required.
/// </summary>
public TenantContext(
string tenantId,
string projectId,
IReadOnlyDictionary<string, string>? labels = null,
TenantRestrictions? restrictions = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(projectId);
TenantId = tenantId.Trim();
ProjectId = projectId.Trim();
Labels = labels?.ToImmutableDictionary(StringComparer.Ordinal) ?? ImmutableDictionary<string, string>.Empty;
Restrictions = restrictions ?? TenantRestrictions.None;
}
/// <summary>
/// Unique identifier for the tenant (organization/account).
/// </summary>
public string TenantId { get; }
/// <summary>
/// Unique identifier for the project within the tenant.
/// </summary>
public string ProjectId { get; }
/// <summary>
/// Optional labels for filtering and grouping.
/// </summary>
public ImmutableDictionary<string, string> Labels { get; }
/// <summary>
/// Restrictions applied to this tenant context.
/// </summary>
public TenantRestrictions Restrictions { get; }
/// <summary>
/// Gets a storage-safe path prefix for this tenant context.
/// Format: {tenantId}/{projectId}
/// </summary>
public string StoragePrefix => $"{SanitizePathSegment(TenantId)}/{SanitizePathSegment(ProjectId)}";
/// <summary>
/// Gets a flat storage key prefix for this tenant context.
/// Format: {tenantId}_{projectId}
/// </summary>
public string FlatPrefix => $"{SanitizePathSegment(TenantId)}_{SanitizePathSegment(ProjectId)}";
/// <summary>
/// Creates a logging scope dictionary with tenant context.
/// </summary>
public IReadOnlyDictionary<string, object> ToLoggingScope() =>
new Dictionary<string, object>
{
["TenantId"] = TenantId,
["ProjectId"] = ProjectId
};
private static string SanitizePathSegment(string value)
{
var result = value.Trim().ToLowerInvariant();
foreach (var invalid in Path.GetInvalidFileNameChars())
{
result = result.Replace(invalid, '_');
}
// Also replace path separators for flat prefixes
result = result.Replace('/', '_').Replace('\\', '_');
return string.IsNullOrWhiteSpace(result) ? "unknown" : result;
}
}
/// <summary>
/// Restrictions that can be applied to a tenant context.
/// </summary>
public sealed record TenantRestrictions
{
public static TenantRestrictions None { get; } = new();
/// <summary>
/// Whether egress (outbound network) is blocked for this tenant.
/// </summary>
public bool EgressBlocked { get; init; }
/// <summary>
/// Allowed egress domains when egress is restricted (not fully blocked).
/// Empty means all domains blocked when EgressBlocked is true.
/// </summary>
public ImmutableArray<string> AllowedEgressDomains { get; init; } = [];
/// <summary>
/// Whether the tenant is in read-only mode (no writes allowed).
/// </summary>
public bool ReadOnly { get; init; }
/// <summary>
/// Whether the tenant is suspended (no operations allowed).
/// </summary>
public bool Suspended { get; init; }
/// <summary>
/// Maximum concurrent pack runs allowed for this tenant.
/// Null means unlimited.
/// </summary>
public int? MaxConcurrentRuns { get; init; }
/// <summary>
/// Maximum retention period for run artifacts in days.
/// Null means default retention applies.
/// </summary>
public int? MaxRetentionDays { get; init; }
}
/// <summary>
/// Provider interface for tenant context resolution.
/// </summary>
public interface ITenantContextProvider
{
/// <summary>
/// Gets the tenant context for a given tenant and project ID.
/// </summary>
ValueTask<TenantContext?> GetContextAsync(
string tenantId,
string projectId,
CancellationToken cancellationToken = default);
/// <summary>
/// Validates that the tenant context is active and not suspended.
/// </summary>
ValueTask<TenantValidationResult> ValidateAsync(
TenantContext context,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of tenant validation.
/// </summary>
public sealed record TenantValidationResult
{
public static TenantValidationResult Valid { get; } = new() { IsValid = true };
public static TenantValidationResult Invalid(string reason) => new()
{
IsValid = false,
Reason = reason
};
public static TenantValidationResult Suspended(string reason) => new()
{
IsValid = false,
IsSuspended = true,
Reason = reason
};
public bool IsValid { get; init; }
public bool IsSuspended { get; init; }
public string? Reason { get; init; }
}
/// <summary>
/// In-memory implementation of tenant context provider for testing.
/// </summary>
public sealed class InMemoryTenantContextProvider : ITenantContextProvider
{
private readonly Dictionary<string, TenantContext> _contexts = new(StringComparer.Ordinal);
private readonly HashSet<string> _suspendedTenants = new(StringComparer.Ordinal);
public ValueTask<TenantContext?> GetContextAsync(
string tenantId,
string projectId,
CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{projectId}";
return ValueTask.FromResult(_contexts.TryGetValue(key, out var context) ? context : null);
}
public ValueTask<TenantValidationResult> ValidateAsync(
TenantContext context,
CancellationToken cancellationToken = default)
{
if (context.Restrictions.Suspended || _suspendedTenants.Contains(context.TenantId))
{
return ValueTask.FromResult(TenantValidationResult.Suspended("Tenant is suspended."));
}
return ValueTask.FromResult(TenantValidationResult.Valid);
}
/// <summary>
/// Registers a tenant context (for testing).
/// </summary>
public void Register(TenantContext context)
{
var key = $"{context.TenantId}:{context.ProjectId}";
_contexts[key] = context;
}
/// <summary>
/// Suspends a tenant (for testing).
/// </summary>
public void Suspend(string tenantId)
{
_suspendedTenants.Add(tenantId);
}
/// <summary>
/// Unsuspends a tenant (for testing).
/// </summary>
public void Unsuspend(string tenantId)
{
_suspendedTenants.Remove(tenantId);
}
}

View File

@@ -0,0 +1,241 @@
using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Tenancy;
namespace StellaOps.TaskRunner.Infrastructure.Tenancy;
/// <summary>
/// Tenant-scoped pack run log store per TASKRUN-TEN-48-001.
/// Persists logs as NDJSON under tenant-prefixed paths with tenant context propagation.
/// </summary>
public sealed class TenantScopedPackRunLogStore : IPackRunLogStore
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false
};
private readonly ITenantScopedStoragePathResolver _pathResolver;
private readonly TenantContext _tenant;
private readonly ConcurrentDictionary<string, SemaphoreSlim> _fileLocks = new(StringComparer.Ordinal);
private readonly ILogger<TenantScopedPackRunLogStore> _logger;
public TenantScopedPackRunLogStore(
ITenantScopedStoragePathResolver pathResolver,
TenantContext tenant,
ILogger<TenantScopedPackRunLogStore> logger)
{
_pathResolver = pathResolver ?? throw new ArgumentNullException(nameof(pathResolver));
_tenant = tenant ?? throw new ArgumentNullException(nameof(tenant));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task AppendAsync(string runId, PackRunLogEntry entry, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
ArgumentNullException.ThrowIfNull(entry);
var path = GetLogsPath(runId);
var directory = Path.GetDirectoryName(path);
if (directory is not null)
{
Directory.CreateDirectory(directory);
}
var gate = _fileLocks.GetOrAdd(path, _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
// Enrich entry with tenant context
var enrichedEntry = EnrichWithTenantContext(entry);
var document = PackRunLogEntryDocument.FromDomain(enrichedEntry);
var json = JsonSerializer.Serialize(document, SerializerOptions);
await File.AppendAllTextAsync(path, json + Environment.NewLine, cancellationToken)
.ConfigureAwait(false);
_logger.LogDebug(
"Appended log entry for run {RunId} in tenant {TenantId}.",
runId,
_tenant.TenantId);
}
finally
{
gate.Release();
}
}
public async IAsyncEnumerable<PackRunLogEntry> ReadAsync(
string runId,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
var path = GetLogsPath(runId);
if (!File.Exists(path))
{
_logger.LogDebug(
"No logs found for run {RunId} in tenant {TenantId}.",
runId,
_tenant.TenantId);
yield break;
}
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var reader = new StreamReader(stream, Encoding.UTF8);
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
if (line is null)
{
yield break;
}
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
PackRunLogEntryDocument? document = null;
try
{
document = JsonSerializer.Deserialize<PackRunLogEntryDocument>(line, SerializerOptions);
}
catch
{
// Skip malformed entries
_logger.LogWarning("Skipping malformed log entry in run {RunId}.", runId);
}
if (document is null)
{
continue;
}
var entry = document.ToDomain();
// Verify tenant ownership from metadata
var tenantId = entry.Metadata?.GetValueOrDefault("TenantId");
if (tenantId is not null && !string.Equals(tenantId, _tenant.TenantId, StringComparison.Ordinal))
{
_logger.LogWarning(
"Log entry tenant mismatch: expected {ExpectedTenantId}, found {ActualTenantId} in run {RunId}.",
_tenant.TenantId,
tenantId,
runId);
continue;
}
yield return entry;
}
}
public Task<bool> ExistsAsync(string runId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
var path = GetLogsPath(runId);
return Task.FromResult(File.Exists(path));
}
private string GetLogsPath(string runId)
{
var logsPath = _pathResolver.GetLogsPath(_tenant, runId);
return $"{logsPath}.ndjson";
}
private PackRunLogEntry EnrichWithTenantContext(PackRunLogEntry entry)
{
// Add tenant context to metadata
var metadata = entry.Metadata is not null
? new Dictionary<string, string>(entry.Metadata, StringComparer.Ordinal)
: new Dictionary<string, string>(StringComparer.Ordinal);
metadata["TenantId"] = _tenant.TenantId;
metadata["ProjectId"] = _tenant.ProjectId;
return new PackRunLogEntry(
entry.Timestamp,
entry.Level,
entry.EventType,
entry.Message,
entry.StepId,
metadata);
}
private sealed record PackRunLogEntryDocument(
DateTimeOffset Timestamp,
string Level,
string EventType,
string Message,
string? StepId,
Dictionary<string, string>? Metadata)
{
public static PackRunLogEntryDocument FromDomain(PackRunLogEntry entry)
{
var metadata = entry.Metadata is null
? null
: new Dictionary<string, string>(entry.Metadata, StringComparer.Ordinal);
return new PackRunLogEntryDocument(
entry.Timestamp,
entry.Level,
entry.EventType,
entry.Message,
entry.StepId,
metadata);
}
public PackRunLogEntry ToDomain()
{
IReadOnlyDictionary<string, string>? metadata = Metadata is null
? null
: new Dictionary<string, string>(Metadata, StringComparer.Ordinal);
return new PackRunLogEntry(
Timestamp,
Level,
EventType,
Message,
StepId,
metadata);
}
}
}
/// <summary>
/// Factory for creating tenant-scoped log stores.
/// </summary>
public interface ITenantScopedLogStoreFactory
{
IPackRunLogStore Create(TenantContext tenant);
}
/// <summary>
/// Default implementation of tenant-scoped log store factory.
/// </summary>
public sealed class TenantScopedLogStoreFactory : ITenantScopedLogStoreFactory
{
private readonly ITenantScopedStoragePathResolver _pathResolver;
private readonly ILoggerFactory _loggerFactory;
public TenantScopedLogStoreFactory(
ITenantScopedStoragePathResolver pathResolver,
ILoggerFactory loggerFactory)
{
_pathResolver = pathResolver ?? throw new ArgumentNullException(nameof(pathResolver));
_loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
}
public IPackRunLogStore Create(TenantContext tenant)
{
ArgumentNullException.ThrowIfNull(tenant);
var logger = _loggerFactory.CreateLogger<TenantScopedPackRunLogStore>();
return new TenantScopedPackRunLogStore(_pathResolver, tenant, logger);
}
}

View File

@@ -0,0 +1,282 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
using StellaOps.TaskRunner.Core.Tenancy;
namespace StellaOps.TaskRunner.Infrastructure.Tenancy;
/// <summary>
/// Tenant-scoped pack run state store per TASKRUN-TEN-48-001.
/// Ensures all state is stored under tenant-prefixed paths.
/// </summary>
public sealed class TenantScopedPackRunStateStore : IPackRunStateStore
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
private readonly ITenantScopedStoragePathResolver _pathResolver;
private readonly TenantContext _tenant;
private readonly SemaphoreSlim _mutex = new(1, 1);
private readonly ILogger<TenantScopedPackRunStateStore> _logger;
private readonly string _basePath;
public TenantScopedPackRunStateStore(
ITenantScopedStoragePathResolver pathResolver,
TenantContext tenant,
ILogger<TenantScopedPackRunStateStore> logger)
{
_pathResolver = pathResolver ?? throw new ArgumentNullException(nameof(pathResolver));
_tenant = tenant ?? throw new ArgumentNullException(nameof(tenant));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
// Use the tenant base path for listing operations
_basePath = _pathResolver.GetTenantBasePath(tenant);
Directory.CreateDirectory(_basePath);
}
public async Task<PackRunState?> GetAsync(string runId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
var path = GetStatePath(runId);
if (!File.Exists(path))
{
_logger.LogDebug(
"State not found for run {RunId} in tenant {TenantId}.",
runId,
_tenant.TenantId);
return null;
}
await using var stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read);
var document = await JsonSerializer.DeserializeAsync<StateDocument>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
var state = document?.ToDomain();
// Validate tenant ownership
if (state is not null && !string.Equals(state.TenantId, _tenant.TenantId, StringComparison.Ordinal))
{
_logger.LogWarning(
"State tenant mismatch: expected {ExpectedTenantId}, found {ActualTenantId} for run {RunId}.",
_tenant.TenantId,
state.TenantId,
runId);
return null;
}
return state;
}
public async Task SaveAsync(PackRunState state, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(state);
// Enforce tenant ownership
if (!string.Equals(state.TenantId, _tenant.TenantId, StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"Cannot save state for tenant {state.TenantId} in store scoped to tenant {_tenant.TenantId}.");
}
var path = GetStatePath(state.RunId);
var directory = Path.GetDirectoryName(path);
if (directory is not null)
{
Directory.CreateDirectory(directory);
}
var document = StateDocument.FromDomain(state);
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await using var stream = File.Open(path, FileMode.Create, FileAccess.Write, FileShare.None);
await JsonSerializer.SerializeAsync(stream, document, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
_logger.LogDebug(
"Saved state for run {RunId} in tenant {TenantId}.",
state.RunId,
_tenant.TenantId);
}
finally
{
_mutex.Release();
}
}
public async Task<IReadOnlyList<PackRunState>> ListAsync(CancellationToken cancellationToken)
{
var stateBasePath = Path.Combine(_basePath, "state");
if (!Directory.Exists(stateBasePath))
{
return Array.Empty<PackRunState>();
}
var states = new List<PackRunState>();
// Search recursively for state files in tenant-scoped directory
var files = Directory.EnumerateFiles(stateBasePath, "*.json", SearchOption.AllDirectories)
.OrderBy(file => file, StringComparer.Ordinal);
foreach (var file in files)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
await using var stream = File.Open(file, FileMode.Open, FileAccess.Read, FileShare.Read);
var document = await JsonSerializer.DeserializeAsync<StateDocument>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
if (document is not null)
{
var state = document.ToDomain();
// Only include states that belong to this tenant
if (string.Equals(state.TenantId, _tenant.TenantId, StringComparison.Ordinal))
{
states.Add(state);
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to read state file {File}.", file);
}
}
return states;
}
private string GetStatePath(string runId)
{
var statePath = _pathResolver.GetStatePath(_tenant, runId);
return $"{statePath}.json";
}
private sealed record StateDocument(
string RunId,
string PlanHash,
TaskPackPlan Plan,
TaskPackPlanFailurePolicy FailurePolicy,
DateTimeOffset RequestedAt,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt,
IReadOnlyList<StepDocument> Steps,
string? TenantId)
{
public static StateDocument FromDomain(PackRunState state)
{
var steps = state.Steps.Values
.OrderBy(step => step.StepId, StringComparer.Ordinal)
.Select(step => new StepDocument(
step.StepId,
step.Kind,
step.Enabled,
step.ContinueOnError,
step.MaxParallel,
step.ApprovalId,
step.GateMessage,
step.Status,
step.Attempts,
step.LastTransitionAt,
step.NextAttemptAt,
step.StatusReason))
.ToList();
return new StateDocument(
state.RunId,
state.PlanHash,
state.Plan,
state.FailurePolicy,
state.RequestedAt,
state.CreatedAt,
state.UpdatedAt,
steps,
state.TenantId);
}
public PackRunState ToDomain()
{
var steps = Steps.ToDictionary(
step => step.StepId,
step => new PackRunStepStateRecord(
step.StepId,
step.Kind,
step.Enabled,
step.ContinueOnError,
step.MaxParallel,
step.ApprovalId,
step.GateMessage,
step.Status,
step.Attempts,
step.LastTransitionAt,
step.NextAttemptAt,
step.StatusReason),
StringComparer.Ordinal);
return new PackRunState(
RunId,
PlanHash,
Plan,
FailurePolicy,
RequestedAt,
CreatedAt,
UpdatedAt,
steps,
TenantId);
}
}
private sealed record StepDocument(
string StepId,
PackRunStepKind Kind,
bool Enabled,
bool ContinueOnError,
int? MaxParallel,
string? ApprovalId,
string? GateMessage,
PackRunStepExecutionStatus Status,
int Attempts,
DateTimeOffset? LastTransitionAt,
DateTimeOffset? NextAttemptAt,
string? StatusReason);
}
/// <summary>
/// Factory for creating tenant-scoped state stores.
/// </summary>
public interface ITenantScopedStateStoreFactory
{
IPackRunStateStore Create(TenantContext tenant);
}
/// <summary>
/// Default implementation of tenant-scoped state store factory.
/// </summary>
public sealed class TenantScopedStateStoreFactory : ITenantScopedStateStoreFactory
{
private readonly ITenantScopedStoragePathResolver _pathResolver;
private readonly ILoggerFactory _loggerFactory;
public TenantScopedStateStoreFactory(
ITenantScopedStoragePathResolver pathResolver,
ILoggerFactory loggerFactory)
{
_pathResolver = pathResolver ?? throw new ArgumentNullException(nameof(pathResolver));
_loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
}
public IPackRunStateStore Create(TenantContext tenant)
{
ArgumentNullException.ThrowIfNull(tenant);
var logger = _loggerFactory.CreateLogger<TenantScopedPackRunStateStore>();
return new TenantScopedPackRunStateStore(_pathResolver, tenant, logger);
}
}

View File

@@ -0,0 +1,530 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.TaskRunner.Core.Tenancy;
using StellaOps.TaskRunner.Infrastructure.Tenancy;
namespace StellaOps.TaskRunner.Tests;
/// <summary>
/// Tests for tenant enforcement per TASKRUN-TEN-48-001.
/// </summary>
public sealed class TenantEnforcementTests
{
#region TenantContext Tests
[Fact]
public void TenantContext_RequiresTenantId()
{
Assert.ThrowsAny<ArgumentException>(() =>
new TenantContext(null!, "project-1"));
Assert.ThrowsAny<ArgumentException>(() =>
new TenantContext("", "project-1"));
Assert.ThrowsAny<ArgumentException>(() =>
new TenantContext(" ", "project-1"));
}
[Fact]
public void TenantContext_RequiresProjectId()
{
Assert.ThrowsAny<ArgumentException>(() =>
new TenantContext("tenant-1", null!));
Assert.ThrowsAny<ArgumentException>(() =>
new TenantContext("tenant-1", ""));
Assert.ThrowsAny<ArgumentException>(() =>
new TenantContext("tenant-1", " "));
}
[Fact]
public void TenantContext_TrimsIds()
{
var context = new TenantContext(" tenant-1 ", " project-1 ");
Assert.Equal("tenant-1", context.TenantId);
Assert.Equal("project-1", context.ProjectId);
}
[Fact]
public void TenantContext_GeneratesStoragePrefix()
{
var context = new TenantContext("Tenant-1", "Project-1");
Assert.Equal("tenant-1/project-1", context.StoragePrefix);
}
[Fact]
public void TenantContext_GeneratesFlatPrefix()
{
var context = new TenantContext("Tenant-1", "Project-1");
Assert.Equal("tenant-1_project-1", context.FlatPrefix);
}
[Fact]
public void TenantContext_GeneratesLoggingScope()
{
var context = new TenantContext("tenant-1", "project-1");
var scope = context.ToLoggingScope();
Assert.Equal("tenant-1", scope["TenantId"]);
Assert.Equal("project-1", scope["ProjectId"]);
}
[Fact]
public void TenantContext_DefaultRestrictionsAreNone()
{
var context = new TenantContext("tenant-1", "project-1");
Assert.False(context.Restrictions.EgressBlocked);
Assert.False(context.Restrictions.ReadOnly);
Assert.False(context.Restrictions.Suspended);
Assert.Null(context.Restrictions.MaxConcurrentRuns);
}
#endregion
#region StoragePathResolver Tests
[Fact]
public void StoragePathResolver_HierarchicalPaths()
{
var options = new TenantStoragePathOptions
{
PathStrategy = TenantPathStrategy.Hierarchical,
StateBasePath = "state",
LogsBasePath = "logs"
};
var resolver = new TenantScopedStoragePathResolver(options, "/data");
var tenant = new TenantContext("tenant-1", "project-1");
var statePath = resolver.GetStatePath(tenant, "run-123");
var logsPath = resolver.GetLogsPath(tenant, "run-123");
Assert.Contains("state", statePath);
Assert.Contains("tenant-1", statePath);
Assert.Contains("project-1", statePath);
Assert.Contains("run-123", statePath);
Assert.Contains("logs", logsPath);
Assert.Contains("tenant-1", logsPath);
}
[Fact]
public void StoragePathResolver_FlatPaths()
{
var options = new TenantStoragePathOptions
{
PathStrategy = TenantPathStrategy.Flat,
StateBasePath = "state"
};
var resolver = new TenantScopedStoragePathResolver(options, "/data");
var tenant = new TenantContext("tenant-1", "project-1");
var statePath = resolver.GetStatePath(tenant, "run-123");
Assert.Contains("tenant-1_project-1_run-123", statePath);
}
[Fact]
public void StoragePathResolver_HashedPaths()
{
var options = new TenantStoragePathOptions
{
PathStrategy = TenantPathStrategy.Hashed
};
var resolver = new TenantScopedStoragePathResolver(options, "/data");
var tenant = new TenantContext("tenant-1", "project-1");
var basePath = resolver.GetTenantBasePath(tenant);
// Should contain a hash (hex characters)
Assert.DoesNotContain("tenant-1", basePath);
Assert.Contains("project-1", basePath);
}
[Fact]
public void StoragePathResolver_ValidatesPathOwnership()
{
var options = new TenantStoragePathOptions
{
PathStrategy = TenantPathStrategy.Hierarchical
};
// Use temp path for cross-platform compatibility
var basePath = Path.Combine(Path.GetTempPath(), "tenant-test-" + Guid.NewGuid().ToString("N")[..8]);
var resolver = new TenantScopedStoragePathResolver(options, basePath);
var tenant1 = new TenantContext("tenant-1", "project-1");
var tenant2 = new TenantContext("tenant-2", "project-1");
var tenant1Path = resolver.GetStatePath(tenant1, "run-123");
var tenant2Path = resolver.GetStatePath(tenant2, "run-123");
Assert.True(resolver.ValidatePathBelongsToTenant(tenant1, tenant1Path));
Assert.False(resolver.ValidatePathBelongsToTenant(tenant1, tenant2Path));
}
#endregion
#region EgressPolicy Tests
[Fact]
public async Task EgressPolicy_AllowsByDefault()
{
var options = new TenantEgressPolicyOptions { AllowByDefault = true };
var policy = CreateEgressPolicy(options);
var tenant = new TenantContext("tenant-1", "project-1");
var result = await policy.CheckEgressAsync(tenant, "example.com", 443);
Assert.True(result.IsAllowed);
}
[Fact]
public async Task EgressPolicy_BlocksGlobalBlocklist()
{
var options = new TenantEgressPolicyOptions
{
AllowByDefault = true,
GlobalBlocklist = ["blocked.com"]
};
var policy = CreateEgressPolicy(options);
var tenant = new TenantContext("tenant-1", "project-1");
var result = await policy.CheckEgressAsync(tenant, "blocked.com", 443);
Assert.False(result.IsAllowed);
Assert.Equal(EgressBlockReason.GlobalPolicy, result.BlockReason);
}
[Fact]
public async Task EgressPolicy_BlocksSuspendedTenants()
{
var options = new TenantEgressPolicyOptions { AllowByDefault = true };
var policy = CreateEgressPolicy(options);
var tenant = new TenantContext(
"tenant-1",
"project-1",
restrictions: new TenantRestrictions { Suspended = true });
var result = await policy.CheckEgressAsync(tenant, "example.com", 443);
Assert.False(result.IsAllowed);
Assert.Equal(EgressBlockReason.TenantSuspended, result.BlockReason);
}
[Fact]
public async Task EgressPolicy_BlocksRestrictedTenants()
{
var options = new TenantEgressPolicyOptions { AllowByDefault = true };
var policy = CreateEgressPolicy(options);
var tenant = new TenantContext(
"tenant-1",
"project-1",
restrictions: new TenantRestrictions { EgressBlocked = true });
var result = await policy.CheckEgressAsync(tenant, "example.com", 443);
Assert.False(result.IsAllowed);
Assert.Equal(EgressBlockReason.TenantRestriction, result.BlockReason);
}
[Fact]
public async Task EgressPolicy_AllowsRestrictedTenantAllowlist()
{
var options = new TenantEgressPolicyOptions { AllowByDefault = true };
var policy = CreateEgressPolicy(options);
var tenant = new TenantContext(
"tenant-1",
"project-1",
restrictions: new TenantRestrictions
{
EgressBlocked = true,
AllowedEgressDomains = ["allowed.com"]
});
var allowedResult = await policy.CheckEgressAsync(tenant, "allowed.com", 443);
var blockedResult = await policy.CheckEgressAsync(tenant, "other.com", 443);
Assert.True(allowedResult.IsAllowed);
Assert.False(blockedResult.IsAllowed);
}
[Fact]
public async Task EgressPolicy_SupportsWildcardDomains()
{
var options = new TenantEgressPolicyOptions
{
AllowByDefault = true,
GlobalBlocklist = ["*.blocked.com"]
};
var policy = CreateEgressPolicy(options);
var tenant = new TenantContext("tenant-1", "project-1");
var result = await policy.CheckEgressAsync(tenant, "sub.blocked.com", 443);
Assert.False(result.IsAllowed);
}
[Fact]
public async Task EgressPolicy_RecordsAttempts()
{
var auditLog = new InMemoryEgressAuditLog();
var options = new TenantEgressPolicyOptions
{
AllowByDefault = true,
LogBlockedAttempts = true
};
var policy = CreateEgressPolicy(options, auditLog);
var tenant = new TenantContext("tenant-1", "project-1");
var uri = new Uri("https://example.com/api");
var result = await policy.CheckEgressAsync(tenant, uri);
await policy.RecordEgressAttemptAsync(tenant, "run-123", uri, result);
var records = auditLog.GetAllRecords();
Assert.Single(records);
Assert.Equal("tenant-1", records[0].TenantId);
Assert.Equal("run-123", records[0].RunId);
Assert.True(records[0].WasAllowed);
}
#endregion
#region TenantEnforcer Tests
[Fact]
public async Task TenantEnforcer_RequiresTenantId()
{
var enforcer = CreateTenantEnforcer();
var request = new PackRunTenantRequest("", "project-1");
var result = await enforcer.ValidateRequestAsync(request);
Assert.False(result.IsValid);
Assert.Equal(TenantEnforcementFailureKind.MissingTenantId, result.FailureKind);
}
[Fact]
public async Task TenantEnforcer_RequiresProjectId()
{
var options = new TenancyEnforcementOptions { RequireProjectId = true };
var enforcer = CreateTenantEnforcer(options);
var request = new PackRunTenantRequest("tenant-1", "");
var result = await enforcer.ValidateRequestAsync(request);
Assert.False(result.IsValid);
Assert.Equal(TenantEnforcementFailureKind.MissingProjectId, result.FailureKind);
}
[Fact]
public async Task TenantEnforcer_BlocksSuspendedTenants()
{
var tenantProvider = new InMemoryTenantContextProvider();
var tenant = new TenantContext(
"tenant-1",
"project-1",
restrictions: new TenantRestrictions { Suspended = true });
tenantProvider.Register(tenant);
var options = new TenancyEnforcementOptions { BlockSuspendedTenants = true };
var enforcer = CreateTenantEnforcer(options, tenantProvider);
var request = new PackRunTenantRequest("tenant-1", "project-1");
var result = await enforcer.ValidateRequestAsync(request);
Assert.False(result.IsValid);
Assert.Equal(TenantEnforcementFailureKind.TenantSuspended, result.FailureKind);
}
[Fact]
public async Task TenantEnforcer_BlocksReadOnlyTenants()
{
var tenantProvider = new InMemoryTenantContextProvider();
var tenant = new TenantContext(
"tenant-1",
"project-1",
restrictions: new TenantRestrictions { ReadOnly = true });
tenantProvider.Register(tenant);
var enforcer = CreateTenantEnforcer(tenantProvider: tenantProvider);
var request = new PackRunTenantRequest("tenant-1", "project-1");
var result = await enforcer.ValidateRequestAsync(request);
Assert.False(result.IsValid);
Assert.Equal(TenantEnforcementFailureKind.TenantReadOnly, result.FailureKind);
}
[Fact]
public async Task TenantEnforcer_EnforcesConcurrentRunLimit()
{
var tenantProvider = new InMemoryTenantContextProvider();
var tenant = new TenantContext(
"tenant-1",
"project-1",
restrictions: new TenantRestrictions { MaxConcurrentRuns = 2 });
tenantProvider.Register(tenant);
var runTracker = new InMemoryConcurrentRunTracker();
await runTracker.IncrementAsync("tenant-1", "run-1");
await runTracker.IncrementAsync("tenant-1", "run-2");
var enforcer = CreateTenantEnforcer(tenantProvider: tenantProvider, runTracker: runTracker);
var request = new PackRunTenantRequest("tenant-1", "project-1");
var result = await enforcer.ValidateRequestAsync(request);
Assert.False(result.IsValid);
Assert.Equal(TenantEnforcementFailureKind.MaxConcurrentRunsReached, result.FailureKind);
}
[Fact]
public async Task TenantEnforcer_AllowsWithinConcurrentLimit()
{
var tenantProvider = new InMemoryTenantContextProvider();
var tenant = new TenantContext(
"tenant-1",
"project-1",
restrictions: new TenantRestrictions { MaxConcurrentRuns = 5 });
tenantProvider.Register(tenant);
var runTracker = new InMemoryConcurrentRunTracker();
await runTracker.IncrementAsync("tenant-1", "run-1");
var enforcer = CreateTenantEnforcer(tenantProvider: tenantProvider, runTracker: runTracker);
var request = new PackRunTenantRequest("tenant-1", "project-1");
var result = await enforcer.ValidateRequestAsync(request);
Assert.True(result.IsValid);
Assert.NotNull(result.Tenant);
}
[Fact]
public async Task TenantEnforcer_TracksRunStartCompletion()
{
var runTracker = new InMemoryConcurrentRunTracker();
var enforcer = CreateTenantEnforcer(runTracker: runTracker);
var tenant = new TenantContext("tenant-1", "project-1");
await enforcer.RecordRunStartAsync(tenant, "run-1");
Assert.Equal(1, await enforcer.GetConcurrentRunCountAsync(tenant));
await enforcer.RecordRunStartAsync(tenant, "run-2");
Assert.Equal(2, await enforcer.GetConcurrentRunCountAsync(tenant));
await enforcer.RecordRunCompletionAsync(tenant, "run-1");
Assert.Equal(1, await enforcer.GetConcurrentRunCountAsync(tenant));
await enforcer.RecordRunCompletionAsync(tenant, "run-2");
Assert.Equal(0, await enforcer.GetConcurrentRunCountAsync(tenant));
}
[Fact]
public async Task TenantEnforcer_CreatesExecutionContext()
{
var tenantProvider = new InMemoryTenantContextProvider();
var tenant = new TenantContext("tenant-1", "project-1");
tenantProvider.Register(tenant);
var enforcer = CreateTenantEnforcer(tenantProvider: tenantProvider);
var request = new PackRunTenantRequest("tenant-1", "project-1");
var context = await enforcer.CreateExecutionContextAsync(request, "run-123");
Assert.NotNull(context);
Assert.Equal("tenant-1", context.Tenant.TenantId);
Assert.Equal("project-1", context.Tenant.ProjectId);
Assert.NotNull(context.StoragePaths);
Assert.Contains("tenant-1", context.LoggingScope["TenantId"].ToString());
}
[Fact]
public async Task TenantEnforcer_ThrowsOnInvalidRequest()
{
var enforcer = CreateTenantEnforcer();
var request = new PackRunTenantRequest("", "project-1");
await Assert.ThrowsAsync<TenantEnforcementException>(() =>
enforcer.CreateExecutionContextAsync(request, "run-123").AsTask());
}
#endregion
#region ConcurrentRunTracker Tests
[Fact]
public async Task ConcurrentRunTracker_TracksMultipleTenants()
{
var tracker = new InMemoryConcurrentRunTracker();
await tracker.IncrementAsync("tenant-1", "run-1");
await tracker.IncrementAsync("tenant-1", "run-2");
await tracker.IncrementAsync("tenant-2", "run-3");
Assert.Equal(2, await tracker.GetCountAsync("tenant-1"));
Assert.Equal(1, await tracker.GetCountAsync("tenant-2"));
Assert.Equal(0, await tracker.GetCountAsync("tenant-3"));
}
[Fact]
public async Task ConcurrentRunTracker_PreventsDoubleIncrement()
{
var tracker = new InMemoryConcurrentRunTracker();
await tracker.IncrementAsync("tenant-1", "run-1");
await tracker.IncrementAsync("tenant-1", "run-1"); // Same run ID
Assert.Equal(1, await tracker.GetCountAsync("tenant-1"));
}
[Fact]
public async Task ConcurrentRunTracker_HandlesNonExistentDecrement()
{
var tracker = new InMemoryConcurrentRunTracker();
// Should not throw
await tracker.DecrementAsync("tenant-1", "non-existent");
Assert.Equal(0, await tracker.GetCountAsync("tenant-1"));
}
#endregion
#region Helper Methods
private static TenantEgressPolicy CreateEgressPolicy(
TenantEgressPolicyOptions? options = null,
IEgressAuditLog? auditLog = null)
{
return new TenantEgressPolicy(
options ?? new TenantEgressPolicyOptions(),
auditLog ?? NullEgressAuditLog.Instance,
NullLogger<TenantEgressPolicy>.Instance);
}
private static PackRunTenantEnforcer CreateTenantEnforcer(
TenancyEnforcementOptions? options = null,
ITenantContextProvider? tenantProvider = null,
IConcurrentRunTracker? runTracker = null)
{
var storageOptions = new TenantStoragePathOptions();
var pathResolver = new TenantScopedStoragePathResolver(storageOptions, Path.GetTempPath());
return new PackRunTenantEnforcer(
tenantProvider ?? new InMemoryTenantContextProvider(),
pathResolver,
options ?? new TenancyEnforcementOptions { ValidateTenantExists = false },
runTracker ?? new InMemoryConcurrentRunTracker(),
NullLogger<PackRunTenantEnforcer>.Instance);
}
#endregion
}