finish secrets finding work and audit remarks work save
This commit is contained in:
@@ -0,0 +1,497 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SecretDetectionSettingsService.cs
|
||||
// Sprint: SPRINT_20260104_006_BE (Secret Detection Configuration API)
|
||||
// Task: SDC-005 - Create Settings CRUD API endpoints
|
||||
// Description: Service layer for secret detection configuration.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Scanner.Core.Secrets.Configuration;
|
||||
using StellaOps.Scanner.Storage.Entities;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service interface for secret detection settings.
|
||||
/// </summary>
|
||||
public interface ISecretDetectionSettingsService
|
||||
{
|
||||
/// <summary>Gets settings for a tenant.</summary>
|
||||
Task<SecretDetectionSettingsResponseDto?> GetSettingsAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Creates default settings for a tenant.</summary>
|
||||
Task<SecretDetectionSettingsResponseDto> CreateSettingsAsync(
|
||||
Guid tenantId,
|
||||
string createdBy,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Updates settings with optimistic concurrency.</summary>
|
||||
Task<(bool Success, SecretDetectionSettingsResponseDto? Settings, string? Error)> UpdateSettingsAsync(
|
||||
Guid tenantId,
|
||||
SecretDetectionSettingsDto settings,
|
||||
int expectedVersion,
|
||||
string updatedBy,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Gets available rule categories.</summary>
|
||||
Task<RuleCategoriesResponseDto> GetRuleCategoriesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service interface for secret exception patterns.
|
||||
/// </summary>
|
||||
public interface ISecretExceptionPatternService
|
||||
{
|
||||
/// <summary>Gets all exception patterns for a tenant.</summary>
|
||||
Task<SecretExceptionPatternListResponseDto> GetPatternsAsync(
|
||||
Guid tenantId,
|
||||
bool includeInactive = false,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Gets a specific pattern by ID.</summary>
|
||||
Task<SecretExceptionPatternResponseDto?> GetPatternAsync(
|
||||
Guid patternId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Creates a new exception pattern.</summary>
|
||||
Task<(SecretExceptionPatternResponseDto? Pattern, IReadOnlyList<string> Errors)> CreatePatternAsync(
|
||||
Guid tenantId,
|
||||
SecretExceptionPatternDto pattern,
|
||||
string createdBy,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Updates an exception pattern.</summary>
|
||||
Task<(bool Success, SecretExceptionPatternResponseDto? Pattern, IReadOnlyList<string> Errors)> UpdatePatternAsync(
|
||||
Guid patternId,
|
||||
SecretExceptionPatternDto pattern,
|
||||
string updatedBy,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Deletes an exception pattern.</summary>
|
||||
Task<bool> DeletePatternAsync(
|
||||
Guid patternId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of secret detection settings service.
|
||||
/// </summary>
|
||||
public sealed class SecretDetectionSettingsService : ISecretDetectionSettingsService
|
||||
{
|
||||
private readonly ISecretDetectionSettingsRepository _repository;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
public SecretDetectionSettingsService(
|
||||
ISecretDetectionSettingsRepository repository,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public async Task<SecretDetectionSettingsResponseDto?> GetSettingsAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var row = await _repository.GetByTenantAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
return row is null ? null : MapToDto(row);
|
||||
}
|
||||
|
||||
public async Task<SecretDetectionSettingsResponseDto> CreateSettingsAsync(
|
||||
Guid tenantId,
|
||||
string createdBy,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var defaultSettings = SecretDetectionSettings.CreateDefault(tenantId, createdBy);
|
||||
var row = MapToRow(defaultSettings, tenantId, createdBy);
|
||||
|
||||
var created = await _repository.CreateAsync(row, cancellationToken).ConfigureAwait(false);
|
||||
return MapToDto(created);
|
||||
}
|
||||
|
||||
public async Task<(bool Success, SecretDetectionSettingsResponseDto? Settings, string? Error)> UpdateSettingsAsync(
|
||||
Guid tenantId,
|
||||
SecretDetectionSettingsDto settings,
|
||||
int expectedVersion,
|
||||
string updatedBy,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existing = await _repository.GetByTenantAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
if (existing is null)
|
||||
{
|
||||
return (false, null, "Settings not found for tenant");
|
||||
}
|
||||
|
||||
// Validate settings
|
||||
var validationErrors = ValidateSettings(settings);
|
||||
if (validationErrors.Count > 0)
|
||||
{
|
||||
return (false, null, string.Join("; ", validationErrors));
|
||||
}
|
||||
|
||||
// Apply updates
|
||||
existing.Enabled = settings.Enabled;
|
||||
existing.RevelationPolicy = JsonSerializer.Serialize(settings.RevelationPolicy, JsonOptions);
|
||||
existing.EnabledRuleCategories = settings.EnabledRuleCategories.ToArray();
|
||||
existing.DisabledRuleIds = settings.DisabledRuleIds.ToArray();
|
||||
existing.AlertSettings = JsonSerializer.Serialize(settings.AlertSettings, JsonOptions);
|
||||
existing.MaxFileSizeBytes = settings.MaxFileSizeBytes;
|
||||
existing.ExcludedFileExtensions = settings.ExcludedFileExtensions.ToArray();
|
||||
existing.ExcludedPaths = settings.ExcludedPaths.ToArray();
|
||||
existing.ScanBinaryFiles = settings.ScanBinaryFiles;
|
||||
existing.RequireSignedRuleBundles = settings.RequireSignedRuleBundles;
|
||||
existing.UpdatedBy = updatedBy;
|
||||
|
||||
var success = await _repository.UpdateAsync(existing, expectedVersion, cancellationToken).ConfigureAwait(false);
|
||||
if (!success)
|
||||
{
|
||||
return (false, null, "Version conflict - settings were modified by another request");
|
||||
}
|
||||
|
||||
// Fetch updated version
|
||||
var updated = await _repository.GetByTenantAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
return (true, updated is null ? null : MapToDto(updated), null);
|
||||
}
|
||||
|
||||
public Task<RuleCategoriesResponseDto> GetRuleCategoriesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var categories = new List<RuleCategoryDto>
|
||||
{
|
||||
new() { Id = SecretRuleCategories.Aws, Name = "AWS", Description = "Amazon Web Services credentials", RuleCount = 15 },
|
||||
new() { Id = SecretRuleCategories.Gcp, Name = "GCP", Description = "Google Cloud Platform credentials", RuleCount = 12 },
|
||||
new() { Id = SecretRuleCategories.Azure, Name = "Azure", Description = "Microsoft Azure credentials", RuleCount = 10 },
|
||||
new() { Id = SecretRuleCategories.Generic, Name = "Generic", Description = "Generic secrets and passwords", RuleCount = 25 },
|
||||
new() { Id = SecretRuleCategories.PrivateKeys, Name = "Private Keys", Description = "SSH, PGP, and other private keys", RuleCount = 8 },
|
||||
new() { Id = SecretRuleCategories.Database, Name = "Database", Description = "Database connection strings and credentials", RuleCount = 18 },
|
||||
new() { Id = SecretRuleCategories.Messaging, Name = "Messaging", Description = "Messaging platform credentials (Slack, Discord)", RuleCount = 6 },
|
||||
new() { Id = SecretRuleCategories.Payment, Name = "Payment", Description = "Payment processor credentials (Stripe, PayPal)", RuleCount = 5 },
|
||||
new() { Id = SecretRuleCategories.SocialMedia, Name = "Social Media", Description = "Social media API keys", RuleCount = 8 },
|
||||
new() { Id = SecretRuleCategories.Internal, Name = "Internal", Description = "Custom internal secrets", RuleCount = 0 }
|
||||
};
|
||||
|
||||
return Task.FromResult(new RuleCategoriesResponseDto { Categories = categories });
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ValidateSettings(SecretDetectionSettingsDto settings)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
if (settings.MaxFileSizeBytes < 1024)
|
||||
{
|
||||
errors.Add("MaxFileSizeBytes must be at least 1024 bytes");
|
||||
}
|
||||
|
||||
if (settings.MaxFileSizeBytes > 100 * 1024 * 1024)
|
||||
{
|
||||
errors.Add("MaxFileSizeBytes must be 100 MB or less");
|
||||
}
|
||||
|
||||
if (settings.RevelationPolicy.PartialRevealChars < 1 || settings.RevelationPolicy.PartialRevealChars > 10)
|
||||
{
|
||||
errors.Add("PartialRevealChars must be between 1 and 10");
|
||||
}
|
||||
|
||||
if (settings.AlertSettings.Enabled && settings.AlertSettings.Destinations.Count == 0)
|
||||
{
|
||||
errors.Add("At least one destination is required when alerting is enabled");
|
||||
}
|
||||
|
||||
if (settings.AlertSettings.MaxAlertsPerScan < 1 || settings.AlertSettings.MaxAlertsPerScan > 100)
|
||||
{
|
||||
errors.Add("MaxAlertsPerScan must be between 1 and 100");
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
private static SecretDetectionSettingsResponseDto MapToDto(SecretDetectionSettingsRow row)
|
||||
{
|
||||
var revelationPolicy = JsonSerializer.Deserialize<RevelationPolicyDto>(row.RevelationPolicy, JsonOptions)
|
||||
?? new RevelationPolicyDto
|
||||
{
|
||||
DefaultPolicy = SecretRevelationPolicyType.PartialReveal,
|
||||
ExportPolicy = SecretRevelationPolicyType.FullMask,
|
||||
PartialRevealChars = 4,
|
||||
MaxMaskChars = 8,
|
||||
FullRevealRoles = []
|
||||
};
|
||||
|
||||
var alertSettings = JsonSerializer.Deserialize<SecretAlertSettingsDto>(row.AlertSettings, JsonOptions)
|
||||
?? new SecretAlertSettingsDto
|
||||
{
|
||||
Enabled = false,
|
||||
MinimumAlertSeverity = SecretSeverityType.High,
|
||||
Destinations = [],
|
||||
MaxAlertsPerScan = 10,
|
||||
DeduplicationWindowMinutes = 1440,
|
||||
IncludeFilePath = true,
|
||||
IncludeMaskedValue = true,
|
||||
IncludeImageRef = true
|
||||
};
|
||||
|
||||
return new SecretDetectionSettingsResponseDto
|
||||
{
|
||||
TenantId = row.TenantId,
|
||||
Settings = new SecretDetectionSettingsDto
|
||||
{
|
||||
Enabled = row.Enabled,
|
||||
RevelationPolicy = revelationPolicy,
|
||||
EnabledRuleCategories = row.EnabledRuleCategories,
|
||||
DisabledRuleIds = row.DisabledRuleIds,
|
||||
AlertSettings = alertSettings,
|
||||
MaxFileSizeBytes = row.MaxFileSizeBytes,
|
||||
ExcludedFileExtensions = row.ExcludedFileExtensions,
|
||||
ExcludedPaths = row.ExcludedPaths,
|
||||
ScanBinaryFiles = row.ScanBinaryFiles,
|
||||
RequireSignedRuleBundles = row.RequireSignedRuleBundles
|
||||
},
|
||||
Version = row.Version,
|
||||
UpdatedAt = row.UpdatedAt,
|
||||
UpdatedBy = row.UpdatedBy
|
||||
};
|
||||
}
|
||||
|
||||
private static SecretDetectionSettingsRow MapToRow(SecretDetectionSettings settings, Guid tenantId, string updatedBy)
|
||||
{
|
||||
var revelationPolicyDto = new RevelationPolicyDto
|
||||
{
|
||||
DefaultPolicy = (SecretRevelationPolicyType)settings.RevelationPolicy.DefaultPolicy,
|
||||
ExportPolicy = (SecretRevelationPolicyType)settings.RevelationPolicy.ExportPolicy,
|
||||
PartialRevealChars = settings.RevelationPolicy.PartialRevealChars,
|
||||
MaxMaskChars = settings.RevelationPolicy.MaxMaskChars,
|
||||
FullRevealRoles = settings.RevelationPolicy.FullRevealRoles
|
||||
};
|
||||
|
||||
var alertSettingsDto = new SecretAlertSettingsDto
|
||||
{
|
||||
Enabled = settings.AlertSettings.Enabled,
|
||||
MinimumAlertSeverity = (SecretSeverityType)settings.AlertSettings.MinimumAlertSeverity,
|
||||
Destinations = settings.AlertSettings.Destinations.Select(d => new SecretAlertDestinationDto
|
||||
{
|
||||
Id = d.Id,
|
||||
Name = d.Name,
|
||||
ChannelType = (Contracts.AlertChannelType)d.ChannelType,
|
||||
ChannelId = d.ChannelId,
|
||||
SeverityFilter = d.SeverityFilter?.Select(s => (SecretSeverityType)s).ToList(),
|
||||
RuleCategoryFilter = d.RuleCategoryFilter?.ToList(),
|
||||
IsActive = d.IsActive
|
||||
}).ToList(),
|
||||
MaxAlertsPerScan = settings.AlertSettings.MaxAlertsPerScan,
|
||||
DeduplicationWindowMinutes = (int)settings.AlertSettings.DeduplicationWindow.TotalMinutes,
|
||||
IncludeFilePath = settings.AlertSettings.IncludeFilePath,
|
||||
IncludeMaskedValue = settings.AlertSettings.IncludeMaskedValue,
|
||||
IncludeImageRef = settings.AlertSettings.IncludeImageRef,
|
||||
AlertMessagePrefix = settings.AlertSettings.AlertMessagePrefix
|
||||
};
|
||||
|
||||
return new SecretDetectionSettingsRow
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Enabled = settings.Enabled,
|
||||
RevelationPolicy = JsonSerializer.Serialize(revelationPolicyDto, JsonOptions),
|
||||
EnabledRuleCategories = settings.EnabledRuleCategories.ToArray(),
|
||||
DisabledRuleIds = settings.DisabledRuleIds.ToArray(),
|
||||
AlertSettings = JsonSerializer.Serialize(alertSettingsDto, JsonOptions),
|
||||
MaxFileSizeBytes = settings.MaxFileSizeBytes,
|
||||
ExcludedFileExtensions = settings.ExcludedFileExtensions.ToArray(),
|
||||
ExcludedPaths = settings.ExcludedPaths.ToArray(),
|
||||
ScanBinaryFiles = settings.ScanBinaryFiles,
|
||||
RequireSignedRuleBundles = settings.RequireSignedRuleBundles,
|
||||
UpdatedBy = updatedBy
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of secret exception pattern service.
|
||||
/// </summary>
|
||||
public sealed class SecretExceptionPatternService : ISecretExceptionPatternService
|
||||
{
|
||||
private readonly ISecretExceptionPatternRepository _repository;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public SecretExceptionPatternService(
|
||||
ISecretExceptionPatternRepository repository,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public async Task<SecretExceptionPatternListResponseDto> GetPatternsAsync(
|
||||
Guid tenantId,
|
||||
bool includeInactive = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var patterns = includeInactive
|
||||
? await _repository.GetAllByTenantAsync(tenantId, cancellationToken).ConfigureAwait(false)
|
||||
: await _repository.GetActiveByTenantAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new SecretExceptionPatternListResponseDto
|
||||
{
|
||||
Patterns = patterns.Select(MapToDto).ToList(),
|
||||
TotalCount = patterns.Count
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<SecretExceptionPatternResponseDto?> GetPatternAsync(
|
||||
Guid patternId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var pattern = await _repository.GetByIdAsync(patternId, cancellationToken).ConfigureAwait(false);
|
||||
return pattern is null ? null : MapToDto(pattern);
|
||||
}
|
||||
|
||||
public async Task<(SecretExceptionPatternResponseDto? Pattern, IReadOnlyList<string> Errors)> CreatePatternAsync(
|
||||
Guid tenantId,
|
||||
SecretExceptionPatternDto pattern,
|
||||
string createdBy,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var errors = ValidatePattern(pattern);
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return (null, errors);
|
||||
}
|
||||
|
||||
var row = new SecretExceptionPatternRow
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Name = pattern.Name,
|
||||
Description = pattern.Description,
|
||||
ValuePattern = pattern.ValuePattern,
|
||||
ApplicableRuleIds = pattern.ApplicableRuleIds.ToArray(),
|
||||
FilePathGlob = pattern.FilePathGlob,
|
||||
Justification = pattern.Justification,
|
||||
ExpiresAt = pattern.ExpiresAt,
|
||||
IsActive = pattern.IsActive,
|
||||
CreatedBy = createdBy
|
||||
};
|
||||
|
||||
var created = await _repository.CreateAsync(row, cancellationToken).ConfigureAwait(false);
|
||||
return (MapToDto(created), []);
|
||||
}
|
||||
|
||||
public async Task<(bool Success, SecretExceptionPatternResponseDto? Pattern, IReadOnlyList<string> Errors)> UpdatePatternAsync(
|
||||
Guid patternId,
|
||||
SecretExceptionPatternDto pattern,
|
||||
string updatedBy,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existing = await _repository.GetByIdAsync(patternId, cancellationToken).ConfigureAwait(false);
|
||||
if (existing is null)
|
||||
{
|
||||
return (false, null, ["Pattern not found"]);
|
||||
}
|
||||
|
||||
var errors = ValidatePattern(pattern);
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return (false, null, errors);
|
||||
}
|
||||
|
||||
existing.Name = pattern.Name;
|
||||
existing.Description = pattern.Description;
|
||||
existing.ValuePattern = pattern.ValuePattern;
|
||||
existing.ApplicableRuleIds = pattern.ApplicableRuleIds.ToArray();
|
||||
existing.FilePathGlob = pattern.FilePathGlob;
|
||||
existing.Justification = pattern.Justification;
|
||||
existing.ExpiresAt = pattern.ExpiresAt;
|
||||
existing.IsActive = pattern.IsActive;
|
||||
existing.UpdatedBy = updatedBy;
|
||||
existing.UpdatedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
var success = await _repository.UpdateAsync(existing, cancellationToken).ConfigureAwait(false);
|
||||
if (!success)
|
||||
{
|
||||
return (false, null, ["Failed to update pattern"]);
|
||||
}
|
||||
|
||||
var updated = await _repository.GetByIdAsync(patternId, cancellationToken).ConfigureAwait(false);
|
||||
return (true, updated is null ? null : MapToDto(updated), []);
|
||||
}
|
||||
|
||||
public async Task<bool> DeletePatternAsync(
|
||||
Guid patternId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _repository.DeleteAsync(patternId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ValidatePattern(SecretExceptionPatternDto pattern)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(pattern.Name))
|
||||
{
|
||||
errors.Add("Name is required");
|
||||
}
|
||||
else if (pattern.Name.Length > 100)
|
||||
{
|
||||
errors.Add("Name must be 100 characters or less");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(pattern.ValuePattern))
|
||||
{
|
||||
errors.Add("ValuePattern is required");
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = new System.Text.RegularExpressions.Regex(pattern.ValuePattern);
|
||||
}
|
||||
catch (System.Text.RegularExpressions.RegexParseException ex)
|
||||
{
|
||||
errors.Add(string.Format(CultureInfo.InvariantCulture, "ValuePattern is not a valid regex: {0}", ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(pattern.Justification))
|
||||
{
|
||||
errors.Add("Justification is required");
|
||||
}
|
||||
else if (pattern.Justification.Length < 20)
|
||||
{
|
||||
errors.Add("Justification must be at least 20 characters");
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
private static SecretExceptionPatternResponseDto MapToDto(SecretExceptionPatternRow row)
|
||||
{
|
||||
return new SecretExceptionPatternResponseDto
|
||||
{
|
||||
Id = row.ExceptionId,
|
||||
TenantId = row.TenantId,
|
||||
Pattern = new SecretExceptionPatternDto
|
||||
{
|
||||
Name = row.Name,
|
||||
Description = row.Description,
|
||||
ValuePattern = row.ValuePattern,
|
||||
ApplicableRuleIds = row.ApplicableRuleIds,
|
||||
FilePathGlob = row.FilePathGlob,
|
||||
Justification = row.Justification,
|
||||
ExpiresAt = row.ExpiresAt,
|
||||
IsActive = row.IsActive
|
||||
},
|
||||
MatchCount = row.MatchCount,
|
||||
LastMatchedAt = row.LastMatchedAt,
|
||||
CreatedAt = row.CreatedAt,
|
||||
CreatedBy = row.CreatedBy,
|
||||
UpdatedAt = row.UpdatedAt,
|
||||
UpdatedBy = row.UpdatedBy
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user