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
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user