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:
commit
d478178bde
25
.dockerignore
Normal file
25
.dockerignore
Normal 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
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
/packages/
|
||||||
|
riderModule.iml
|
||||||
|
/_ReSharper.Caches/
|
||||||
27
Aegis.API/Aegis.API.csproj
Normal file
27
Aegis.API/Aegis.API.csproj
Normal 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>
|
||||||
16
Aegis.API/Auth/CurrentUserAccessor.cs
Normal file
16
Aegis.API/Auth/CurrentUserAccessor.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
Aegis.API/Auth/UserResolutionMiddleware.cs
Normal file
54
Aegis.API/Auth/UserResolutionMiddleware.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
Aegis.API/Controllers/DataStoreController.cs
Normal file
26
Aegis.API/Controllers/DataStoreController.cs
Normal 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
23
Aegis.API/Dockerfile
Normal 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"]
|
||||||
8
Aegis.API/Dtos/DataStoreDto.cs
Normal file
8
Aegis.API/Dtos/DataStoreDto.cs
Normal 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
85
Aegis.API/Program.cs
Normal 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();
|
||||||
13
Aegis.API/Properties/launchSettings.json
Normal file
13
Aegis.API/Properties/launchSettings.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
Aegis.API/appsettings.Development.json
Normal file
20
Aegis.API/appsettings.Development.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
Aegis.API/appsettings.json
Normal file
21
Aegis.API/appsettings.json
Normal 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": "*"
|
||||||
|
}
|
||||||
12
Aegis.Application/Abstractions/DataStoreRow.cs
Normal file
12
Aegis.Application/Abstractions/DataStoreRow.cs
Normal 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
|
||||||
|
);
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
using Aegis.Domain;
|
||||||
|
|
||||||
|
namespace Aegis.Application.Abstractions;
|
||||||
|
|
||||||
|
public interface IDataStoreReadRepository
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<DataStoreRow>> ListForUserAsync(UserId userId, CancellationToken ct);
|
||||||
|
}
|
||||||
12
Aegis.Application/Abstractions/ILabelCrypto.cs
Normal file
12
Aegis.Application/Abstractions/ILabelCrypto.cs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
namespace Aegis.Application.Abstractions;
|
||||||
|
|
||||||
|
public interface ILabelCrypto
|
||||||
|
{
|
||||||
|
string DecryptDatastoreName(
|
||||||
|
byte[] nameEnc,
|
||||||
|
byte[] nameNonce,
|
||||||
|
string lkKid,
|
||||||
|
int lkVersion,
|
||||||
|
string aad
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
using Aegis.Domain;
|
||||||
|
|
||||||
|
namespace Aegis.Application.Abstractions;
|
||||||
|
|
||||||
|
public interface IUnlockedDataStoreCache
|
||||||
|
{
|
||||||
|
bool IsUnlocked(UserId userId, DataStoreId dataStoreId);
|
||||||
|
}
|
||||||
14
Aegis.Application/Abstractions/IUserIdentityRepository.cs
Normal file
14
Aegis.Application/Abstractions/IUserIdentityRepository.cs
Normal 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);
|
||||||
|
}
|
||||||
13
Aegis.Application/Aegis.Application.csproj
Normal file
13
Aegis.Application/Aegis.Application.csproj
Normal 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>
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
using Aegis.Domain;
|
||||||
|
|
||||||
|
namespace Aegis.Application.DataStores.ListDataStores;
|
||||||
|
|
||||||
|
public sealed record DataStoreSummary(
|
||||||
|
DataStoreId DataStoreId,
|
||||||
|
string Name,
|
||||||
|
DataStoreRole Role,
|
||||||
|
bool Locked
|
||||||
|
);
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
Aegis.Domain/Aegis.Domain.csproj
Normal file
9
Aegis.Domain/Aegis.Domain.csproj
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
6
Aegis.Domain/DataStoreId.cs
Normal file
6
Aegis.Domain/DataStoreId.cs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
namespace Aegis.Domain;
|
||||||
|
|
||||||
|
public readonly record struct DataStoreId(string Value)
|
||||||
|
{
|
||||||
|
public override string ToString() => Value;
|
||||||
|
}
|
||||||
9
Aegis.Domain/DataStoreRole.cs
Normal file
9
Aegis.Domain/DataStoreRole.cs
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
namespace Aegis.Domain;
|
||||||
|
|
||||||
|
public enum DataStoreRole
|
||||||
|
{
|
||||||
|
Owner,
|
||||||
|
Admin,
|
||||||
|
Editor,
|
||||||
|
Viewer
|
||||||
|
}
|
||||||
6
Aegis.Domain/UserId.cs
Normal file
6
Aegis.Domain/UserId.cs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
namespace Aegis.Domain;
|
||||||
|
|
||||||
|
public readonly record struct UserId(string Value)
|
||||||
|
{
|
||||||
|
public override string ToString() => Value;
|
||||||
|
}
|
||||||
19
Aegis.Infrastructure/Aegis.Infrastructure.csproj
Normal file
19
Aegis.Infrastructure/Aegis.Infrastructure.csproj
Normal 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>
|
||||||
39
Aegis.Infrastructure/Caching/MemoryUnlockedDatastoreCache.cs
Normal file
39
Aegis.Infrastructure/Caching/MemoryUnlockedDatastoreCache.cs
Normal 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 _);
|
||||||
|
}
|
||||||
|
}
|
||||||
55
Aegis.Infrastructure/Crypto/AesGcmLabelCrypto.cs
Normal file
55
Aegis.Infrastructure/Crypto/AesGcmLabelCrypto.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
7
Aegis.Infrastructure/Crypto/ILabelKeyProvider.cs
Normal file
7
Aegis.Infrastructure/Crypto/ILabelKeyProvider.cs
Normal 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);
|
||||||
|
}
|
||||||
58
Aegis.Infrastructure/Crypto/ServerSecretLabelKeyProvider.cs
Normal file
58
Aegis.Infrastructure/Crypto/ServerSecretLabelKeyProvider.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
19
Aegis.Infrastructure/SQLite/Queries/DataStoreQueries.cs
Normal file
19
Aegis.Infrastructure/SQLite/Queries/DataStoreQueries.cs
Normal 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;
|
||||||
|
""";
|
||||||
|
}
|
||||||
15
Aegis.Infrastructure/SQLite/Queries/UserQueries.cs
Normal file
15
Aegis.Infrastructure/SQLite/Queries/UserQueries.cs
Normal 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;
|
||||||
|
""";
|
||||||
|
}
|
||||||
@ -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}")
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
28
Aegis.Infrastructure/SQLite/SqliteConnectionFactory.cs
Normal file
28
Aegis.Infrastructure/SQLite/SqliteConnectionFactory.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
Aegis.Utils/Aegis.Utils.csproj
Normal file
9
Aegis.Utils/Aegis.Utils.csproj
Normal 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
40
Aegis.sln
Normal 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
|
||||||
286
Scripts/first_migrations.sql
Normal file
286
Scripts/first_migrations.sql
Normal 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;
|
||||||
Loading…
Reference in New Issue
Block a user