274 lines
9.3 KiB
C#
274 lines
9.3 KiB
C#
using System.Security.Claims;
|
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
using TheCr3w.Shared.Crypto;
|
|
using TheCr3w.Shared.Data;
|
|
using TheCr3w.Shared.Models;
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
// Konfiguracja Bazy Danych
|
|
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
|
|
?? "Host=localhost;Port=5432;Database=thecr3w_db;Username=thecr3w_user;Password=thecr3w_pass";
|
|
|
|
builder.Services.AddDbContext<TheCr3wDbContext>(options =>
|
|
options.UseNpgsql(connectionString));
|
|
|
|
// Keycloak OIDC
|
|
var keycloakAuthority = builder.Configuration["Keycloak:Authority"] ?? "https://login.thecr3wnet.com/realms/master";
|
|
var keycloakAudience = builder.Configuration["Keycloak:Audience"] ?? "thecr3w-backend";
|
|
|
|
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
|
.AddJwtBearer(options =>
|
|
{
|
|
options.Authority = keycloakAuthority;
|
|
options.Audience = keycloakAudience;
|
|
options.RequireHttpsMetadata = !builder.Environment.IsDevelopment();
|
|
options.TokenValidationParameters = new TokenValidationParameters
|
|
{
|
|
ValidateIssuer = true,
|
|
ValidIssuer = keycloakAuthority,
|
|
ValidateAudience = true,
|
|
ValidAudience = keycloakAudience,
|
|
ValidateLifetime = true,
|
|
ClockSkew = TimeSpan.FromMinutes(1)
|
|
};
|
|
});
|
|
|
|
builder.Services.AddAuthorization(options =>
|
|
{
|
|
options.AddPolicy("Approver", policy => policy.RequireClaim(ClaimTypes.Role, "thecr3w:approver", "thecr3w:admin"));
|
|
options.AddPolicy("Deployer", policy => policy.RequireClaim(ClaimTypes.Role, "thecr3w:deployer", "thecr3w:admin"));
|
|
options.AddPolicy("AdminOnly", policy => policy.RequireClaim(ClaimTypes.Role, "thecr3w:admin"));
|
|
});
|
|
|
|
builder.Services.AddCors(options =>
|
|
{
|
|
options.AddPolicy("AllowAll", p => p.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod());
|
|
});
|
|
|
|
var app = builder.Build();
|
|
|
|
app.UseCors("AllowAll");
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
|
|
app.MapGet("/health", () => Results.Ok(new { status = "Healthy", service = "DeploymentControl.Api", timestamp = DateTimeOffset.UtcNow }));
|
|
|
|
// 1. Zgłoszenie nowego manifestu wdrożeniowego (np. z Forgejo CI/CD)
|
|
app.MapPost("/api/v1/deployments/manifests", async (DeploymentManifest manifest, TheCr3wDbContext db) =>
|
|
{
|
|
if (string.IsNullOrEmpty(manifest.SignatureEd25519))
|
|
{
|
|
return Results.BadRequest(new { error = "Manifest must contain a valid Ed25519 signature" });
|
|
}
|
|
|
|
if (!manifest.ImmutableDigest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return Results.BadRequest(new { error = "Deployments must specify an immutable digest (sha256:...), tag ':latest' is strictly forbidden." });
|
|
}
|
|
|
|
// Wyliczenie SHA256 kanonicznej reprezentacji manifestu
|
|
var canonicalSha256 = ManifestSigner.ComputeManifestSha256(manifest);
|
|
|
|
var entity = new DeploymentManifestEntity
|
|
{
|
|
ManifestId = manifest.ManifestId == Guid.Empty ? Guid.NewGuid() : manifest.ManifestId,
|
|
ProjectId = manifest.Project,
|
|
EnvironmentId = manifest.Environment.ToLowerInvariant(),
|
|
ServiceName = manifest.ServiceName,
|
|
Image = manifest.Image,
|
|
ImmutableDigest = manifest.ImmutableDigest,
|
|
ManifestSha256 = canonicalSha256,
|
|
SignatureEd25519 = manifest.SignatureEd25519,
|
|
ConfigHash = manifest.ConfigHash,
|
|
Status = manifest.Environment.Equals("production", StringComparison.OrdinalIgnoreCase) ? "pending" : "approved",
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
CreatedBy = manifest.CreatedBy
|
|
};
|
|
|
|
db.DeploymentManifests.Add(entity);
|
|
|
|
db.AuditEvents.Add(new AuditEventEntity
|
|
{
|
|
Actor = manifest.CreatedBy,
|
|
Action = "MANIFEST_REGISTERED",
|
|
ResourceType = "deployment_manifest",
|
|
ResourceId = entity.ManifestId.ToString(),
|
|
CorrelationId = Guid.NewGuid(),
|
|
DetailsJson = System.Text.Json.JsonSerializer.Serialize(new { entity.ProjectId, entity.EnvironmentId, entity.ImmutableDigest })
|
|
});
|
|
|
|
await db.SaveChangesAsync();
|
|
|
|
return Results.Created($"/api/v1/deployments/manifests/{entity.ManifestId}", new
|
|
{
|
|
manifest_id = entity.ManifestId,
|
|
manifest_sha256 = entity.ManifestSha256,
|
|
project = entity.ProjectId,
|
|
environment = entity.EnvironmentId,
|
|
status = entity.Status,
|
|
requires_approval = entity.EnvironmentId.Equals("production", StringComparison.OrdinalIgnoreCase)
|
|
});
|
|
});
|
|
|
|
// 2. Bramka zatwierdzania wdrożenia produkcyjnego powiązana z SHA256 manifestu
|
|
app.MapPost("/api/v1/deployments/approvals", async (
|
|
Guid manifest_id,
|
|
string manifest_sha256,
|
|
string decision,
|
|
string? comment,
|
|
TheCr3wDbContext db) =>
|
|
{
|
|
var manifest = await db.DeploymentManifests.FirstOrDefaultAsync(m => m.ManifestId == manifest_id);
|
|
if (manifest == null)
|
|
return Results.NotFound(new { error = "Manifest not found" });
|
|
|
|
// Weryfikacja niezmienności manifestu (ochrona przed podmianą parametrów po akceptacji)
|
|
if (!string.Equals(manifest.ManifestSha256, manifest_sha256, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return Results.Conflict(new { error = "Manifest SHA256 mismatch! The manifest has been modified and cannot be approved." });
|
|
}
|
|
|
|
var approval = new DeploymentApprovalEntity
|
|
{
|
|
ApprovalId = Guid.NewGuid(),
|
|
ManifestId = manifest.ManifestId,
|
|
ManifestSha256Verified = manifest_sha256,
|
|
Approver = "admin",
|
|
Decision = decision.ToLowerInvariant(),
|
|
Comment = comment,
|
|
DecidedAt = DateTimeOffset.UtcNow
|
|
};
|
|
|
|
db.DeploymentApprovals.Add(approval);
|
|
|
|
if (decision.Equals("approved", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
manifest.Status = "approved";
|
|
}
|
|
else
|
|
{
|
|
manifest.Status = "rejected";
|
|
}
|
|
|
|
db.AuditEvents.Add(new AuditEventEntity
|
|
{
|
|
Actor = approval.Approver,
|
|
Action = $"DEPLOYMENT_{decision.ToUpperInvariant()}",
|
|
ResourceType = "deployment_manifest",
|
|
ResourceId = manifest.ManifestId.ToString(),
|
|
CorrelationId = Guid.NewGuid()
|
|
});
|
|
|
|
await db.SaveChangesAsync();
|
|
|
|
return Results.Ok(new
|
|
{
|
|
approval_id = approval.ApprovalId,
|
|
manifest_id = manifest.ManifestId,
|
|
decision = approval.Decision,
|
|
decided_at = approval.DecidedAt
|
|
});
|
|
});
|
|
|
|
// 3. Wykonanie wdrożenia przez Deployment Agenta
|
|
app.MapPost("/api/v1/deployments/execute/{manifest_id:guid}", async (Guid manifest_id, TheCr3wDbContext db) =>
|
|
{
|
|
var manifest = await db.DeploymentManifests.FirstOrDefaultAsync(m => m.ManifestId == manifest_id);
|
|
if (manifest == null)
|
|
return Results.NotFound(new { error = "Manifest not found" });
|
|
|
|
if (manifest.EnvironmentId.Equals("production", StringComparison.OrdinalIgnoreCase) && manifest.Status != "approved")
|
|
{
|
|
return Results.StatusCode(412); // Precondition Failed: Wymagana akceptacja
|
|
}
|
|
|
|
manifest.Status = "in_progress";
|
|
|
|
var deploymentId = Guid.NewGuid();
|
|
|
|
db.AuditEvents.Add(new AuditEventEntity
|
|
{
|
|
Actor = "deploy-engine",
|
|
Action = "DEPLOYMENT_EXECUTED",
|
|
ResourceType = "deployment",
|
|
ResourceId = deploymentId.ToString(),
|
|
CorrelationId = Guid.NewGuid(),
|
|
DetailsJson = System.Text.Json.JsonSerializer.Serialize(new { manifest.ProjectId, manifest.ImmutableDigest })
|
|
});
|
|
|
|
await db.SaveChangesAsync();
|
|
|
|
return Results.Accepted($"/api/v1/deployments/{deploymentId}/status", new
|
|
{
|
|
deployment_id = deploymentId,
|
|
manifest_id = manifest.ManifestId,
|
|
status = "executing",
|
|
started_at = DateTimeOffset.UtcNow
|
|
});
|
|
});
|
|
|
|
// 4. Zarządzanie serwerami zewnętrznymi (SFTP / FTPS)
|
|
app.MapGet("/api/v1/external-deployments/servers", async (TheCr3wDbContext db) =>
|
|
{
|
|
var servers = await db.ExternalServers
|
|
.Select(s => new
|
|
{
|
|
s.ServerId,
|
|
s.Name,
|
|
s.Protocol,
|
|
s.Host,
|
|
s.Port,
|
|
s.RemoteRootPath,
|
|
s.HostKeyFingerprint,
|
|
s.Status,
|
|
s.UpdatedAt
|
|
})
|
|
.ToListAsync();
|
|
|
|
return Results.Ok(servers);
|
|
});
|
|
|
|
app.MapPost("/api/v1/external-deployments/servers", async (ExternalServerEntity server, TheCr3wDbContext db) =>
|
|
{
|
|
if (server.Protocol.Equals("ftp", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
// Ostrzeżenie i wymóg świadomej konfiguracji nieszyfrowanego FTP
|
|
app.Logger.LogWarning("Dodano serwer nieszyfrowanego FTP: {Host}. Wskazane użycie SFTP.", server.Host);
|
|
}
|
|
|
|
server.ServerId = Guid.NewGuid();
|
|
server.CreatedAt = DateTimeOffset.UtcNow;
|
|
server.UpdatedAt = DateTimeOffset.UtcNow;
|
|
|
|
db.ExternalServers.Add(server);
|
|
await db.SaveChangesAsync();
|
|
|
|
return Results.Created($"/api/v1/external-deployments/servers/{server.ServerId}", server);
|
|
});
|
|
|
|
// 5. Pobranie zdarzeń audytowych
|
|
app.MapGet("/api/v1/audit/logs", async (string? resource_type, TheCr3wDbContext db) =>
|
|
{
|
|
var query = db.AuditEvents.AsQueryable();
|
|
if (!string.IsNullOrEmpty(resource_type))
|
|
{
|
|
query = query.Where(a => a.ResourceType == resource_type);
|
|
}
|
|
|
|
var logs = await query
|
|
.OrderByDescending(a => a.CreatedAt)
|
|
.Take(100)
|
|
.ToListAsync();
|
|
|
|
return Results.Ok(logs);
|
|
});
|
|
|
|
app.Run();
|
|
|
|
namespace DeploymentControl.Api
|
|
{
|
|
public partial class Program { }
|
|
}
|