feat(infrastructure): add data access and authentication services

This commit is contained in:
2024-12-18 12:00:00 -05:00
parent 7a09f8e2f6
commit 370408af95
13 changed files with 577 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
namespace IncidentOps.Infrastructure.Auth;
public interface IPasswordService
{
string HashPassword(string password);
bool VerifyPassword(string password, string hash);
}
public class PasswordService : IPasswordService
{
public string HashPassword(string password)
{
return BCrypt.Net.BCrypt.HashPassword(password);
}
public bool VerifyPassword(string password, string hash)
{
return BCrypt.Net.BCrypt.Verify(password, hash);
}
}

View File

@@ -0,0 +1,63 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using IncidentOps.Domain.Enums;
using Microsoft.IdentityModel.Tokens;
namespace IncidentOps.Infrastructure.Auth;
public interface ITokenService
{
string GenerateAccessToken(Guid userId, Guid orgId, OrgRole orgRole);
string GenerateRefreshToken();
string HashToken(string token);
}
public class TokenService : ITokenService
{
private readonly JwtSettings _settings;
public TokenService(JwtSettings settings)
{
_settings = settings;
}
public string GenerateAccessToken(Guid userId, Guid orgId, OrgRole orgRole)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_settings.SigningKey));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim("org_id", orgId.ToString()),
new Claim("org_role", orgRole.ToString().ToLowerInvariant()),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
var token = new JwtSecurityToken(
issuer: _settings.Issuer,
audience: _settings.Audience,
claims: claims,
expires: DateTime.UtcNow.AddMinutes(_settings.AccessTokenExpirationMinutes),
signingCredentials: credentials
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
public string GenerateRefreshToken()
{
var randomBytes = new byte[64];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(randomBytes);
return Convert.ToBase64String(randomBytes);
}
public string HashToken(string token)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(token));
return Convert.ToBase64String(bytes);
}
}

View File

@@ -0,0 +1,10 @@
namespace IncidentOps.Infrastructure.Auth;
public class JwtSettings
{
public required string Issuer { get; set; }
public required string Audience { get; set; }
public required string SigningKey { get; set; }
public int AccessTokenExpirationMinutes { get; set; } = 15;
public int RefreshTokenExpirationDays { get; set; } = 7;
}

View File

@@ -0,0 +1,24 @@
using System.Data;
using Npgsql;
namespace IncidentOps.Infrastructure.Data;
public interface IDbConnectionFactory
{
IDbConnection CreateConnection();
}
public class DbConnectionFactory : IDbConnectionFactory
{
private readonly string _connectionString;
public DbConnectionFactory(string connectionString)
{
_connectionString = connectionString;
}
public IDbConnection CreateConnection()
{
return new NpgsqlConnection(_connectionString);
}
}

View File

@@ -0,0 +1,45 @@
using IncidentOps.Domain.Entities;
namespace IncidentOps.Infrastructure.Data.Repositories;
public interface IIncidentEventRepository
{
Task<IReadOnlyList<IncidentEvent>> GetByIncidentIdAsync(Guid incidentId);
Task<IncidentEvent> CreateAsync(IncidentEvent incidentEvent);
}
public class IncidentEventRepository : IIncidentEventRepository
{
private readonly IDbConnectionFactory _connectionFactory;
public IncidentEventRepository(IDbConnectionFactory connectionFactory)
{
_connectionFactory = connectionFactory;
}
public async Task<IReadOnlyList<IncidentEvent>> GetByIncidentIdAsync(Guid incidentId)
{
using var connection = _connectionFactory.CreateConnection();
const string sql = "SELECT * FROM incident_events WHERE incident_id = @IncidentId ORDER BY created_at ASC";
var result = await Dapper.SqlMapper.QueryAsync<IncidentEvent>(connection, sql, new { IncidentId = incidentId });
return result.ToList();
}
public async Task<IncidentEvent> CreateAsync(IncidentEvent incidentEvent)
{
using var connection = _connectionFactory.CreateConnection();
const string sql = @"
INSERT INTO incident_events (id, incident_id, event_type, actor_user_id, payload, created_at)
VALUES (@Id, @IncidentId, @EventType, @ActorUserId, @Payload, @CreatedAt)
RETURNING *";
return await Dapper.SqlMapper.QuerySingleAsync<IncidentEvent>(connection, sql, new
{
incidentEvent.Id,
incidentEvent.IncidentId,
EventType = incidentEvent.EventType.ToString().ToLowerInvariant(),
incidentEvent.ActorUserId,
incidentEvent.Payload,
incidentEvent.CreatedAt
});
}
}

View File

@@ -0,0 +1,97 @@
using IncidentOps.Domain.Entities;
using IncidentOps.Domain.Enums;
namespace IncidentOps.Infrastructure.Data.Repositories;
public interface IIncidentRepository
{
Task<Incident?> GetByIdAsync(Guid id, Guid orgId);
Task<IReadOnlyList<Incident>> GetByOrgIdAsync(Guid orgId, IncidentStatus? status, int limit, string? cursor);
Task<Incident> CreateAsync(Incident incident);
Task<bool> TransitionAsync(Guid id, Guid orgId, int expectedVersion, IncidentStatus newStatus, DateTime timestamp);
}
public class IncidentRepository : IIncidentRepository
{
private readonly IDbConnectionFactory _connectionFactory;
public IncidentRepository(IDbConnectionFactory connectionFactory)
{
_connectionFactory = connectionFactory;
}
public async Task<Incident?> GetByIdAsync(Guid id, Guid orgId)
{
using var connection = _connectionFactory.CreateConnection();
const string sql = "SELECT * FROM incidents WHERE id = @Id AND org_id = @OrgId";
return await Dapper.SqlMapper.QuerySingleOrDefaultAsync<Incident>(connection, sql, new { Id = id, OrgId = orgId });
}
public async Task<IReadOnlyList<Incident>> GetByOrgIdAsync(Guid orgId, IncidentStatus? status, int limit, string? cursor)
{
using var connection = _connectionFactory.CreateConnection();
var sql = "SELECT * FROM incidents WHERE org_id = @OrgId";
if (status.HasValue)
sql += " AND status = @Status";
if (!string.IsNullOrEmpty(cursor))
sql += " AND created_at < @Cursor";
sql += " ORDER BY created_at DESC LIMIT @Limit";
var result = await Dapper.SqlMapper.QueryAsync<Incident>(connection, sql, new
{
OrgId = orgId,
Status = status?.ToString().ToLowerInvariant(),
Cursor = cursor != null ? DateTime.Parse(cursor) : (DateTime?)null,
Limit = limit
});
return result.ToList();
}
public async Task<Incident> CreateAsync(Incident incident)
{
using var connection = _connectionFactory.CreateConnection();
const string sql = @"
INSERT INTO incidents (id, org_id, service_id, title, description, status, version, created_at)
VALUES (@Id, @OrgId, @ServiceId, @Title, @Description, @Status, @Version, @CreatedAt)
RETURNING *";
return await Dapper.SqlMapper.QuerySingleAsync<Incident>(connection, sql, new
{
incident.Id,
incident.OrgId,
incident.ServiceId,
incident.Title,
incident.Description,
Status = incident.Status.ToString().ToLowerInvariant(),
incident.Version,
incident.CreatedAt
});
}
public async Task<bool> TransitionAsync(Guid id, Guid orgId, int expectedVersion, IncidentStatus newStatus, DateTime timestamp)
{
using var connection = _connectionFactory.CreateConnection();
var timestampColumn = newStatus switch
{
IncidentStatus.Acknowledged => "acknowledged_at",
IncidentStatus.Mitigated => "mitigated_at",
IncidentStatus.Resolved => "resolved_at",
_ => null
};
var sql = $@"
UPDATE incidents
SET status = @Status, version = version + 1{(timestampColumn != null ? $", {timestampColumn} = @Timestamp" : "")}
WHERE id = @Id AND org_id = @OrgId AND version = @ExpectedVersion";
var affected = await Dapper.SqlMapper.ExecuteAsync(connection, sql, new
{
Id = id,
OrgId = orgId,
Status = newStatus.ToString().ToLowerInvariant(),
ExpectedVersion = expectedVersion,
Timestamp = timestamp
});
return affected > 0;
}
}

View File

@@ -0,0 +1,55 @@
using IncidentOps.Domain.Entities;
namespace IncidentOps.Infrastructure.Data.Repositories;
public interface INotificationTargetRepository
{
Task<IReadOnlyList<NotificationTarget>> GetByOrgIdAsync(Guid orgId);
Task<IReadOnlyList<NotificationTarget>> GetEnabledByOrgIdAsync(Guid orgId);
Task<NotificationTarget> CreateAsync(NotificationTarget target);
}
public class NotificationTargetRepository : INotificationTargetRepository
{
private readonly IDbConnectionFactory _connectionFactory;
public NotificationTargetRepository(IDbConnectionFactory connectionFactory)
{
_connectionFactory = connectionFactory;
}
public async Task<IReadOnlyList<NotificationTarget>> GetByOrgIdAsync(Guid orgId)
{
using var connection = _connectionFactory.CreateConnection();
const string sql = "SELECT * FROM notification_targets WHERE org_id = @OrgId ORDER BY name";
var result = await Dapper.SqlMapper.QueryAsync<NotificationTarget>(connection, sql, new { OrgId = orgId });
return result.ToList();
}
public async Task<IReadOnlyList<NotificationTarget>> GetEnabledByOrgIdAsync(Guid orgId)
{
using var connection = _connectionFactory.CreateConnection();
const string sql = "SELECT * FROM notification_targets WHERE org_id = @OrgId AND is_enabled = true ORDER BY name";
var result = await Dapper.SqlMapper.QueryAsync<NotificationTarget>(connection, sql, new { OrgId = orgId });
return result.ToList();
}
public async Task<NotificationTarget> CreateAsync(NotificationTarget target)
{
using var connection = _connectionFactory.CreateConnection();
const string sql = @"
INSERT INTO notification_targets (id, org_id, name, target_type, configuration, is_enabled, created_at)
VALUES (@Id, @OrgId, @Name, @TargetType, @Configuration, @IsEnabled, @CreatedAt)
RETURNING *";
return await Dapper.SqlMapper.QuerySingleAsync<NotificationTarget>(connection, sql, new
{
target.Id,
target.OrgId,
target.Name,
TargetType = target.TargetType.ToString().ToLowerInvariant(),
target.Configuration,
target.IsEnabled,
target.CreatedAt
});
}
}

View File

@@ -0,0 +1,46 @@
using IncidentOps.Domain.Entities;
using IncidentOps.Domain.Enums;
namespace IncidentOps.Infrastructure.Data.Repositories;
public interface IOrgMemberRepository
{
Task<OrgMember?> GetByUserAndOrgAsync(Guid userId, Guid orgId);
Task<IReadOnlyList<OrgMember>> GetByOrgIdAsync(Guid orgId);
Task<OrgMember> CreateAsync(OrgMember member);
}
public class OrgMemberRepository : IOrgMemberRepository
{
private readonly IDbConnectionFactory _connectionFactory;
public OrgMemberRepository(IDbConnectionFactory connectionFactory)
{
_connectionFactory = connectionFactory;
}
public async Task<OrgMember?> GetByUserAndOrgAsync(Guid userId, Guid orgId)
{
using var connection = _connectionFactory.CreateConnection();
const string sql = "SELECT * FROM org_members WHERE user_id = @UserId AND org_id = @OrgId";
return await Dapper.SqlMapper.QuerySingleOrDefaultAsync<OrgMember>(connection, sql, new { UserId = userId, OrgId = orgId });
}
public async Task<IReadOnlyList<OrgMember>> GetByOrgIdAsync(Guid orgId)
{
using var connection = _connectionFactory.CreateConnection();
const string sql = "SELECT * FROM org_members WHERE org_id = @OrgId";
var result = await Dapper.SqlMapper.QueryAsync<OrgMember>(connection, sql, new { OrgId = orgId });
return result.ToList();
}
public async Task<OrgMember> CreateAsync(OrgMember member)
{
using var connection = _connectionFactory.CreateConnection();
const string sql = @"
INSERT INTO org_members (id, org_id, user_id, role, created_at)
VALUES (@Id, @OrgId, @UserId, @Role, @CreatedAt)
RETURNING *";
return await Dapper.SqlMapper.QuerySingleAsync<OrgMember>(connection, sql, member);
}
}

View File

@@ -0,0 +1,47 @@
namespace IncidentOps.Infrastructure.Data.Repositories;
public interface IOrgRepository
{
Task<Domain.Entities.Org?> GetByIdAsync(Guid id);
Task<Domain.Entities.Org> CreateAsync(Domain.Entities.Org org);
Task<IReadOnlyList<Domain.Entities.Org>> GetByUserIdAsync(Guid userId);
}
public class OrgRepository : IOrgRepository
{
private readonly IDbConnectionFactory _connectionFactory;
public OrgRepository(IDbConnectionFactory connectionFactory)
{
_connectionFactory = connectionFactory;
}
public async Task<Domain.Entities.Org?> GetByIdAsync(Guid id)
{
using var connection = _connectionFactory.CreateConnection();
const string sql = "SELECT * FROM orgs WHERE id = @Id";
return await Dapper.SqlMapper.QuerySingleOrDefaultAsync<Domain.Entities.Org>(connection, sql, new { Id = id });
}
public async Task<Domain.Entities.Org> CreateAsync(Domain.Entities.Org org)
{
using var connection = _connectionFactory.CreateConnection();
const string sql = @"
INSERT INTO orgs (id, name, slug, created_at)
VALUES (@Id, @Name, @Slug, @CreatedAt)
RETURNING *";
return await Dapper.SqlMapper.QuerySingleAsync<Domain.Entities.Org>(connection, sql, org);
}
public async Task<IReadOnlyList<Domain.Entities.Org>> GetByUserIdAsync(Guid userId)
{
using var connection = _connectionFactory.CreateConnection();
const string sql = @"
SELECT o.* FROM orgs o
INNER JOIN org_members om ON o.id = om.org_id
WHERE om.user_id = @UserId
ORDER BY o.name";
var result = await Dapper.SqlMapper.QueryAsync<Domain.Entities.Org>(connection, sql, new { UserId = userId });
return result.ToList();
}
}

View File

@@ -0,0 +1,55 @@
using IncidentOps.Domain.Entities;
namespace IncidentOps.Infrastructure.Data.Repositories;
public interface IRefreshTokenRepository
{
Task<RefreshToken?> GetByHashAsync(string tokenHash);
Task<RefreshToken> CreateAsync(RefreshToken token);
Task RevokeAsync(Guid id);
Task UpdateActiveOrgAsync(Guid id, Guid activeOrgId, string newTokenHash, DateTime newExpiresAt);
}
public class RefreshTokenRepository : IRefreshTokenRepository
{
private readonly IDbConnectionFactory _connectionFactory;
public RefreshTokenRepository(IDbConnectionFactory connectionFactory)
{
_connectionFactory = connectionFactory;
}
public async Task<RefreshToken?> GetByHashAsync(string tokenHash)
{
using var connection = _connectionFactory.CreateConnection();
const string sql = "SELECT * FROM refresh_tokens WHERE token_hash = @TokenHash AND revoked_at IS NULL AND expires_at > NOW()";
return await Dapper.SqlMapper.QuerySingleOrDefaultAsync<RefreshToken>(connection, sql, new { TokenHash = tokenHash });
}
public async Task<RefreshToken> CreateAsync(RefreshToken token)
{
using var connection = _connectionFactory.CreateConnection();
const string sql = @"
INSERT INTO refresh_tokens (id, user_id, token_hash, active_org_id, expires_at, created_at)
VALUES (@Id, @UserId, @TokenHash, @ActiveOrgId, @ExpiresAt, @CreatedAt)
RETURNING *";
return await Dapper.SqlMapper.QuerySingleAsync<RefreshToken>(connection, sql, token);
}
public async Task RevokeAsync(Guid id)
{
using var connection = _connectionFactory.CreateConnection();
const string sql = "UPDATE refresh_tokens SET revoked_at = NOW() WHERE id = @Id";
await Dapper.SqlMapper.ExecuteAsync(connection, sql, new { Id = id });
}
public async Task UpdateActiveOrgAsync(Guid id, Guid activeOrgId, string newTokenHash, DateTime newExpiresAt)
{
using var connection = _connectionFactory.CreateConnection();
const string sql = @"
UPDATE refresh_tokens
SET active_org_id = @ActiveOrgId, token_hash = @NewTokenHash, expires_at = @NewExpiresAt
WHERE id = @Id";
await Dapper.SqlMapper.ExecuteAsync(connection, sql, new { Id = id, ActiveOrgId = activeOrgId, NewTokenHash = newTokenHash, NewExpiresAt = newExpiresAt });
}
}

View File

@@ -0,0 +1,45 @@
using IncidentOps.Domain.Entities;
namespace IncidentOps.Infrastructure.Data.Repositories;
public interface IServiceRepository
{
Task<Service?> GetByIdAsync(Guid id, Guid orgId);
Task<IReadOnlyList<Service>> GetByOrgIdAsync(Guid orgId);
Task<Service> CreateAsync(Service service);
}
public class ServiceRepository : IServiceRepository
{
private readonly IDbConnectionFactory _connectionFactory;
public ServiceRepository(IDbConnectionFactory connectionFactory)
{
_connectionFactory = connectionFactory;
}
public async Task<Service?> GetByIdAsync(Guid id, Guid orgId)
{
using var connection = _connectionFactory.CreateConnection();
const string sql = "SELECT * FROM services WHERE id = @Id AND org_id = @OrgId";
return await Dapper.SqlMapper.QuerySingleOrDefaultAsync<Service>(connection, sql, new { Id = id, OrgId = orgId });
}
public async Task<IReadOnlyList<Service>> GetByOrgIdAsync(Guid orgId)
{
using var connection = _connectionFactory.CreateConnection();
const string sql = "SELECT * FROM services WHERE org_id = @OrgId ORDER BY name";
var result = await Dapper.SqlMapper.QueryAsync<Service>(connection, sql, new { OrgId = orgId });
return result.ToList();
}
public async Task<Service> CreateAsync(Service service)
{
using var connection = _connectionFactory.CreateConnection();
const string sql = @"
INSERT INTO services (id, org_id, name, slug, description, created_at)
VALUES (@Id, @OrgId, @Name, @Slug, @Description, @CreatedAt)
RETURNING *";
return await Dapper.SqlMapper.QuerySingleAsync<Service>(connection, sql, service);
}
}

View File

@@ -0,0 +1,44 @@
using IncidentOps.Domain.Entities;
namespace IncidentOps.Infrastructure.Data.Repositories;
public interface IUserRepository
{
Task<User?> GetByIdAsync(Guid id);
Task<User?> GetByEmailAsync(string email);
Task<User> CreateAsync(User user);
}
public class UserRepository : IUserRepository
{
private readonly IDbConnectionFactory _connectionFactory;
public UserRepository(IDbConnectionFactory connectionFactory)
{
_connectionFactory = connectionFactory;
}
public async Task<User?> GetByIdAsync(Guid id)
{
using var connection = _connectionFactory.CreateConnection();
const string sql = "SELECT * FROM users WHERE id = @Id";
return await Dapper.SqlMapper.QuerySingleOrDefaultAsync<User>(connection, sql, new { Id = id });
}
public async Task<User?> GetByEmailAsync(string email)
{
using var connection = _connectionFactory.CreateConnection();
const string sql = "SELECT * FROM users WHERE email = @Email";
return await Dapper.SqlMapper.QuerySingleOrDefaultAsync<User>(connection, sql, new { Email = email.ToLowerInvariant() });
}
public async Task<User> CreateAsync(User user)
{
using var connection = _connectionFactory.CreateConnection();
const string sql = @"
INSERT INTO users (id, email, password_hash, display_name, created_at)
VALUES (@Id, @Email, @PasswordHash, @DisplayName, @CreatedAt)
RETURNING *";
return await Dapper.SqlMapper.QuerySingleAsync<User>(connection, sql, user);
}
}

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\IncidentOps.Domain\IncidentOps.Domain.csproj" />
<ProjectReference Include="..\IncidentOps.Contracts\IncidentOps.Contracts.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="FluentMigrator" Version="7.2.0" />
<PackageReference Include="FluentMigrator.Runner" Version="7.2.0" />
<PackageReference Include="FluentMigrator.Runner.Postgres" Version="7.2.0" />
<PackageReference Include="Hangfire.Core" Version="1.8.22" />
<PackageReference Include="Hangfire.Redis.StackExchange" Version="1.12.0" />
<PackageReference Include="Npgsql" Version="10.0.1" />
<PackageReference Include="StackExchange.Redis" Version="2.10.1" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>