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