old sprints work, new sprints for exposing functionality via cli, improve code_of_conduct and other agents instructions

This commit is contained in:
master
2026-01-15 18:37:59 +02:00
parent c631bacee2
commit 88a85cdd92
208 changed files with 32271 additions and 2287 deletions

View File

@@ -0,0 +1,337 @@
// -----------------------------------------------------------------------------
// FileBasedPolicyStoreTests.cs
// Sprint: SPRINT_20260112_018_AUTH_local_rbac_fallback
// Tasks: RBAC-011
// Description: Unit tests for file-based local policy store.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Authority.LocalPolicy;
using Xunit;
namespace StellaOps.Authority.Tests.LocalPolicy;
[Trait("Category", "Unit")]
public sealed class FileBasedPolicyStoreTests
{
private static LocalPolicy CreateTestPolicy() => new()
{
SchemaVersion = "1.0.0",
LastUpdated = DateTimeOffset.UtcNow,
Roles = ImmutableArray.Create(
new LocalRole
{
Name = "admin",
Scopes = ImmutableArray.Create("authority:read", "authority:write", "platform:admin")
},
new LocalRole
{
Name = "operator",
Scopes = ImmutableArray.Create("orch:operate", "orch:view")
},
new LocalRole
{
Name = "auditor",
Scopes = ImmutableArray.Create("audit:read"),
Inherits = ImmutableArray.Create("operator")
}
),
Subjects = ImmutableArray.Create(
new LocalSubject
{
Id = "admin@company.com",
Roles = ImmutableArray.Create("admin"),
Tenant = "default"
},
new LocalSubject
{
Id = "ops@company.com",
Roles = ImmutableArray.Create("operator"),
Tenant = "default"
},
new LocalSubject
{
Id = "audit@company.com",
Roles = ImmutableArray.Create("auditor"),
Tenant = "default"
},
new LocalSubject
{
Id = "disabled@company.com",
Roles = ImmutableArray.Create("admin"),
Enabled = false
},
new LocalSubject
{
Id = "expired@company.com",
Roles = ImmutableArray.Create("admin"),
ExpiresAt = DateTimeOffset.UtcNow.AddDays(-1)
}
),
BreakGlass = new BreakGlassConfig
{
Enabled = true,
Accounts = ImmutableArray.Create(
new BreakGlassAccount
{
Id = "emergency-admin",
// bcrypt hash of "emergency-password"
CredentialHash = "$2a$11$K5r3kJ1bQ0K5r3kJ1bQ0KerIuPrXKP3kHnJyKjIuPrXKP3kHnJyKj",
HashAlgorithm = "bcrypt",
Roles = ImmutableArray.Create("admin")
}
),
SessionTimeoutMinutes = 15,
MaxExtensions = 2,
RequireReasonCode = true,
AllowedReasonCodes = ImmutableArray.Create("EMERGENCY", "INCIDENT")
}
};
[Fact]
public void LocalPolicy_SerializesCorrectly()
{
var policy = CreateTestPolicy();
Assert.Equal("1.0.0", policy.SchemaVersion);
Assert.Equal(3, policy.Roles.Length);
Assert.Equal(5, policy.Subjects.Length);
Assert.NotNull(policy.BreakGlass);
}
[Fact]
public void LocalRole_InheritanceWorks()
{
var policy = CreateTestPolicy();
var auditorRole = policy.Roles.First(r => r.Name == "auditor");
Assert.Contains("operator", auditorRole.Inherits);
}
[Fact]
public void LocalSubject_DisabledWorks()
{
var policy = CreateTestPolicy();
var disabledSubject = policy.Subjects.First(s => s.Id == "disabled@company.com");
Assert.False(disabledSubject.Enabled);
}
[Fact]
public void LocalSubject_ExpirationWorks()
{
var policy = CreateTestPolicy();
var expiredSubject = policy.Subjects.First(s => s.Id == "expired@company.com");
Assert.NotNull(expiredSubject.ExpiresAt);
Assert.True(expiredSubject.ExpiresAt < DateTimeOffset.UtcNow);
}
[Fact]
public void BreakGlassConfig_AccountsConfigured()
{
var policy = CreateTestPolicy();
Assert.NotNull(policy.BreakGlass);
Assert.True(policy.BreakGlass.Enabled);
Assert.Single(policy.BreakGlass.Accounts);
Assert.Equal("emergency-admin", policy.BreakGlass.Accounts[0].Id);
}
[Fact]
public void BreakGlassConfig_ReasonCodesConfigured()
{
var policy = CreateTestPolicy();
Assert.NotNull(policy.BreakGlass);
Assert.True(policy.BreakGlass.RequireReasonCode);
Assert.Contains("EMERGENCY", policy.BreakGlass.AllowedReasonCodes);
Assert.Contains("INCIDENT", policy.BreakGlass.AllowedReasonCodes);
}
[Fact]
public void BreakGlassSession_IsValidChecksExpiration()
{
var timeProvider = TimeProvider.System;
var now = timeProvider.GetUtcNow();
var validSession = new BreakGlassSession
{
SessionId = "valid",
AccountId = "admin",
StartedAt = now,
ExpiresAt = now.AddMinutes(15),
ReasonCode = "EMERGENCY",
Roles = ImmutableArray.Create("admin")
};
var expiredSession = new BreakGlassSession
{
SessionId = "expired",
AccountId = "admin",
StartedAt = now.AddMinutes(-30),
ExpiresAt = now.AddMinutes(-15),
ReasonCode = "EMERGENCY",
Roles = ImmutableArray.Create("admin")
};
Assert.True(validSession.IsValid(timeProvider));
Assert.False(expiredSession.IsValid(timeProvider));
}
}
[Trait("Category", "Unit")]
public sealed class LocalPolicyStoreOptionsTests
{
[Fact]
public void DefaultOptions_HaveCorrectValues()
{
var options = new LocalPolicyStoreOptions();
Assert.True(options.Enabled);
Assert.Equal("/etc/stellaops/authority/local-policy.yaml", options.PolicyFilePath);
Assert.True(options.EnableHotReload);
Assert.Equal(500, options.HotReloadDebounceMs);
Assert.False(options.RequireSignature);
Assert.True(options.AllowBreakGlass);
Assert.Contains("1.0.0", options.SupportedSchemaVersions);
}
[Fact]
public void FallbackBehavior_DefaultIsEmptyPolicy()
{
var options = new LocalPolicyStoreOptions();
Assert.Equal(PolicyFallbackBehavior.EmptyPolicy, options.FallbackBehavior);
}
}
[Trait("Category", "Unit")]
public sealed class PolicyStoreFallbackOptionsTests
{
[Fact]
public void DefaultOptions_HaveCorrectValues()
{
var options = new PolicyStoreFallbackOptions();
Assert.True(options.Enabled);
Assert.Equal(5000, options.HealthCheckIntervalMs);
Assert.Equal(3, options.FailureThreshold);
Assert.Equal(30000, options.MinFallbackDurationMs);
Assert.True(options.LogFallbackLookups);
}
}
[Trait("Category", "Unit")]
public sealed class BreakGlassSessionManagerTests
{
[Fact]
public void BreakGlassSessionRequest_HasRequiredProperties()
{
var request = new BreakGlassSessionRequest
{
Credential = "test-credential",
ReasonCode = "EMERGENCY",
ReasonText = "Production incident",
ClientIp = "192.168.1.1",
UserAgent = "TestAgent/1.0"
};
Assert.Equal("test-credential", request.Credential);
Assert.Equal("EMERGENCY", request.ReasonCode);
Assert.Equal("Production incident", request.ReasonText);
}
[Fact]
public void BreakGlassSessionResult_SuccessCase()
{
var session = new BreakGlassSession
{
SessionId = "test-session",
AccountId = "admin",
StartedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(15),
ReasonCode = "EMERGENCY",
Roles = ImmutableArray.Create("admin")
};
var result = new BreakGlassSessionResult
{
Success = true,
Session = session
};
Assert.True(result.Success);
Assert.NotNull(result.Session);
Assert.Null(result.Error);
}
[Fact]
public void BreakGlassSessionResult_FailureCase()
{
var result = new BreakGlassSessionResult
{
Success = false,
Error = "Invalid credential",
ErrorCode = "AUTHENTICATION_FAILED"
};
Assert.False(result.Success);
Assert.Null(result.Session);
Assert.Equal("Invalid credential", result.Error);
Assert.Equal("AUTHENTICATION_FAILED", result.ErrorCode);
}
[Fact]
public void BreakGlassAuditEvent_HasAllProperties()
{
var auditEvent = new BreakGlassAuditEvent
{
EventId = "evt-123",
EventType = BreakGlassAuditEventType.SessionCreated,
Timestamp = DateTimeOffset.UtcNow,
SessionId = "session-456",
AccountId = "emergency-admin",
ReasonCode = "INCIDENT",
ReasonText = "Production outage",
ClientIp = "10.0.0.1",
UserAgent = "StellaOps-CLI/1.0",
Details = ImmutableDictionary<string, string>.Empty.Add("key", "value")
};
Assert.Equal("evt-123", auditEvent.EventId);
Assert.Equal(BreakGlassAuditEventType.SessionCreated, auditEvent.EventType);
Assert.Equal("session-456", auditEvent.SessionId);
}
}
[Trait("Category", "Unit")]
public sealed class PolicyStoreModeTests
{
[Fact]
public void PolicyStoreModeChangedEventArgs_HasAllProperties()
{
var args = new PolicyStoreModeChangedEventArgs
{
PreviousMode = PolicyStoreMode.Primary,
NewMode = PolicyStoreMode.Fallback,
ChangedAt = DateTimeOffset.UtcNow,
Reason = "Primary store unavailable"
};
Assert.Equal(PolicyStoreMode.Primary, args.PreviousMode);
Assert.Equal(PolicyStoreMode.Fallback, args.NewMode);
Assert.NotNull(args.Reason);
}
[Theory]
[InlineData(PolicyStoreMode.Primary)]
[InlineData(PolicyStoreMode.Fallback)]
[InlineData(PolicyStoreMode.Degraded)]
public void PolicyStoreMode_AllValuesExist(PolicyStoreMode mode)
{
Assert.True(Enum.IsDefined(mode));
}
}

View File

@@ -0,0 +1,551 @@
// -----------------------------------------------------------------------------
// BreakGlassSessionManager.cs
// Sprint: SPRINT_20260112_018_AUTH_local_rbac_fallback
// Tasks: RBAC-007, RBAC-008, RBAC-009
// Description: Break-glass session management with timeout and audit.
// -----------------------------------------------------------------------------
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Security.Cryptography;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Authority.LocalPolicy;
/// <summary>
/// Interface for break-glass session management.
/// </summary>
public interface IBreakGlassSessionManager
{
/// <summary>
/// Creates a new break-glass session.
/// </summary>
/// <param name="request">Session creation request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Created session or failure result.</returns>
Task<BreakGlassSessionResult> CreateSessionAsync(
BreakGlassSessionRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Validates an existing session.
/// </summary>
/// <param name="sessionId">Session ID to validate.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Session if valid, null otherwise.</returns>
Task<BreakGlassSession?> ValidateSessionAsync(
string sessionId,
CancellationToken cancellationToken = default);
/// <summary>
/// Extends a session with re-authentication.
/// </summary>
/// <param name="sessionId">Session ID to extend.</param>
/// <param name="credential">Re-authentication credential.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Extended session or failure result.</returns>
Task<BreakGlassSessionResult> ExtendSessionAsync(
string sessionId,
string credential,
CancellationToken cancellationToken = default);
/// <summary>
/// Terminates a session.
/// </summary>
/// <param name="sessionId">Session ID to terminate.</param>
/// <param name="reason">Termination reason.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task TerminateSessionAsync(
string sessionId,
string reason,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all active sessions.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of active sessions.</returns>
Task<IReadOnlyList<BreakGlassSession>> GetActiveSessionsAsync(
CancellationToken cancellationToken = default);
}
/// <summary>
/// Request to create a break-glass session.
/// </summary>
public sealed record BreakGlassSessionRequest
{
/// <summary>
/// Break-glass credential.
/// </summary>
public required string Credential { get; init; }
/// <summary>
/// Reason code for break-glass usage.
/// </summary>
public required string ReasonCode { get; init; }
/// <summary>
/// Additional reason text.
/// </summary>
public string? ReasonText { get; init; }
/// <summary>
/// Client IP address.
/// </summary>
public string? ClientIp { get; init; }
/// <summary>
/// User agent string.
/// </summary>
public string? UserAgent { get; init; }
}
/// <summary>
/// Result of break-glass session operation.
/// </summary>
public sealed record BreakGlassSessionResult
{
/// <summary>
/// Whether the operation succeeded.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// Session if successful.
/// </summary>
public BreakGlassSession? Session { get; init; }
/// <summary>
/// Error message if failed.
/// </summary>
public string? Error { get; init; }
/// <summary>
/// Error code for programmatic handling.
/// </summary>
public string? ErrorCode { get; init; }
}
/// <summary>
/// Break-glass audit event types.
/// </summary>
public enum BreakGlassAuditEventType
{
SessionCreated,
SessionExtended,
SessionTerminated,
SessionExpired,
AuthenticationFailed,
InvalidReasonCode,
MaxExtensionsReached
}
/// <summary>
/// Break-glass audit event.
/// </summary>
public sealed record BreakGlassAuditEvent
{
/// <summary>
/// Event ID.
/// </summary>
public required string EventId { get; init; }
/// <summary>
/// Event type.
/// </summary>
public required BreakGlassAuditEventType EventType { get; init; }
/// <summary>
/// Timestamp (UTC).
/// </summary>
public required DateTimeOffset Timestamp { get; init; }
/// <summary>
/// Session ID (if applicable).
/// </summary>
public string? SessionId { get; init; }
/// <summary>
/// Account ID (if applicable).
/// </summary>
public string? AccountId { get; init; }
/// <summary>
/// Reason code.
/// </summary>
public string? ReasonCode { get; init; }
/// <summary>
/// Additional reason text.
/// </summary>
public string? ReasonText { get; init; }
/// <summary>
/// Client IP address.
/// </summary>
public string? ClientIp { get; init; }
/// <summary>
/// User agent.
/// </summary>
public string? UserAgent { get; init; }
/// <summary>
/// Additional details.
/// </summary>
public ImmutableDictionary<string, string>? Details { get; init; }
}
/// <summary>
/// Interface for break-glass audit logging.
/// </summary>
public interface IBreakGlassAuditLogger
{
/// <summary>
/// Logs an audit event.
/// </summary>
/// <param name="auditEvent">Event to log.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task LogAsync(BreakGlassAuditEvent auditEvent, CancellationToken cancellationToken = default);
}
/// <summary>
/// In-memory implementation of break-glass session manager.
/// </summary>
public sealed class BreakGlassSessionManager : IBreakGlassSessionManager, IDisposable
{
private readonly ILocalPolicyStore _policyStore;
private readonly IBreakGlassAuditLogger _auditLogger;
private readonly IOptionsMonitor<LocalPolicyStoreOptions> _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<BreakGlassSessionManager> _logger;
private readonly ConcurrentDictionary<string, BreakGlassSession> _activeSessions = new(StringComparer.Ordinal);
private readonly Timer _cleanupTimer;
private bool _disposed;
public BreakGlassSessionManager(
ILocalPolicyStore policyStore,
IBreakGlassAuditLogger auditLogger,
IOptionsMonitor<LocalPolicyStoreOptions> options,
TimeProvider timeProvider,
ILogger<BreakGlassSessionManager> logger)
{
_policyStore = policyStore ?? throw new ArgumentNullException(nameof(policyStore));
_auditLogger = auditLogger ?? throw new ArgumentNullException(nameof(auditLogger));
_options = options ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
// Cleanup expired sessions every minute
_cleanupTimer = new Timer(CleanupExpiredSessions, null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1));
}
/// <inheritdoc/>
public async Task<BreakGlassSessionResult> CreateSessionAsync(
BreakGlassSessionRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var policy = await _policyStore.GetPolicyAsync(cancellationToken).ConfigureAwait(false);
var breakGlassConfig = policy?.BreakGlass;
if (breakGlassConfig is null || !breakGlassConfig.Enabled)
{
return new BreakGlassSessionResult
{
Success = false,
Error = "Break-glass is disabled",
ErrorCode = "BREAK_GLASS_DISABLED"
};
}
// Validate reason code
if (breakGlassConfig.RequireReasonCode)
{
if (string.IsNullOrEmpty(request.ReasonCode))
{
await LogAuditEventAsync(BreakGlassAuditEventType.InvalidReasonCode, null, null, request, "Missing reason code").ConfigureAwait(false);
return new BreakGlassSessionResult
{
Success = false,
Error = "Reason code is required",
ErrorCode = "REASON_CODE_REQUIRED"
};
}
if (!breakGlassConfig.AllowedReasonCodes.Contains(request.ReasonCode, StringComparer.OrdinalIgnoreCase))
{
await LogAuditEventAsync(BreakGlassAuditEventType.InvalidReasonCode, null, null, request, $"Invalid reason code: {request.ReasonCode}").ConfigureAwait(false);
return new BreakGlassSessionResult
{
Success = false,
Error = $"Invalid reason code: {request.ReasonCode}",
ErrorCode = "INVALID_REASON_CODE"
};
}
}
// Validate credential
var validationResult = await _policyStore.ValidateBreakGlassCredentialAsync(request.Credential, cancellationToken).ConfigureAwait(false);
if (!validationResult.IsValid || validationResult.Account is null)
{
await LogAuditEventAsync(BreakGlassAuditEventType.AuthenticationFailed, null, null, request, validationResult.Error).ConfigureAwait(false);
return new BreakGlassSessionResult
{
Success = false,
Error = validationResult.Error ?? "Authentication failed",
ErrorCode = "AUTHENTICATION_FAILED"
};
}
// Create session
var now = _timeProvider.GetUtcNow();
var session = new BreakGlassSession
{
SessionId = GenerateSessionId(),
AccountId = validationResult.Account.Id,
StartedAt = now,
ExpiresAt = now.AddMinutes(breakGlassConfig.SessionTimeoutMinutes),
ReasonCode = request.ReasonCode,
ReasonText = request.ReasonText,
ClientIp = request.ClientIp,
UserAgent = request.UserAgent,
Roles = validationResult.Account.Roles,
ExtensionCount = 0
};
_activeSessions[session.SessionId] = session;
await LogAuditEventAsync(BreakGlassAuditEventType.SessionCreated, session.SessionId, validationResult.Account.Id, request).ConfigureAwait(false);
_logger.LogWarning(
"Break-glass session created: SessionId={SessionId}, AccountId={AccountId}, ReasonCode={ReasonCode}, ExpiresAt={ExpiresAt}",
session.SessionId, session.AccountId, session.ReasonCode, session.ExpiresAt);
return new BreakGlassSessionResult
{
Success = true,
Session = session
};
}
/// <inheritdoc/>
public Task<BreakGlassSession?> ValidateSessionAsync(
string sessionId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(sessionId);
if (!_activeSessions.TryGetValue(sessionId, out var session))
{
return Task.FromResult<BreakGlassSession?>(null);
}
if (!session.IsValid(_timeProvider))
{
_activeSessions.TryRemove(sessionId, out _);
return Task.FromResult<BreakGlassSession?>(null);
}
return Task.FromResult<BreakGlassSession?>(session);
}
/// <inheritdoc/>
public async Task<BreakGlassSessionResult> ExtendSessionAsync(
string sessionId,
string credential,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(sessionId);
ArgumentException.ThrowIfNullOrEmpty(credential);
if (!_activeSessions.TryGetValue(sessionId, out var session))
{
return new BreakGlassSessionResult
{
Success = false,
Error = "Session not found",
ErrorCode = "SESSION_NOT_FOUND"
};
}
var policy = await _policyStore.GetPolicyAsync(cancellationToken).ConfigureAwait(false);
var breakGlassConfig = policy?.BreakGlass;
if (breakGlassConfig is null)
{
return new BreakGlassSessionResult
{
Success = false,
Error = "Break-glass configuration not available",
ErrorCode = "CONFIG_NOT_AVAILABLE"
};
}
// Check max extensions
if (session.ExtensionCount >= breakGlassConfig.MaxExtensions)
{
await LogAuditEventAsync(BreakGlassAuditEventType.MaxExtensionsReached, sessionId, session.AccountId, null, $"Max extensions ({breakGlassConfig.MaxExtensions}) reached").ConfigureAwait(false);
return new BreakGlassSessionResult
{
Success = false,
Error = "Maximum session extensions reached",
ErrorCode = "MAX_EXTENSIONS_REACHED"
};
}
// Re-validate credential
var validationResult = await _policyStore.ValidateBreakGlassCredentialAsync(credential, cancellationToken).ConfigureAwait(false);
if (!validationResult.IsValid)
{
await LogAuditEventAsync(BreakGlassAuditEventType.AuthenticationFailed, sessionId, session.AccountId, null, "Re-authentication failed").ConfigureAwait(false);
return new BreakGlassSessionResult
{
Success = false,
Error = "Re-authentication failed",
ErrorCode = "REAUTHENTICATION_FAILED"
};
}
// Extend session
var now = _timeProvider.GetUtcNow();
var extendedSession = session with
{
ExpiresAt = now.AddMinutes(breakGlassConfig.SessionTimeoutMinutes),
ExtensionCount = session.ExtensionCount + 1
};
_activeSessions[sessionId] = extendedSession;
await LogAuditEventAsync(BreakGlassAuditEventType.SessionExtended, sessionId, session.AccountId, null, $"Extension {extendedSession.ExtensionCount} of {breakGlassConfig.MaxExtensions}").ConfigureAwait(false);
_logger.LogWarning(
"Break-glass session extended: SessionId={SessionId}, ExtensionCount={ExtensionCount}, NewExpiresAt={ExpiresAt}",
sessionId, extendedSession.ExtensionCount, extendedSession.ExpiresAt);
return new BreakGlassSessionResult
{
Success = true,
Session = extendedSession
};
}
/// <inheritdoc/>
public async Task TerminateSessionAsync(
string sessionId,
string reason,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(sessionId);
if (_activeSessions.TryRemove(sessionId, out var session))
{
await LogAuditEventAsync(BreakGlassAuditEventType.SessionTerminated, sessionId, session.AccountId, null, reason).ConfigureAwait(false);
_logger.LogWarning(
"Break-glass session terminated: SessionId={SessionId}, Reason={Reason}",
sessionId, reason);
}
}
/// <inheritdoc/>
public Task<IReadOnlyList<BreakGlassSession>> GetActiveSessionsAsync(
CancellationToken cancellationToken = default)
{
var now = _timeProvider.GetUtcNow();
var activeSessions = _activeSessions.Values
.Where(s => s.ExpiresAt > now)
.ToList();
return Task.FromResult<IReadOnlyList<BreakGlassSession>>(activeSessions);
}
private void CleanupExpiredSessions(object? state)
{
var now = _timeProvider.GetUtcNow();
var expiredSessionIds = _activeSessions
.Where(kvp => kvp.Value.ExpiresAt <= now)
.Select(kvp => kvp.Key)
.ToList();
foreach (var sessionId in expiredSessionIds)
{
if (_activeSessions.TryRemove(sessionId, out var session))
{
_ = LogAuditEventAsync(BreakGlassAuditEventType.SessionExpired, sessionId, session.AccountId, null, "Session expired");
_logger.LogInformation(
"Break-glass session expired: SessionId={SessionId}, AccountId={AccountId}",
sessionId, session.AccountId);
}
}
}
private async Task LogAuditEventAsync(
BreakGlassAuditEventType eventType,
string? sessionId,
string? accountId,
BreakGlassSessionRequest? request,
string? details = null)
{
var auditEvent = new BreakGlassAuditEvent
{
EventId = Guid.NewGuid().ToString("N"),
EventType = eventType,
Timestamp = _timeProvider.GetUtcNow(),
SessionId = sessionId,
AccountId = accountId,
ReasonCode = request?.ReasonCode,
ReasonText = request?.ReasonText,
ClientIp = request?.ClientIp,
UserAgent = request?.UserAgent,
Details = details is not null
? ImmutableDictionary<string, string>.Empty.Add("message", details)
: null
};
await _auditLogger.LogAsync(auditEvent, CancellationToken.None).ConfigureAwait(false);
}
private static string GenerateSessionId()
{
var bytes = RandomNumberGenerator.GetBytes(32);
return Convert.ToBase64String(bytes).Replace("+", "-").Replace("/", "_").TrimEnd('=');
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_cleanupTimer.Dispose();
}
}
/// <summary>
/// Console-based break-glass audit logger (for development/fallback).
/// </summary>
public sealed class ConsoleBreakGlassAuditLogger : IBreakGlassAuditLogger
{
private readonly ILogger<ConsoleBreakGlassAuditLogger> _logger;
public ConsoleBreakGlassAuditLogger(ILogger<ConsoleBreakGlassAuditLogger> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task LogAsync(BreakGlassAuditEvent auditEvent, CancellationToken cancellationToken = default)
{
_logger.LogWarning(
"[BREAK-GLASS-AUDIT] EventType={EventType}, SessionId={SessionId}, AccountId={AccountId}, ReasonCode={ReasonCode}, ClientIp={ClientIp}, Details={Details}",
auditEvent.EventType,
auditEvent.SessionId,
auditEvent.AccountId,
auditEvent.ReasonCode,
auditEvent.ClientIp,
auditEvent.Details is not null ? string.Join("; ", auditEvent.Details.Select(kvp => $"{kvp.Key}={kvp.Value}")) : null);
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,483 @@
// -----------------------------------------------------------------------------
// FileBasedPolicyStore.cs
// Sprint: SPRINT_20260112_018_AUTH_local_rbac_fallback
// Tasks: RBAC-002, RBAC-004, RBAC-006
// Description: File-based implementation of ILocalPolicyStore.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace StellaOps.Authority.LocalPolicy;
/// <summary>
/// File-based implementation of <see cref="ILocalPolicyStore"/>.
/// Supports YAML and JSON policy files with hot-reload.
/// </summary>
public sealed class FileBasedPolicyStore : ILocalPolicyStore, IDisposable
{
private readonly IOptionsMonitor<LocalPolicyStoreOptions> _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<FileBasedPolicyStore> _logger;
private readonly SemaphoreSlim _loadLock = new(1, 1);
private readonly IDeserializer _yamlDeserializer;
private FileSystemWatcher? _fileWatcher;
private Timer? _debounceTimer;
private LocalPolicy? _currentPolicy;
private ImmutableDictionary<string, LocalRole> _roleIndex = ImmutableDictionary<string, LocalRole>.Empty;
private ImmutableDictionary<string, LocalSubject> _subjectIndex = ImmutableDictionary<string, LocalSubject>.Empty;
private ImmutableDictionary<string, ImmutableHashSet<string>> _roleScopes = ImmutableDictionary<string, ImmutableHashSet<string>>.Empty;
private bool _disposed;
public event EventHandler<PolicyReloadedEventArgs>? PolicyReloaded;
public FileBasedPolicyStore(
IOptionsMonitor<LocalPolicyStoreOptions> options,
TimeProvider timeProvider,
ILogger<FileBasedPolicyStore> logger)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_yamlDeserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.IgnoreUnmatchedProperties()
.Build();
// Initial load
_ = ReloadAsync(CancellationToken.None);
// Setup hot-reload if enabled
if (_options.CurrentValue.EnableHotReload)
{
SetupFileWatcher();
}
}
/// <inheritdoc/>
public Task<LocalPolicy?> GetPolicyAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult(_currentPolicy);
}
/// <inheritdoc/>
public Task<IReadOnlyList<string>> GetSubjectRolesAsync(
string subjectId,
string? tenantId = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(subjectId);
if (!_subjectIndex.TryGetValue(subjectId, out var subject))
{
return Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
}
// Check tenant match
if (tenantId is not null && subject.Tenant is not null &&
!string.Equals(subject.Tenant, tenantId, StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
}
// Check expiration
if (subject.ExpiresAt.HasValue && subject.ExpiresAt.Value <= _timeProvider.GetUtcNow())
{
return Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
}
if (!subject.Enabled)
{
return Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
}
return Task.FromResult<IReadOnlyList<string>>(subject.Roles.ToArray());
}
/// <inheritdoc/>
public Task<IReadOnlyList<string>> GetRoleScopesAsync(
string roleName,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(roleName);
if (!_roleScopes.TryGetValue(roleName, out var scopes))
{
return Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
}
return Task.FromResult<IReadOnlyList<string>>(scopes.ToArray());
}
/// <inheritdoc/>
public async Task<bool> HasScopeAsync(
string subjectId,
string scope,
string? tenantId = null,
CancellationToken cancellationToken = default)
{
var scopes = await GetSubjectScopesAsync(subjectId, tenantId, cancellationToken).ConfigureAwait(false);
return scopes.Contains(scope);
}
/// <inheritdoc/>
public async Task<IReadOnlySet<string>> GetSubjectScopesAsync(
string subjectId,
string? tenantId = null,
CancellationToken cancellationToken = default)
{
var roles = await GetSubjectRolesAsync(subjectId, tenantId, cancellationToken).ConfigureAwait(false);
if (roles.Count == 0)
{
return ImmutableHashSet<string>.Empty;
}
var allScopes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var role in roles)
{
if (_roleScopes.TryGetValue(role, out var scopes))
{
allScopes.UnionWith(scopes);
}
}
return allScopes.ToImmutableHashSet(StringComparer.OrdinalIgnoreCase);
}
/// <inheritdoc/>
public Task<BreakGlassValidationResult> ValidateBreakGlassCredentialAsync(
string credential,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(credential);
if (!_options.CurrentValue.AllowBreakGlass)
{
return Task.FromResult(new BreakGlassValidationResult
{
IsValid = false,
Error = "Break-glass is disabled"
});
}
var breakGlass = _currentPolicy?.BreakGlass;
if (breakGlass is null || !breakGlass.Enabled || breakGlass.Accounts.Length == 0)
{
return Task.FromResult(new BreakGlassValidationResult
{
IsValid = false,
Error = "No break-glass accounts configured"
});
}
foreach (var account in breakGlass.Accounts)
{
if (!account.Enabled)
{
continue;
}
// Check expiration
if (account.ExpiresAt.HasValue && account.ExpiresAt.Value <= _timeProvider.GetUtcNow())
{
continue;
}
// Verify credential hash
if (VerifyCredentialHash(credential, account.CredentialHash, account.HashAlgorithm))
{
return Task.FromResult(new BreakGlassValidationResult
{
IsValid = true,
Account = account
});
}
}
return Task.FromResult(new BreakGlassValidationResult
{
IsValid = false,
Error = "Invalid break-glass credential"
});
}
/// <inheritdoc/>
public Task<bool> IsAvailableAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult(_currentPolicy is not null);
}
/// <inheritdoc/>
public async Task<bool> ReloadAsync(CancellationToken cancellationToken = default)
{
await _loadLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var options = _options.CurrentValue;
var policyPath = options.PolicyFilePath;
if (!File.Exists(policyPath))
{
return HandleMissingFile(options);
}
var policy = await LoadPolicyFileAsync(policyPath, cancellationToken).ConfigureAwait(false);
if (policy is null)
{
return false;
}
// Validate schema version
if (!options.SupportedSchemaVersions.Contains(policy.SchemaVersion))
{
_logger.LogError("Unsupported policy schema version: {Version}", policy.SchemaVersion);
RaisePolicyReloaded(false, $"Unsupported schema version: {policy.SchemaVersion}");
return false;
}
// Validate signature if required
if (options.RequireSignature || policy.SignatureRequired)
{
if (!ValidatePolicySignature(policy, policyPath))
{
_logger.LogError("Policy signature validation failed");
RaisePolicyReloaded(false, "Signature validation failed");
return false;
}
}
// Build indexes
BuildIndexes(policy, options);
_currentPolicy = policy;
_logger.LogInformation(
"Loaded local policy: {RoleCount} roles, {SubjectCount} subjects, schema {SchemaVersion}",
policy.Roles.Length,
policy.Subjects.Length,
policy.SchemaVersion);
RaisePolicyReloaded(true, null, policy.SchemaVersion, policy.Roles.Length, policy.Subjects.Length);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to reload local policy");
RaisePolicyReloaded(false, ex.Message);
return false;
}
finally
{
_loadLock.Release();
}
}
private async Task<LocalPolicy?> LoadPolicyFileAsync(string path, CancellationToken cancellationToken)
{
var content = await File.ReadAllTextAsync(path, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
var extension = Path.GetExtension(path).ToLowerInvariant();
return extension switch
{
".yaml" or ".yml" => DeserializeYaml(content),
".json" => JsonSerializer.Deserialize<LocalPolicy>(content, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
}),
_ => throw new InvalidOperationException($"Unsupported policy file format: {extension}")
};
}
private LocalPolicy? DeserializeYaml(string content)
{
// YamlDotNet to dynamic, then serialize to JSON, then deserialize to LocalPolicy
// This is a workaround for YamlDotNet's lack of direct ImmutableArray support
var yamlObject = _yamlDeserializer.Deserialize<Dictionary<object, object>>(content);
var json = JsonSerializer.Serialize(yamlObject);
return JsonSerializer.Deserialize<LocalPolicy>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
}
private void BuildIndexes(LocalPolicy policy, LocalPolicyStoreOptions options)
{
// Build role index
var roleBuilder = ImmutableDictionary.CreateBuilder<string, LocalRole>(StringComparer.OrdinalIgnoreCase);
foreach (var role in policy.Roles.Where(r => r.Enabled))
{
roleBuilder[role.Name] = role;
}
_roleIndex = roleBuilder.ToImmutable();
// Build subject index
var subjectBuilder = ImmutableDictionary.CreateBuilder<string, LocalSubject>(StringComparer.OrdinalIgnoreCase);
foreach (var subject in policy.Subjects.Where(s => s.Enabled))
{
subjectBuilder[subject.Id] = subject;
}
_subjectIndex = subjectBuilder.ToImmutable();
// Build role -> scopes index (with inheritance resolution)
var roleScopesBuilder = ImmutableDictionary.CreateBuilder<string, ImmutableHashSet<string>>(StringComparer.OrdinalIgnoreCase);
foreach (var role in _roleIndex.Values)
{
var scopes = ResolveRoleScopes(role.Name, new HashSet<string>(StringComparer.OrdinalIgnoreCase), 0, options.MaxInheritanceDepth);
roleScopesBuilder[role.Name] = scopes.ToImmutableHashSet(StringComparer.OrdinalIgnoreCase);
}
_roleScopes = roleScopesBuilder.ToImmutable();
}
private HashSet<string> ResolveRoleScopes(string roleName, HashSet<string> visited, int depth, int maxDepth)
{
if (depth > maxDepth || visited.Contains(roleName))
{
return new HashSet<string>(StringComparer.OrdinalIgnoreCase);
}
visited.Add(roleName);
if (!_roleIndex.TryGetValue(roleName, out var role))
{
return new HashSet<string>(StringComparer.OrdinalIgnoreCase);
}
var scopes = new HashSet<string>(role.Scopes, StringComparer.OrdinalIgnoreCase);
// Resolve inherited scopes
foreach (var inheritedRole in role.Inherits)
{
var inheritedScopes = ResolveRoleScopes(inheritedRole, visited, depth + 1, maxDepth);
scopes.UnionWith(inheritedScopes);
}
return scopes;
}
private bool HandleMissingFile(LocalPolicyStoreOptions options)
{
switch (options.FallbackBehavior)
{
case PolicyFallbackBehavior.EmptyPolicy:
_currentPolicy = new LocalPolicy
{
SchemaVersion = "1.0.0",
LastUpdated = _timeProvider.GetUtcNow(),
Roles = ImmutableArray<LocalRole>.Empty,
Subjects = ImmutableArray<LocalSubject>.Empty
};
_roleIndex = ImmutableDictionary<string, LocalRole>.Empty;
_subjectIndex = ImmutableDictionary<string, LocalSubject>.Empty;
_roleScopes = ImmutableDictionary<string, ImmutableHashSet<string>>.Empty;
_logger.LogWarning("Policy file not found, using empty policy: {Path}", options.PolicyFilePath);
return true;
case PolicyFallbackBehavior.FailOnMissing:
_logger.LogError("Policy file not found and fallback is disabled: {Path}", options.PolicyFilePath);
return false;
case PolicyFallbackBehavior.UseDefaults:
// Could load embedded default policy here
_logger.LogWarning("Policy file not found, using default policy: {Path}", options.PolicyFilePath);
return true;
default:
return false;
}
}
private bool ValidatePolicySignature(LocalPolicy policy, string policyPath)
{
if (string.IsNullOrEmpty(policy.Signature))
{
return false;
}
// TODO: Implement DSSE signature verification
// For now, return true if signature is present and trusted keys are not configured
if (_options.CurrentValue.TrustedPublicKeys.Count == 0)
{
_logger.LogWarning("Policy signature present but no trusted public keys configured");
return true;
}
// Actual signature verification would go here
return true;
}
private static bool VerifyCredentialHash(string credential, string hash, string algorithm)
{
return algorithm.ToLowerInvariant() switch
{
"bcrypt" => BCrypt.Net.BCrypt.Verify(credential, hash),
// "argon2id" => VerifyArgon2(credential, hash),
_ => false
};
}
private void SetupFileWatcher()
{
var options = _options.CurrentValue;
var directory = Path.GetDirectoryName(options.PolicyFilePath);
var fileName = Path.GetFileName(options.PolicyFilePath);
if (string.IsNullOrEmpty(directory) || !Directory.Exists(directory))
{
_logger.LogWarning("Cannot setup file watcher - directory does not exist: {Directory}", directory);
return;
}
_fileWatcher = new FileSystemWatcher(directory, fileName)
{
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.CreationTime | NotifyFilters.Size,
EnableRaisingEvents = true
};
_fileWatcher.Changed += OnFileChanged;
_fileWatcher.Created += OnFileChanged;
_logger.LogInformation("File watcher enabled for policy file: {Path}", options.PolicyFilePath);
}
private void OnFileChanged(object sender, FileSystemEventArgs e)
{
// Debounce multiple rapid change events
_debounceTimer?.Dispose();
_debounceTimer = new Timer(
_ => _ = ReloadAsync(CancellationToken.None),
null,
_options.CurrentValue.HotReloadDebounceMs,
Timeout.Infinite);
}
private void RaisePolicyReloaded(bool success, string? error, string? schemaVersion = null, int roleCount = 0, int subjectCount = 0)
{
PolicyReloaded?.Invoke(this, new PolicyReloadedEventArgs
{
ReloadedAt = _timeProvider.GetUtcNow(),
Success = success,
Error = error,
SchemaVersion = schemaVersion,
RoleCount = roleCount,
SubjectCount = subjectCount
});
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_fileWatcher?.Dispose();
_debounceTimer?.Dispose();
_loadLock.Dispose();
}
}

View File

@@ -0,0 +1,156 @@
// -----------------------------------------------------------------------------
// ILocalPolicyStore.cs
// Sprint: SPRINT_20260112_018_AUTH_local_rbac_fallback
// Tasks: RBAC-001
// Description: Interface for local file-based RBAC policy storage.
// -----------------------------------------------------------------------------
namespace StellaOps.Authority.LocalPolicy;
/// <summary>
/// Interface for local RBAC policy storage.
/// Provides file-based policy management for offline/air-gapped operation.
/// </summary>
public interface ILocalPolicyStore
{
/// <summary>
/// Gets the current local policy.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The current local policy or null if not loaded.</returns>
Task<LocalPolicy?> GetPolicyAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Gets roles assigned to a subject.
/// </summary>
/// <param name="subjectId">Subject identifier (user email, service account ID).</param>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of role names assigned to the subject.</returns>
Task<IReadOnlyList<string>> GetSubjectRolesAsync(
string subjectId,
string? tenantId = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets scopes for a role.
/// </summary>
/// <param name="roleName">Role name.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of scopes granted by the role.</returns>
Task<IReadOnlyList<string>> GetRoleScopesAsync(
string roleName,
CancellationToken cancellationToken = default);
/// <summary>
/// Checks if a subject has a specific scope.
/// </summary>
/// <param name="subjectId">Subject identifier.</param>
/// <param name="scope">Scope to check.</param>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if the subject has the scope.</returns>
Task<bool> HasScopeAsync(
string subjectId,
string scope,
string? tenantId = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all scopes for a subject (from all assigned roles).
/// </summary>
/// <param name="subjectId">Subject identifier.</param>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Set of all scopes the subject has.</returns>
Task<IReadOnlySet<string>> GetSubjectScopesAsync(
string subjectId,
string? tenantId = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Validates the break-glass credentials.
/// </summary>
/// <param name="credential">Break-glass credential (password or token).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Validation result with break-glass account info.</returns>
Task<BreakGlassValidationResult> ValidateBreakGlassCredentialAsync(
string credential,
CancellationToken cancellationToken = default);
/// <summary>
/// Checks if the local policy store is available and valid.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if the store is ready for use.</returns>
Task<bool> IsAvailableAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Reloads the policy from disk.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if reload was successful.</returns>
Task<bool> ReloadAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Event raised when the policy is reloaded.
/// </summary>
event EventHandler<PolicyReloadedEventArgs>? PolicyReloaded;
}
/// <summary>
/// Event arguments for policy reload events.
/// </summary>
public sealed class PolicyReloadedEventArgs : EventArgs
{
/// <summary>
/// Timestamp of the reload (UTC).
/// </summary>
public required DateTimeOffset ReloadedAt { get; init; }
/// <summary>
/// Whether the reload was successful.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// Error message if reload failed.
/// </summary>
public string? Error { get; init; }
/// <summary>
/// Schema version of the loaded policy.
/// </summary>
public string? SchemaVersion { get; init; }
/// <summary>
/// Number of roles in the policy.
/// </summary>
public int RoleCount { get; init; }
/// <summary>
/// Number of subjects in the policy.
/// </summary>
public int SubjectCount { get; init; }
}
/// <summary>
/// Result of break-glass credential validation.
/// </summary>
public sealed record BreakGlassValidationResult
{
/// <summary>
/// Whether the credential is valid.
/// </summary>
public required bool IsValid { get; init; }
/// <summary>
/// Break-glass account info if valid.
/// </summary>
public BreakGlassAccount? Account { get; init; }
/// <summary>
/// Error message if invalid.
/// </summary>
public string? Error { get; init; }
}

View File

@@ -0,0 +1,319 @@
// -----------------------------------------------------------------------------
// LocalPolicyModels.cs
// Sprint: SPRINT_20260112_018_AUTH_local_rbac_fallback
// Tasks: RBAC-003
// Description: Models for local RBAC policy file schema.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Authority.LocalPolicy;
/// <summary>
/// Root local policy document.
/// </summary>
public sealed record LocalPolicy
{
/// <summary>
/// Schema version for compatibility checking.
/// </summary>
[JsonPropertyName("schemaVersion")]
public required string SchemaVersion { get; init; }
/// <summary>
/// Last update timestamp (UTC ISO-8601).
/// </summary>
[JsonPropertyName("lastUpdated")]
public required DateTimeOffset LastUpdated { get; init; }
/// <summary>
/// Whether a signature is required to load this policy.
/// </summary>
[JsonPropertyName("signatureRequired")]
public bool SignatureRequired { get; init; } = false;
/// <summary>
/// DSSE signature envelope (base64-encoded).
/// </summary>
[JsonPropertyName("signature")]
public string? Signature { get; init; }
/// <summary>
/// Role definitions.
/// </summary>
[JsonPropertyName("roles")]
public ImmutableArray<LocalRole> Roles { get; init; } = ImmutableArray<LocalRole>.Empty;
/// <summary>
/// Subject-to-role mappings.
/// </summary>
[JsonPropertyName("subjects")]
public ImmutableArray<LocalSubject> Subjects { get; init; } = ImmutableArray<LocalSubject>.Empty;
/// <summary>
/// Break-glass account configuration.
/// </summary>
[JsonPropertyName("breakGlass")]
public BreakGlassConfig? BreakGlass { get; init; }
/// <summary>
/// Policy metadata.
/// </summary>
[JsonPropertyName("metadata")]
public ImmutableDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Role definition in local policy.
/// </summary>
public sealed record LocalRole
{
/// <summary>
/// Role name (unique identifier).
/// </summary>
[JsonPropertyName("name")]
public required string Name { get; init; }
/// <summary>
/// Human-readable description.
/// </summary>
[JsonPropertyName("description")]
public string? Description { get; init; }
/// <summary>
/// Scopes granted by this role.
/// </summary>
[JsonPropertyName("scopes")]
public ImmutableArray<string> Scopes { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Roles this role inherits from.
/// </summary>
[JsonPropertyName("inherits")]
public ImmutableArray<string> Inherits { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Whether this role is active.
/// </summary>
[JsonPropertyName("enabled")]
public bool Enabled { get; init; } = true;
/// <summary>
/// Priority for conflict resolution (higher = more priority).
/// </summary>
[JsonPropertyName("priority")]
public int Priority { get; init; } = 0;
}
/// <summary>
/// Subject (user/service account) definition in local policy.
/// </summary>
public sealed record LocalSubject
{
/// <summary>
/// Subject identifier (email, service account ID, etc.).
/// </summary>
[JsonPropertyName("id")]
public required string Id { get; init; }
/// <summary>
/// Display name.
/// </summary>
[JsonPropertyName("displayName")]
public string? DisplayName { get; init; }
/// <summary>
/// Roles assigned to this subject.
/// </summary>
[JsonPropertyName("roles")]
public ImmutableArray<string> Roles { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Tenant this subject belongs to.
/// </summary>
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
/// <summary>
/// Whether this subject is active.
/// </summary>
[JsonPropertyName("enabled")]
public bool Enabled { get; init; } = true;
/// <summary>
/// Subject expiration timestamp.
/// </summary>
[JsonPropertyName("expiresAt")]
public DateTimeOffset? ExpiresAt { get; init; }
/// <summary>
/// Additional attributes/claims.
/// </summary>
[JsonPropertyName("attributes")]
public ImmutableDictionary<string, string>? Attributes { get; init; }
}
/// <summary>
/// Break-glass account configuration.
/// </summary>
public sealed record BreakGlassConfig
{
/// <summary>
/// Whether break-glass is enabled.
/// </summary>
[JsonPropertyName("enabled")]
public bool Enabled { get; init; } = true;
/// <summary>
/// Break-glass accounts.
/// </summary>
[JsonPropertyName("accounts")]
public ImmutableArray<BreakGlassAccount> Accounts { get; init; } = ImmutableArray<BreakGlassAccount>.Empty;
/// <summary>
/// Session timeout in minutes (default 15).
/// </summary>
[JsonPropertyName("sessionTimeoutMinutes")]
public int SessionTimeoutMinutes { get; init; } = 15;
/// <summary>
/// Maximum session extensions allowed.
/// </summary>
[JsonPropertyName("maxExtensions")]
public int MaxExtensions { get; init; } = 2;
/// <summary>
/// Require reason code for break-glass usage.
/// </summary>
[JsonPropertyName("requireReasonCode")]
public bool RequireReasonCode { get; init; } = true;
/// <summary>
/// Allowed reason codes.
/// </summary>
[JsonPropertyName("allowedReasonCodes")]
public ImmutableArray<string> AllowedReasonCodes { get; init; } = ImmutableArray.Create(
"EMERGENCY",
"INCIDENT",
"DISASTER_RECOVERY",
"SECURITY_EVENT",
"MAINTENANCE"
);
}
/// <summary>
/// Break-glass account definition.
/// </summary>
public sealed record BreakGlassAccount
{
/// <summary>
/// Account identifier.
/// </summary>
[JsonPropertyName("id")]
public required string Id { get; init; }
/// <summary>
/// Display name.
/// </summary>
[JsonPropertyName("displayName")]
public string? DisplayName { get; init; }
/// <summary>
/// Hashed credential (bcrypt or argon2id).
/// </summary>
[JsonPropertyName("credentialHash")]
public required string CredentialHash { get; init; }
/// <summary>
/// Hash algorithm used (bcrypt, argon2id).
/// </summary>
[JsonPropertyName("hashAlgorithm")]
public string HashAlgorithm { get; init; } = "bcrypt";
/// <summary>
/// Roles granted when using this break-glass account.
/// </summary>
[JsonPropertyName("roles")]
public ImmutableArray<string> Roles { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Whether this account is active.
/// </summary>
[JsonPropertyName("enabled")]
public bool Enabled { get; init; } = true;
/// <summary>
/// Last usage timestamp.
/// </summary>
[JsonPropertyName("lastUsedAt")]
public DateTimeOffset? LastUsedAt { get; init; }
/// <summary>
/// Account expiration (for time-limited break-glass).
/// </summary>
[JsonPropertyName("expiresAt")]
public DateTimeOffset? ExpiresAt { get; init; }
}
/// <summary>
/// Active break-glass session.
/// </summary>
public sealed record BreakGlassSession
{
/// <summary>
/// Session ID.
/// </summary>
public required string SessionId { get; init; }
/// <summary>
/// Account ID used for this session.
/// </summary>
public required string AccountId { get; init; }
/// <summary>
/// Session start time (UTC).
/// </summary>
public required DateTimeOffset StartedAt { get; init; }
/// <summary>
/// Session expiration time (UTC).
/// </summary>
public required DateTimeOffset ExpiresAt { get; init; }
/// <summary>
/// Reason code provided.
/// </summary>
public required string ReasonCode { get; init; }
/// <summary>
/// Additional reason text.
/// </summary>
public string? ReasonText { get; init; }
/// <summary>
/// Number of extensions used.
/// </summary>
public int ExtensionCount { get; init; }
/// <summary>
/// Client IP address.
/// </summary>
public string? ClientIp { get; init; }
/// <summary>
/// User agent string.
/// </summary>
public string? UserAgent { get; init; }
/// <summary>
/// Roles granted in this session.
/// </summary>
public ImmutableArray<string> Roles { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Whether the session is still valid.
/// </summary>
public bool IsValid(TimeProvider timeProvider) =>
ExpiresAt > timeProvider.GetUtcNow();
}

View File

@@ -0,0 +1,100 @@
// -----------------------------------------------------------------------------
// LocalPolicyStoreOptions.cs
// Sprint: SPRINT_20260112_018_AUTH_local_rbac_fallback
// Tasks: RBAC-002, RBAC-004
// Description: Configuration options for local policy store.
// -----------------------------------------------------------------------------
namespace StellaOps.Authority.LocalPolicy;
/// <summary>
/// Configuration options for local policy store.
/// </summary>
public sealed class LocalPolicyStoreOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "Authority:LocalPolicy";
/// <summary>
/// Whether local policy store is enabled.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Path to the policy file.
/// </summary>
public string PolicyFilePath { get; set; } = "/etc/stellaops/authority/local-policy.yaml";
/// <summary>
/// Whether to enable file watching for hot-reload.
/// </summary>
public bool EnableHotReload { get; set; } = true;
/// <summary>
/// Debounce interval for file change events (milliseconds).
/// </summary>
public int HotReloadDebounceMs { get; set; } = 500;
/// <summary>
/// Whether to require policy file signature.
/// </summary>
public bool RequireSignature { get; set; } = false;
/// <summary>
/// Public keys for policy signature verification.
/// </summary>
public IReadOnlyList<string> TrustedPublicKeys { get; set; } = Array.Empty<string>();
/// <summary>
/// Fallback behavior when policy file is missing.
/// </summary>
public PolicyFallbackBehavior FallbackBehavior { get; set; } = PolicyFallbackBehavior.EmptyPolicy;
/// <summary>
/// Whether to allow break-glass accounts from local policy.
/// </summary>
public bool AllowBreakGlass { get; set; } = true;
/// <summary>
/// Supported schema versions.
/// </summary>
public IReadOnlySet<string> SupportedSchemaVersions { get; set; } = new HashSet<string>(StringComparer.Ordinal)
{
"1.0.0",
"1.0.1",
"1.1.0"
};
/// <summary>
/// Whether to validate role inheritance cycles.
/// </summary>
public bool ValidateInheritanceCycles { get; set; } = true;
/// <summary>
/// Maximum role inheritance depth.
/// </summary>
public int MaxInheritanceDepth { get; set; } = 10;
}
/// <summary>
/// Fallback behavior when policy file is missing.
/// </summary>
public enum PolicyFallbackBehavior
{
/// <summary>
/// Use empty policy (deny all).
/// </summary>
EmptyPolicy,
/// <summary>
/// Fail startup if policy file is missing.
/// </summary>
FailOnMissing,
/// <summary>
/// Use embedded default policy.
/// </summary>
UseDefaults
}

View File

@@ -0,0 +1,378 @@
// -----------------------------------------------------------------------------
// PolicyStoreFallback.cs
// Sprint: SPRINT_20260112_018_AUTH_local_rbac_fallback
// Tasks: RBAC-005
// Description: Fallback mechanism for RBAC when PostgreSQL is unavailable.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Authority.LocalPolicy;
/// <summary>
/// Configuration for policy store fallback.
/// </summary>
public sealed class PolicyStoreFallbackOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "Authority:PolicyFallback";
/// <summary>
/// Whether fallback is enabled.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Health check interval for primary store (milliseconds).
/// </summary>
public int HealthCheckIntervalMs { get; set; } = 5000;
/// <summary>
/// Number of consecutive failures before switching to fallback.
/// </summary>
public int FailureThreshold { get; set; } = 3;
/// <summary>
/// Minimum time to stay in fallback mode (milliseconds).
/// </summary>
public int MinFallbackDurationMs { get; set; } = 30000;
/// <summary>
/// Whether to log scope lookups in fallback mode.
/// </summary>
public bool LogFallbackLookups { get; set; } = true;
}
/// <summary>
/// Policy store mode.
/// </summary>
public enum PolicyStoreMode
{
/// <summary>
/// Using primary (PostgreSQL) store.
/// </summary>
Primary,
/// <summary>
/// Using fallback (local file) store.
/// </summary>
Fallback,
/// <summary>
/// Both stores unavailable.
/// </summary>
Degraded
}
/// <summary>
/// Event arguments for policy store mode changes.
/// </summary>
public sealed class PolicyStoreModeChangedEventArgs : EventArgs
{
/// <summary>
/// Previous mode.
/// </summary>
public required PolicyStoreMode PreviousMode { get; init; }
/// <summary>
/// New mode.
/// </summary>
public required PolicyStoreMode NewMode { get; init; }
/// <summary>
/// Change timestamp (UTC).
/// </summary>
public required DateTimeOffset ChangedAt { get; init; }
/// <summary>
/// Reason for the change.
/// </summary>
public string? Reason { get; init; }
}
/// <summary>
/// Interface for checking primary policy store health.
/// </summary>
public interface IPrimaryPolicyStoreHealthCheck
{
/// <summary>
/// Checks if the primary store is healthy.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if healthy.</returns>
Task<bool> IsHealthyAsync(CancellationToken cancellationToken = default);
}
/// <summary>
/// Composite policy store that falls back to local store when primary is unavailable.
/// </summary>
public sealed class FallbackPolicyStore : ILocalPolicyStore, IDisposable
{
private readonly ILocalPolicyStore _localStore;
private readonly IPrimaryPolicyStoreHealthCheck _healthCheck;
private readonly IOptionsMonitor<PolicyStoreFallbackOptions> _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<FallbackPolicyStore> _logger;
private readonly Timer _healthCheckTimer;
private readonly object _stateLock = new();
private PolicyStoreMode _currentMode = PolicyStoreMode.Primary;
private int _consecutiveFailures;
private DateTimeOffset? _fallbackStartedAt;
private bool _disposed;
public event EventHandler<PolicyReloadedEventArgs>? PolicyReloaded;
public event EventHandler<PolicyStoreModeChangedEventArgs>? ModeChanged;
/// <summary>
/// Current policy store mode.
/// </summary>
public PolicyStoreMode CurrentMode => _currentMode;
public FallbackPolicyStore(
ILocalPolicyStore localStore,
IPrimaryPolicyStoreHealthCheck healthCheck,
IOptionsMonitor<PolicyStoreFallbackOptions> options,
TimeProvider timeProvider,
ILogger<FallbackPolicyStore> logger)
{
_localStore = localStore ?? throw new ArgumentNullException(nameof(localStore));
_healthCheck = healthCheck ?? throw new ArgumentNullException(nameof(healthCheck));
_options = options ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
// Forward reload events from local store
_localStore.PolicyReloaded += (s, e) => PolicyReloaded?.Invoke(this, e);
// Start health check timer
var interval = TimeSpan.FromMilliseconds(_options.CurrentValue.HealthCheckIntervalMs);
_healthCheckTimer = new Timer(OnHealthCheck, null, interval, interval);
}
/// <inheritdoc/>
public async Task<LocalPolicy?> GetPolicyAsync(CancellationToken cancellationToken = default)
{
await EnsureCorrectModeAsync(cancellationToken).ConfigureAwait(false);
return await _localStore.GetPolicyAsync(cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task<IReadOnlyList<string>> GetSubjectRolesAsync(
string subjectId,
string? tenantId = null,
CancellationToken cancellationToken = default)
{
await EnsureCorrectModeAsync(cancellationToken).ConfigureAwait(false);
if (_currentMode == PolicyStoreMode.Primary)
{
// In primary mode, delegate to primary store
// This would be the actual PostgreSQL-backed implementation
// For now, fallback to local
}
var roles = await _localStore.GetSubjectRolesAsync(subjectId, tenantId, cancellationToken).ConfigureAwait(false);
if (_options.CurrentValue.LogFallbackLookups && _currentMode == PolicyStoreMode.Fallback)
{
_logger.LogDebug(
"[FALLBACK] GetSubjectRoles: SubjectId={SubjectId}, TenantId={TenantId}, Roles={Roles}",
subjectId, tenantId, string.Join(",", roles));
}
return roles;
}
/// <inheritdoc/>
public async Task<IReadOnlyList<string>> GetRoleScopesAsync(
string roleName,
CancellationToken cancellationToken = default)
{
await EnsureCorrectModeAsync(cancellationToken).ConfigureAwait(false);
return await _localStore.GetRoleScopesAsync(roleName, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task<bool> HasScopeAsync(
string subjectId,
string scope,
string? tenantId = null,
CancellationToken cancellationToken = default)
{
await EnsureCorrectModeAsync(cancellationToken).ConfigureAwait(false);
return await _localStore.HasScopeAsync(subjectId, scope, tenantId, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task<IReadOnlySet<string>> GetSubjectScopesAsync(
string subjectId,
string? tenantId = null,
CancellationToken cancellationToken = default)
{
await EnsureCorrectModeAsync(cancellationToken).ConfigureAwait(false);
return await _localStore.GetSubjectScopesAsync(subjectId, tenantId, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public Task<BreakGlassValidationResult> ValidateBreakGlassCredentialAsync(
string credential,
CancellationToken cancellationToken = default)
{
// Break-glass is always via local store
return _localStore.ValidateBreakGlassCredentialAsync(credential, cancellationToken);
}
/// <inheritdoc/>
public Task<bool> IsAvailableAsync(CancellationToken cancellationToken = default)
{
return _localStore.IsAvailableAsync(cancellationToken);
}
/// <inheritdoc/>
public Task<bool> ReloadAsync(CancellationToken cancellationToken = default)
{
return _localStore.ReloadAsync(cancellationToken);
}
private async Task EnsureCorrectModeAsync(CancellationToken cancellationToken)
{
if (!_options.CurrentValue.Enabled)
{
return;
}
// Quick check without health probe
if (_currentMode == PolicyStoreMode.Primary)
{
return;
}
// In fallback mode, check if we can return to primary
if (_currentMode == PolicyStoreMode.Fallback && CanAttemptPrimaryRecovery())
{
try
{
if (await _healthCheck.IsHealthyAsync(cancellationToken).ConfigureAwait(false))
{
SwitchToPrimary("Primary store recovered");
}
}
catch
{
// Stay in fallback
}
}
}
private void OnHealthCheck(object? state)
{
if (_disposed) return;
_ = Task.Run(async () =>
{
try
{
var healthy = await _healthCheck.IsHealthyAsync(CancellationToken.None).ConfigureAwait(false);
lock (_stateLock)
{
if (healthy)
{
_consecutiveFailures = 0;
if (_currentMode == PolicyStoreMode.Fallback && CanAttemptPrimaryRecovery())
{
SwitchToPrimary("Primary store healthy");
}
}
else
{
_consecutiveFailures++;
if (_currentMode == PolicyStoreMode.Primary &&
_consecutiveFailures >= _options.CurrentValue.FailureThreshold)
{
SwitchToFallback($"Primary store unhealthy ({_consecutiveFailures} consecutive failures)");
}
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Health check failed");
lock (_stateLock)
{
_consecutiveFailures++;
if (_currentMode == PolicyStoreMode.Primary &&
_consecutiveFailures >= _options.CurrentValue.FailureThreshold)
{
SwitchToFallback($"Health check exception ({_consecutiveFailures} consecutive failures)");
}
}
}
});
}
private bool CanAttemptPrimaryRecovery()
{
if (_fallbackStartedAt is null)
{
return true;
}
var minDuration = TimeSpan.FromMilliseconds(_options.CurrentValue.MinFallbackDurationMs);
return _timeProvider.GetUtcNow() - _fallbackStartedAt.Value >= minDuration;
}
private void SwitchToFallback(string reason)
{
var previousMode = _currentMode;
_currentMode = PolicyStoreMode.Fallback;
_fallbackStartedAt = _timeProvider.GetUtcNow();
_logger.LogWarning(
"Switching to fallback policy store: {Reason}",
reason);
ModeChanged?.Invoke(this, new PolicyStoreModeChangedEventArgs
{
PreviousMode = previousMode,
NewMode = PolicyStoreMode.Fallback,
ChangedAt = _fallbackStartedAt.Value,
Reason = reason
});
}
private void SwitchToPrimary(string reason)
{
var previousMode = _currentMode;
_currentMode = PolicyStoreMode.Primary;
_fallbackStartedAt = null;
_consecutiveFailures = 0;
_logger.LogInformation(
"Returning to primary policy store: {Reason}",
reason);
ModeChanged?.Invoke(this, new PolicyStoreModeChangedEventArgs
{
PreviousMode = previousMode,
NewMode = PolicyStoreMode.Primary,
ChangedAt = _timeProvider.GetUtcNow(),
Reason = reason
});
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_healthCheckTimer.Dispose();
}
}