feat(backend): add JWT authentication, RBAC and consent/audit services

Add signup/signin with backend-issued JWT (Argon2 passwords in SurrealDB), role-based authorization policies, per-user data access levels, consent register and read audit log. Protect user endpoints and record sensitive reads.
This commit is contained in:
Rafał Tyński
2026-10-04 02:11:56 +02:00
parent 24c9e582c9
commit 482a4b41f0
26 changed files with 896 additions and 0 deletions
@@ -0,0 +1,114 @@
using SurrealDb.Net;
using TarninaHub.Application.Abstractions;
using TarninaHub.Application.Dtos;
using TarninaHub.Application.Models;
namespace TarninaHub.Infrastructure.Persistence;
public sealed class UserService : IUserService
{
private const string SelectUserFields =
"type::string(id) AS id, phone, email, role, type::string(organization) AS organization";
private readonly ISurrealDbClient _client;
public UserService(ISurrealDbClient client)
{
_client = client;
}
public async Task<UserAccount?> FindByPhoneAsync(string phone, CancellationToken cancellationToken)
{
var response = await _client.RawQuery(
$"SELECT {SelectUserFields} FROM user WHERE phone = $phone LIMIT 1;",
new Dictionary<string, object?> { ["phone"] = phone },
cancellationToken);
response.EnsureAllOks();
var rows = response.GetValue<List<UserRow>>(0) ?? [];
return rows.Count > 0 ? Map(rows[0]) : null;
}
public async Task<UserAccount?> GetByIdAsync(string userId, CancellationToken cancellationToken)
{
var (table, id) = SurrealRecordId.Split(userId);
var response = await _client.RawQuery(
$"SELECT {SelectUserFields} FROM type::record($table, $id) LIMIT 1;",
new Dictionary<string, object?> { ["table"] = table, ["id"] = id },
cancellationToken);
response.EnsureAllOks();
var rows = response.GetValue<List<UserRow>>(0) ?? [];
return rows.Count > 0 ? Map(rows[0]) : null;
}
public async Task<UserAccount?> CreateAsync(
RegisterRequest request,
string role,
CancellationToken cancellationToken)
{
var emailClause = string.IsNullOrWhiteSpace(request.Email) ? string.Empty : ", email = $email";
var parameters = new Dictionary<string, object?>
{
["phone"] = request.Phone,
["password"] = request.Password,
["role"] = role,
};
if (!string.IsNullOrWhiteSpace(request.Email))
{
parameters["email"] = request.Email;
}
var response = await _client.RawQuery(
"CREATE user SET phone = $phone" + emailClause + ", " +
"password = crypto::argon2::generate($password), role = $role; " +
$"SELECT {SelectUserFields} FROM user WHERE phone = $phone LIMIT 1;",
parameters,
cancellationToken);
response.EnsureAllOks();
var rows = response.GetValue<List<UserRow>>(1) ?? [];
return rows.Count > 0 ? Map(rows[0]) : null;
}
public async Task<bool> VerifyCredentialsAsync(
string phone,
string password,
CancellationToken cancellationToken)
{
var response = await _client.RawQuery(
"SELECT crypto::argon2::compare(password, $password) AS password_ok " +
"FROM user WHERE phone = $phone LIMIT 1;",
new Dictionary<string, object?>
{
["phone"] = phone,
["password"] = password,
},
cancellationToken);
response.EnsureAllOks();
var rows = response.GetValue<List<PasswordCheckRow>>(0) ?? [];
return rows.Count > 0 && rows[0].PasswordOk;
}
private static UserAccount Map(UserRow row) => new(
row.Id,
row.Phone,
string.IsNullOrWhiteSpace(row.Email) ? null : row.Email,
row.Role,
NormalizeRecordId(row.Organization));
private static string? NormalizeRecordId(string? value) =>
string.IsNullOrWhiteSpace(value) || value is "NONE" or "NULL" ? null : value;
}