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);
}
}