old sprints work, new sprints for exposing functionality via cli, improve code_of_conduct and other agents instructions
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user