Introduce initial implementation of the Aegis backend platform. Includes APIs, data access, cryptography utilities, user resolution middleware, and initial SQLite migrations.

This commit is contained in:
Márcio Eric 2026-02-02 16:36:48 -03:00
commit d478178bde
37 changed files with 1130 additions and 0 deletions

25
.dockerignore Normal file
View File

@ -0,0 +1,25 @@
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/.idea
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
bin/
obj/
/packages/
riderModule.iml
/_ReSharper.Caches/

View File

@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<InvariantGlobalization>true</InvariantGlobalization>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.2"/>
</ItemGroup>
<ItemGroup>
<Content Include="..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Aegis.Domain\Aegis.Domain.csproj" />
<ProjectReference Include="..\Aegis.Infrastructure\Aegis.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,16 @@
using System.Security.Claims;
using Aegis.Domain;
namespace Aegis.API.Auth;
public sealed class CurrentUserAccessor
{
public UserId GetUserId(ClaimsPrincipal user)
{
var userId = user.FindFirstValue("aegis_uid");
if (string.IsNullOrWhiteSpace(userId))
throw new InvalidOperationException("Missing claim: aegis_uid (middleware not executed?)");
return new UserId(userId);
}
}

View File

@ -0,0 +1,54 @@
using System.Security.Claims;
using Aegis.Application.Abstractions;
using Microsoft.Extensions.Caching.Memory;
namespace Aegis.API.Auth;
public class UserResolutionMiddleware(RequestDelegate next, IMemoryCache cache)
{
// TTL do cache (ajuste como quiser)
private static readonly TimeSpan CacheTtl = TimeSpan.FromHours(1);
public async Task InvokeAsync(HttpContext ctx, IUserIdentityRepository repo, CancellationToken ct)
{
// Só roda para requests autenticadas
if (ctx.User?.Identity?.IsAuthenticated == true)
{
var sub = ctx.User.FindFirstValue("sub");
var iss = ctx.User.FindFirstValue("iss");
if (!string.IsNullOrWhiteSpace(sub) && !string.IsNullOrWhiteSpace(iss))
{
var cacheKey = $"aegis_uid|{iss}|{sub}";
if (!cache.TryGetValue(cacheKey, out string? userIdValue))
{
var displayName = ctx.User.FindFirstValue("name")
?? ctx.User.FindFirstValue("preferred_username");
var email = ctx.User.FindFirstValue("email");
var userId = await repo.GetOrCreateAsync(sub, iss, displayName, email, ct);
userIdValue = userId.Value;
cache.Set(
cacheKey,
userIdValue,
new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = CacheTtl,
SlidingExpiration = TimeSpan.FromMinutes(15)
});
}
// Injeta claim aegis_uid se não existir
if (ctx.User.FindFirst("aegis_uid") is null && userIdValue is not null)
{
if (ctx.User.Identity is ClaimsIdentity identity)
identity.AddClaim(new Claim("aegis_uid", userIdValue));
}
}
}
await next(ctx);
}
}

View File

@ -0,0 +1,26 @@
using Aegis.API.Auth;
using Aegis.API.Dtos;
using Aegis.Application.DataStores.ListDataStores;
using Microsoft.AspNetCore.Mvc;
namespace Aegis.API.Controllers;
public class DataStoreController(CurrentUserAccessor currentUser, ListDataStoresUseCase listDataStoresUseCase)
: ControllerBase
{
[HttpGet]
public async Task<ActionResult<IReadOnlyList<DataStoreDto>>> List(CancellationToken ct)
{
var userId = currentUser.GetUserId(User);
var summaries = await listDataStoresUseCase.ExecuteAsync(userId, ct);
var dtos = summaries.Select(s => new DataStoreDto(
s.DataStoreId.Value,
s.Name,
s.Role.ToString().ToUpperInvariant(),
s.Locked
)).ToList();
return Ok(dtos);
}
}

23
Aegis.API/Dockerfile Normal file
View File

@ -0,0 +1,23 @@
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["Aegis/Aegis.csproj", "Aegis/"]
RUN dotnet restore "Aegis/Aegis.csproj"
COPY . .
WORKDIR "/src/Aegis"
RUN dotnet build "./Aegis.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./Aegis.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "Aegis.dll"]

View File

@ -0,0 +1,8 @@
namespace Aegis.API.Dtos;
public sealed record DataStoreDto(
string DatastoreId,
string Name,
string Role,
bool Locked
);

85
Aegis.API/Program.cs Normal file
View File

@ -0,0 +1,85 @@
using Aegis.API.Auth;
using Aegis.Application.Abstractions;
using Aegis.Application.DataStores.ListDataStores;
using Aegis.Repository.Caching;
using Aegis.Repository.Crypto;
using Aegis.Repository.SQLite;
using Aegis.Repository.SQLite.Repositories;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddOpenApi();
builder.Services.AddMemoryCache();
var authority = builder.Configuration["Auth:Authority"]
?? throw new InvalidOperationException("Missing config: Auth:Authority");
var audience = builder.Configuration["Auth:Audience"]
?? throw new InvalidOperationException("Missing config: Auth:Audience");
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o =>
{
o.Authority = authority;
o.Audience = audience;
o.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
NameClaimType = "name",
RoleClaimType = "roles"
};
});
builder.Services.AddAuthorization();
var cs = builder.Configuration.GetConnectionString("AegisManifest")
?? "Data Source=aegis_manifest.db;Cache=Shared;";
builder.Services.AddSingleton(new SqliteConnectionFactory(cs));
// Resolve (iss, sub) -> user_id (1x por TTL)
builder.Services.AddScoped<IUserIdentityRepository, UserIdentityRepository>();
// ListDatastores
builder.Services.AddScoped<IDataStoreReadRepository, DataStoreReadRepository>();
// Unlocked cache (status)
builder.Services.AddSingleton<MemoryUnlockedDatastoreCache>();
builder.Services.AddSingleton<IUnlockedDataStoreCache>(sp => sp.GetRequiredService<MemoryUnlockedDatastoreCache>());
// Label key / crypto (LK)
var serverSecretB64 = builder.Configuration["Aegis:LabelKey:ServerSecretB64"]
?? throw new InvalidOperationException("Missing config: Aegis:LabelKey:ServerSecretB64");
var serverSecret = Convert.FromBase64String(serverSecretB64);
builder.Services.AddSingleton<ILabelKeyProvider>(new ServerSecretLabelKeyProvider(serverSecret));
builder.Services.AddSingleton<ILabelCrypto, AesGcmLabelCrypto>();
// Use case
builder.Services.AddScoped<ListDataStoresUseCase>();
// Helpers API
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<CurrentUserAccessor>();
var app = builder.Build();
app.UseHttpsRedirection();
// Auth pipeline
app.UseAuthentication();
app.UseMiddleware<UserResolutionMiddleware>(); // injeta claim aegis_uid via cache/DB
app.UseAuthorization();
app.MapControllers();
app.Run();

View File

@ -0,0 +1,13 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"applicationUrl": "http://localhost:5164",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@ -0,0 +1,20 @@
{
"Auth": {
"Authority": "https://auth.evolucao.io/application/o/aegis/",
"Audience": "qT1jT7HbAapchwbPVCXTnVmk6iM1icC8p0FqYMvm"
},
"Aegis": {
"LabelKey": {
"ServerSecretB64": "UP3siJ9K8uESWv7P5/qRcK5/tUl/bk2FB8UkFFdaBgc="
}
},
"ConnectionStrings": {
"AegisManifest": "Data Source=aegis_manifest.db;Cache=Shared;"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@ -0,0 +1,21 @@
{
"Auth": {
"Authority": "https://auth.seudominio.com/application/o/aegis/",
"Audience": "aegis-api"
},
"Aegis": {
"LabelKey": {
"ServerSecretB64": "COLOQUE_UM_BASE64_DE_PELO_MENOS_32_BYTES"
}
},
"ConnectionStrings": {
"AegisManifest": "Data Source=aegis_manifest.db;Cache=Shared;"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

@ -0,0 +1,12 @@
using Aegis.Domain;
namespace Aegis.Application.Abstractions;
public sealed record DataStoreRow(
DataStoreId DataStoreId,
DataStoreRole Role,
byte[] NameEnc,
byte[] NameNonce,
string LkKid,
int LkVersion
);

View File

@ -0,0 +1,8 @@
using Aegis.Domain;
namespace Aegis.Application.Abstractions;
public interface IDataStoreReadRepository
{
Task<IReadOnlyList<DataStoreRow>> ListForUserAsync(UserId userId, CancellationToken ct);
}

View File

@ -0,0 +1,12 @@
namespace Aegis.Application.Abstractions;
public interface ILabelCrypto
{
string DecryptDatastoreName(
byte[] nameEnc,
byte[] nameNonce,
string lkKid,
int lkVersion,
string aad
);
}

View File

@ -0,0 +1,8 @@
using Aegis.Domain;
namespace Aegis.Application.Abstractions;
public interface IUnlockedDataStoreCache
{
bool IsUnlocked(UserId userId, DataStoreId dataStoreId);
}

View File

@ -0,0 +1,14 @@
using Aegis.Domain;
namespace Aegis.Application.Abstractions;
public interface IUserIdentityRepository
{
// “Get or create” user_id baseado em OIDC identity
Task<UserId> GetOrCreateAsync(
string subject,
string issuer,
string? displayName,
string? email,
CancellationToken ct);
}

View File

@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Aegis.Domain\Aegis.Domain.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,10 @@
using Aegis.Domain;
namespace Aegis.Application.DataStores.ListDataStores;
public sealed record DataStoreSummary(
DataStoreId DataStoreId,
string Name,
DataStoreRole Role,
bool Locked
);

View File

@ -0,0 +1,45 @@
using Aegis.Application.Abstractions;
using Aegis.Domain;
namespace Aegis.Application.DataStores.ListDataStores;
public sealed class ListDataStoresUseCase
{
private readonly IDataStoreReadRepository _repo;
private readonly ILabelCrypto _labelCrypto;
private readonly IUnlockedDataStoreCache _unlockedCache;
public ListDataStoresUseCase(
IDataStoreReadRepository repo,
ILabelCrypto labelCrypto,
IUnlockedDataStoreCache unlockedCache)
{
_repo = repo;
_labelCrypto = labelCrypto;
_unlockedCache = unlockedCache;
}
public async Task<IReadOnlyList<DataStoreSummary>> ExecuteAsync(UserId userId, CancellationToken ct)
{
var rows = await _repo.ListForUserAsync(userId, ct);
var result = new List<DataStoreSummary>(rows.Count);
foreach (var row in rows)
{
var aad = $"aegis:datastore-name:{row.DataStoreId.Value}";
var name = _labelCrypto.DecryptDatastoreName(
row.NameEnc,
row.NameNonce,
row.LkKid,
row.LkVersion,
aad
);
var unlocked = _unlockedCache.IsUnlocked(userId, row.DataStoreId);
result.Add(new DataStoreSummary(row.DataStoreId, name, row.Role, Locked: !unlocked));
}
result.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase));
return result;
}
}

View File

@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,6 @@
namespace Aegis.Domain;
public readonly record struct DataStoreId(string Value)
{
public override string ToString() => Value;
}

View File

@ -0,0 +1,9 @@
namespace Aegis.Domain;
public enum DataStoreRole
{
Owner,
Admin,
Editor,
Viewer
}

6
Aegis.Domain/UserId.cs Normal file
View File

@ -0,0 +1,6 @@
namespace Aegis.Domain;
public readonly record struct UserId(string Value)
{
public override string ToString() => Value;
}

View File

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Aegis.Repository</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Aegis.Application\Aegis.Application.csproj" />
<ProjectReference Include="..\Aegis.Domain\Aegis.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.2" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,39 @@
using System.Collections.Concurrent;
using Aegis.Application.Abstractions;
using Aegis.Domain;
namespace Aegis.Repository.Caching;
public class MemoryUnlockedDatastoreCache : IUnlockedDataStoreCache
{
// key: "userId|datastoreId"
private readonly ConcurrentDictionary<string, DateTimeOffset> _expirations = new();
public bool IsUnlocked(UserId userId, DataStoreId datastoreId)
{
var key = $"{userId.Value}|{datastoreId.Value}";
if (!_expirations.TryGetValue(key, out var exp))
return false;
if (DateTimeOffset.UtcNow >= exp)
{
_expirations.TryRemove(key, out _);
return false;
}
return true;
}
// Helper para quando você implementar unlock:
public void MarkUnlocked(UserId userId, DataStoreId datastoreId, TimeSpan ttl)
{
var key = $"{userId.Value}|{datastoreId.Value}";
_expirations[key] = DateTimeOffset.UtcNow.Add(ttl);
}
public void Lock(UserId userId, DataStoreId datastoreId)
{
var key = $"{userId.Value}|{datastoreId.Value}";
_expirations.TryRemove(key, out _);
}
}

View File

@ -0,0 +1,55 @@
using System.Security.Cryptography;
using System.Text;
using Aegis.Application.Abstractions;
namespace Aegis.Repository.Crypto;
public class AesGcmLabelCrypto : ILabelCrypto
{
private readonly ILabelKeyProvider _keys;
private const int TagSize = 16;
public AesGcmLabelCrypto(ILabelKeyProvider keys)
=> _keys = keys;
public string DecryptDatastoreName(
byte[] nameEnc,
byte[] nameNonce,
string lkKid,
int lkVersion,
string aad)
{
var key = _keys.GetLabelKey(lkKid, lkVersion);
try
{
return DecryptUtf8AesGcm(key, nameEnc, nameNonce, Encoding.UTF8.GetBytes(aad));
}
finally
{
CryptographicOperations.ZeroMemory(key);
}
}
private static string DecryptUtf8AesGcm(byte[] key, byte[] cipherWithTag, byte[] nonce, byte[] aad)
{
if (cipherWithTag.Length < TagSize)
throw new CryptographicException("Ciphertext inválido.");
var cipherLen = cipherWithTag.Length - TagSize;
var ciphertext = new byte[cipherLen];
var tag = new byte[TagSize];
Buffer.BlockCopy(cipherWithTag, 0, ciphertext, 0, cipherLen);
Buffer.BlockCopy(cipherWithTag, cipherLen, tag, 0, TagSize);
var plaintext = new byte[cipherLen];
using var aead = new AesGcm(key);
aead.Decrypt(nonce, ciphertext, tag, plaintext, aad);
var text = Encoding.UTF8.GetString(plaintext);
CryptographicOperations.ZeroMemory(plaintext);
CryptographicOperations.ZeroMemory(ciphertext);
CryptographicOperations.ZeroMemory(tag);
return text;
}
}

View File

@ -0,0 +1,7 @@
namespace Aegis.Repository.Crypto;
public interface ILabelKeyProvider
{
// Retorna a chave simétrica (32 bytes) para o LK identificado por lkKid/lkVersion
byte[] GetLabelKey(string lkKid, int lkVersion);
}

View File

@ -0,0 +1,58 @@
using System.Security.Cryptography;
using System.Text;
namespace Aegis.Repository.Crypto;
public class ServerSecretLabelKeyProvider : ILabelKeyProvider
{
private readonly byte[] _serverSecret;
public ServerSecretLabelKeyProvider(byte[] serverSecret)
{
if (serverSecret.Length < 32)
throw new ArgumentException("Server secret deve ter pelo menos 32 bytes.");
_serverSecret = serverSecret;
}
public byte[] GetLabelKey(string lkKid, int lkVersion)
{
// HKDF simples via HMACSHA256 (ok para derivação)
// info amarra ao kid+version
var info = Encoding.UTF8.GetBytes($"aegis:lk:{lkKid}:v{lkVersion}");
return HkdfSha256(_serverSecret, salt: null, info, 32);
}
private static byte[] HkdfSha256(byte[] ikm, byte[]? salt, byte[] info, int len)
{
salt ??= new byte[32]; // salt zero (ok se ikm for segredo forte)
using var hmac = new HMACSHA256(salt);
var prk = hmac.ComputeHash(ikm);
var okm = new byte[len];
byte[] t = Array.Empty<byte>();
var pos = 0;
byte counter = 1;
using var hmac2 = new HMACSHA256(prk);
while (pos < len)
{
var input = new byte[t.Length + info.Length + 1];
Buffer.BlockCopy(t, 0, input, 0, t.Length);
Buffer.BlockCopy(info, 0, input, t.Length, info.Length);
input[^1] = counter;
t = hmac2.ComputeHash(input);
var take = Math.Min(t.Length, len - pos);
Buffer.BlockCopy(t, 0, okm, pos, take);
pos += take;
counter++;
}
CryptographicOperations.ZeroMemory(prk);
CryptographicOperations.ZeroMemory(t);
return okm;
}
}

View File

@ -0,0 +1,19 @@
namespace Aegis.Repository.SQLite.Queries;
public static class DataStoreQueries
{
public const string ListForUser = """
SELECT
ds.datastore_id,
m.role,
ds.name_enc,
ds.name_nonce,
ds.lk_kid,
ds.lk_version
FROM datastore_members m
JOIN datastores ds ON ds.datastore_id = m.datastore_id
WHERE m.user_id = $userId
AND ds.deleted_at IS NULL
ORDER BY ds.created_at ASC;
""";
}

View File

@ -0,0 +1,15 @@
namespace Aegis.Repository.SQLite.Queries;
public static class UserQueries
{
// Cria user se não existir (subject+issuer é UNIQUE).
// Em seguida retorna o user_id.
public const string GetOrCreate = """
INSERT INTO users (user_id, created_at, subject, issuer, display_name, email)
VALUES ($user_id, $created_at, $sub, $iss, $display_name, $email)
ON CONFLICT(subject, issuer) DO UPDATE SET
display_name = COALESCE(excluded.display_name, users.display_name),
email = COALESCE(excluded.email, users.email)
RETURNING user_id;
""";
}

View File

@ -0,0 +1,43 @@
using Aegis.Application.Abstractions;
using Aegis.Domain;
using Aegis.Repository.SQLite.Queries;
namespace Aegis.Repository.SQLite.Repositories;
public class DataStoreReadRepository(SqliteConnectionFactory factory) : IDataStoreReadRepository
{
public async Task<IReadOnlyList<DataStoreRow>> ListForUserAsync(UserId userId, CancellationToken ct)
{
await using var conn = factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = DataStoreQueries.ListForUser;
cmd.Parameters.AddWithValue("$userId", userId.Value);
var items = new List<DataStoreRow>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
var datastoreId = new DataStoreId(reader.GetString(0));
var role = ParseRole(reader.GetString(1));
var nameEnc = (byte[])reader["name_enc"];
var nameNonce = (byte[])reader["name_nonce"];
var lkKid = reader.GetString(4);
var lkVersion = reader.GetInt32(5);
items.Add(new DataStoreRow(datastoreId, role, nameEnc, nameNonce, lkKid, lkVersion));
}
return items;
}
private static DataStoreRole ParseRole(string role)
=> role.ToUpperInvariant() switch
{
"OWNER" => DataStoreRole.Owner,
"ADMIN" => DataStoreRole.Admin,
"EDITOR" => DataStoreRole.Editor,
"VIEWER" => DataStoreRole.Viewer,
_ => throw new InvalidOperationException($"Unknown role: {role}")
};
}

View File

@ -0,0 +1,37 @@
using Aegis.Application.Abstractions;
using Aegis.Domain;
using Aegis.Repository.SQLite.Queries;
namespace Aegis.Repository.SQLite.Repositories;
public class UserIdentityRepository(SqliteConnectionFactory factory) : IUserIdentityRepository
{
public async Task<UserId> GetOrCreateAsync(
string subject,
string issuer,
string? displayName,
string? email,
CancellationToken ct)
{
await using var conn = factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = UserQueries.GetOrCreate;
cmd.Parameters.AddWithValue("$user_id", NewId());
cmd.Parameters.AddWithValue("$created_at", DateTimeOffset.UtcNow.ToUnixTimeSeconds());
cmd.Parameters.AddWithValue("$sub", subject);
cmd.Parameters.AddWithValue("$iss", issuer);
cmd.Parameters.AddWithValue("$display_name", (object?)displayName ?? DBNull.Value);
cmd.Parameters.AddWithValue("$email", (object?)email ?? DBNull.Value);
var result = await cmd.ExecuteScalarAsync(ct);
if (result is null)
throw new InvalidOperationException("Failed to resolve user_id.");
return new UserId((string)result);
}
private static string NewId()
=> Guid.NewGuid().ToString("N"); // pode trocar por ULID depois
}

View File

@ -0,0 +1,28 @@
using Microsoft.Data.Sqlite;
namespace Aegis.Repository.SQLite;
public sealed class SqliteConnectionFactory
{
private readonly string _connectionString;
public SqliteConnectionFactory(string connectionString)
=> _connectionString = connectionString;
public SqliteConnection Open()
{
var conn = new SqliteConnection(_connectionString);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = """
PRAGMA foreign_keys = ON;
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA temp_store = MEMORY;
""";
cmd.ExecuteNonQuery();
return conn;
}
}

View File

@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

40
Aegis.sln Normal file
View File

@ -0,0 +1,40 @@

Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aegis.API", "Aegis.API\Aegis.API.csproj", "{51B1A32B-AB64-4D34-8DEA-687895A27930}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aegis.Domain", "Aegis.Domain\Aegis.Domain.csproj", "{3A3B3084-69B1-4350-93D4-980A4D7DED38}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aegis.Infrastructure", "Aegis.Infrastructure\Aegis.Infrastructure.csproj", "{94DB7195-18D0-49C2-87C1-2BC1A8722621}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aegis.Utils", "Aegis.Utils\Aegis.Utils.csproj", "{9343A17C-D414-4327-BD46-EA10F9278B10}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aegis.Application", "Aegis.Application\Aegis.Application.csproj", "{B8A70ACC-23BA-4D69-95CF-BD300B9FCA1C}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{51B1A32B-AB64-4D34-8DEA-687895A27930}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{51B1A32B-AB64-4D34-8DEA-687895A27930}.Debug|Any CPU.Build.0 = Debug|Any CPU
{51B1A32B-AB64-4D34-8DEA-687895A27930}.Release|Any CPU.ActiveCfg = Release|Any CPU
{51B1A32B-AB64-4D34-8DEA-687895A27930}.Release|Any CPU.Build.0 = Release|Any CPU
{3A3B3084-69B1-4350-93D4-980A4D7DED38}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3A3B3084-69B1-4350-93D4-980A4D7DED38}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3A3B3084-69B1-4350-93D4-980A4D7DED38}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3A3B3084-69B1-4350-93D4-980A4D7DED38}.Release|Any CPU.Build.0 = Release|Any CPU
{94DB7195-18D0-49C2-87C1-2BC1A8722621}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{94DB7195-18D0-49C2-87C1-2BC1A8722621}.Debug|Any CPU.Build.0 = Debug|Any CPU
{94DB7195-18D0-49C2-87C1-2BC1A8722621}.Release|Any CPU.ActiveCfg = Release|Any CPU
{94DB7195-18D0-49C2-87C1-2BC1A8722621}.Release|Any CPU.Build.0 = Release|Any CPU
{9343A17C-D414-4327-BD46-EA10F9278B10}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9343A17C-D414-4327-BD46-EA10F9278B10}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9343A17C-D414-4327-BD46-EA10F9278B10}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9343A17C-D414-4327-BD46-EA10F9278B10}.Release|Any CPU.Build.0 = Release|Any CPU
{B8A70ACC-23BA-4D69-95CF-BD300B9FCA1C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B8A70ACC-23BA-4D69-95CF-BD300B9FCA1C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B8A70ACC-23BA-4D69-95CF-BD300B9FCA1C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B8A70ACC-23BA-4D69-95CF-BD300B9FCA1C}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@ -0,0 +1,286 @@
-- ============================================================
-- AEGIS MANIFEST (SQLite) - UPDATED: datastore label visible, inner requires PIN
-- ============================================================
-- ----------------------------
-- PRAGMAS (aplicar na abertura da conexão)
-- ----------------------------
PRAGMA foreign_keys = ON;
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA temp_store = MEMORY;
-- ============================================================
-- USERS (OIDC identities)
-- ============================================================
CREATE TABLE IF NOT EXISTS users (
user_id TEXT PRIMARY KEY, -- GUID/ULID (string)
created_at INTEGER NOT NULL, -- epoch seconds
disabled_at INTEGER, -- null = active
-- OIDC identity (stable pairing)
subject TEXT NOT NULL, -- "sub"
issuer TEXT NOT NULL, -- "iss"
display_name TEXT,
email TEXT,
UNIQUE(subject, issuer)
);
-- ============================================================
-- DATASTORES
-- ============================================================
-- IMPORTANT:
-- - datastore name is decryptable WITHOUT PIN (using Label Key mechanism)
-- - datastore inner content requires PIN (Master Key unwrapped with PIN-derived KEK)
CREATE TABLE IF NOT EXISTS datastores (
datastore_id TEXT PRIMARY KEY, -- GUID/ULID
created_at INTEGER NOT NULL,
deleted_at INTEGER,
-- --------------------------
-- LABEL (visible without PIN)
-- --------------------------
-- name_enc is encrypted with a "Label Key" (LK) that does NOT require datastore PIN.
-- LK can be derived from a server secret, KMS, or any non-PIN mechanism.
name_enc BLOB NOT NULL,
name_nonce BLOB NOT NULL,
-- Identifies which LK mechanism/version was used (for rotation/migration).
-- Examples: "lk:server:v1", "lk:kms:key/123", "lk:yubikey:piv:9c"
lk_kid TEXT NOT NULL,
lk_version INTEGER NOT NULL DEFAULT 1,
-- --------------------------
-- MASTER KEY (PIN protected)
-- --------------------------
-- mk_wrapped is the datastore Master Key (MK) encrypted ("wrapped") with a KEK derived from the datastore PIN.
mk_wrapped BLOB NOT NULL,
mk_nonce BLOB NOT NULL,
mk_version INTEGER NOT NULL DEFAULT 1,
-- PIN KDF parameters (so you can change KDF settings per datastore over time)
pin_kdf TEXT NOT NULL DEFAULT 'argon2id', -- 'argon2id' recommended
pin_salt BLOB NOT NULL,
-- Store params as JSON text (portable and easy to evolve)
-- Example: {"memory_kib":65536,"iterations":3,"parallelism":1,"output_len":32}
pin_kdf_params TEXT NOT NULL,
-- Optional: quick verifier to reduce unwrap attempts (can be null).
-- If used, store an AEAD-encrypted constant (e.g., "AEGIS_PIN_OK") with KEK_pin and a nonce.
-- Otherwise you simply "try unwrap MK" and treat failure as "wrong PIN".
pin_verifier_enc BLOB,
pin_verifier_nonce BLOB,
-- Root node of the datastore tree
root_node_id TEXT NOT NULL,
FOREIGN KEY(root_node_id) REFERENCES nodes(node_id)
);
-- ============================================================
-- DATASTORE MEMBERS (authorization)
-- ============================================================
CREATE TABLE IF NOT EXISTS datastore_members (
datastore_id TEXT NOT NULL,
user_id TEXT NOT NULL,
role TEXT NOT NULL, -- 'OWNER'|'ADMIN'|'EDITOR'|'VIEWER'
created_at INTEGER NOT NULL,
PRIMARY KEY(datastore_id, user_id),
FOREIGN KEY(datastore_id) REFERENCES datastores(datastore_id),
FOREIGN KEY(user_id) REFERENCES users(user_id)
);
-- ============================================================
-- NODES (logical tree) - INTERNAL: requires PIN
-- ============================================================
CREATE TABLE IF NOT EXISTS nodes (
node_id TEXT PRIMARY KEY, -- GUID/ULID
datastore_id TEXT NOT NULL,
parent_id TEXT, -- null only for root
kind INTEGER NOT NULL, -- 1=DIR, 2=FILE
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
deleted_at INTEGER, -- soft delete/trash
-- Encrypted node name (requires MK -> PIN)
name_enc BLOB NOT NULL,
name_nonce BLOB NOT NULL,
-- File-only fields (kind=FILE)
main_object_id TEXT, -- FK objects.object_id
size_plain INTEGER, -- optional (can leak info; keep or encrypt later)
mime_enc BLOB, -- optional: encrypt mime to reduce metadata leak
mime_nonce BLOB,
version INTEGER NOT NULL DEFAULT 1, -- optimistic lock
FOREIGN KEY(datastore_id) REFERENCES datastores(datastore_id),
FOREIGN KEY(parent_id) REFERENCES nodes(node_id),
FOREIGN KEY(main_object_id) REFERENCES objects(object_id)
);
-- ============================================================
-- OBJECTS (encrypted blobs) - INTERNAL: requires PIN
-- ============================================================
CREATE TABLE IF NOT EXISTS objects (
object_id TEXT PRIMARY KEY, -- GUID/ULID
datastore_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
-- AEAD algorithm info
cipher TEXT NOT NULL, -- e.g. 'AES-256-GCM'
nonce BLOB NOT NULL, -- base nonce / object nonce (format depends on your file container)
-- DEK wrapped by datastore MK (requires PIN)
dek_wrapped BLOB NOT NULL,
dek_nonce BLOB NOT NULL,
-- Reference to blob location (also encrypted to avoid path leaks)
blob_ref_enc BLOB NOT NULL,
blob_ref_nonce BLOB NOT NULL,
size_cipher INTEGER NOT NULL,
hash_plain BLOB, -- optional (SHA-256) for integrity/dedupe v2
FOREIGN KEY(datastore_id) REFERENCES datastores(datastore_id)
);
-- ============================================================
-- DERIVATIVES (thumbs/previews/posters/etc.) - INTERNAL: requires PIN
-- ============================================================
CREATE TABLE IF NOT EXISTS derivatives (
derivative_id TEXT PRIMARY KEY, -- GUID/ULID
datastore_id TEXT NOT NULL,
node_id TEXT NOT NULL, -- owning file node
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
kind TEXT NOT NULL, -- 'THUMB'|'POSTER'|'PREVIEW'|'WAVEFORM'|'SPRITE'...
profile TEXT NOT NULL, -- '256w','512w','page1','t=3s','wave:1024',...
object_id TEXT, -- object containing the derivative blob (when READY)
status INTEGER NOT NULL, -- 0=PENDING,1=READY,2=FAILED
-- store error details encrypted (may contain sensitive paths/info)
error_enc BLOB,
error_nonce BLOB,
FOREIGN KEY(datastore_id) REFERENCES datastores(datastore_id),
FOREIGN KEY(node_id) REFERENCES nodes(node_id),
FOREIGN KEY(object_id) REFERENCES objects(object_id),
UNIQUE(datastore_id, node_id, kind, profile)
);
-- ============================================================
-- JOBS (SQLite queue) - does NOT require PIN itself
-- Payload should be encrypted with MK if it contains sensitive info (recommended).
-- ============================================================
CREATE TABLE IF NOT EXISTS jobs (
job_id TEXT PRIMARY KEY, -- GUID/ULID
datastore_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
available_at INTEGER NOT NULL, -- for backoff scheduling
picked_at INTEGER,
finished_at INTEGER,
kind TEXT NOT NULL, -- 'GEN_DERIVATIVES'|'REWRAP_KEYS' etc.
payload_enc BLOB NOT NULL,
payload_nonce BLOB NOT NULL,
status INTEGER NOT NULL, -- 0=QUEUED,1=RUNNING,2=DONE,3=FAILED
attempts INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(datastore_id) REFERENCES datastores(datastore_id)
);
-- ============================================================
-- AUDIT LOG (encrypted details) - INTERNAL: may require PIN depending on what you put in details
-- ============================================================
CREATE TABLE IF NOT EXISTS audit_log (
audit_id TEXT PRIMARY KEY, -- GUID/ULID
datastore_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
actor_user_id TEXT, -- null for system/worker
action TEXT NOT NULL, -- 'UPLOAD','RENAME','MOVE','DELETE','DOWNLOAD','UNLOCK','LOCK'...
target_node_id TEXT,
details_enc BLOB NOT NULL,
details_nonce BLOB NOT NULL,
FOREIGN KEY(datastore_id) REFERENCES datastores(datastore_id),
FOREIGN KEY(actor_user_id) REFERENCES users(user_id),
FOREIGN KEY(target_node_id) REFERENCES nodes(node_id)
);
-- ============================================================
-- INDEXES (hot paths)
-- ============================================================
-- list directory: by datastore + parent, excluding deleted
CREATE INDEX IF NOT EXISTS idx_nodes_parent
ON nodes(datastore_id, parent_id, deleted_at);
CREATE INDEX IF NOT EXISTS idx_nodes_parent_kind
ON nodes(datastore_id, parent_id, kind, deleted_at);
CREATE INDEX IF NOT EXISTS idx_nodes_main_object
ON nodes(main_object_id);
CREATE INDEX IF NOT EXISTS idx_objects_datastore_created
ON objects(datastore_id, created_at);
CREATE INDEX IF NOT EXISTS idx_derivatives_node
ON derivatives(datastore_id, node_id, kind, status);
-- jobs queue
CREATE INDEX IF NOT EXISTS idx_jobs_queue
ON jobs(status, available_at);
-- members lookup
CREATE INDEX IF NOT EXISTS idx_members_user
ON datastore_members(user_id);
-- datastore listing for a user typically joins members -> datastores
CREATE INDEX IF NOT EXISTS idx_members_datastore
ON datastore_members(datastore_id);
-- audit time queries
CREATE INDEX IF NOT EXISTS idx_audit_datastore_time
ON audit_log(datastore_id, created_at);
-- ============================================================
-- TRIGGERS (touch updated_at / version)
-- NOTE: SQLite default PRAGMA recursive_triggers is OFF, so these won't recurse infinitely.
-- ============================================================
CREATE TRIGGER IF NOT EXISTS trg_nodes_touch
AFTER UPDATE ON nodes
FOR EACH ROW
BEGIN
UPDATE nodes
SET updated_at = strftime('%s','now'),
version = version + 1
WHERE node_id = NEW.node_id;
END;
CREATE TRIGGER IF NOT EXISTS trg_derivatives_touch
AFTER UPDATE ON derivatives
FOR EACH ROW
BEGIN
UPDATE derivatives
SET updated_at = strftime('%s','now')
WHERE derivative_id = NEW.derivative_id;
END;