Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions backend/src/Tawny.Api/Auth/ApiTokenAuthHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Tawny.Domain;
using Tawny.Infrastructure;

namespace Tawny.Api.Auth;

public class ApiTokenAuthOptions : AuthenticationSchemeOptions
{
}

public class ApiTokenAuthHandler(
IOptionsMonitor<ApiTokenAuthOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
TawnyDbContext db)
: AuthenticationHandler<ApiTokenAuthOptions>(options, logger, encoder)
{
public const string TokenPrefix = "twny_";

protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue("Authorization", out var authHeader))
{
return AuthenticateResult.NoResult();
}

var raw = authHeader.ToString();
if (string.IsNullOrEmpty(raw) || !raw.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
return AuthenticateResult.NoResult();
}

var token = raw["Bearer ".Length..].Trim();
if (!token.StartsWith(TokenPrefix, StringComparison.Ordinal))
{
// Not one of our API tokens — let the JWT scheme handle it.
return AuthenticateResult.NoResult();
}

var hash = HashToken(token);
var record = await db.ApiTokens
.FirstOrDefaultAsync(t => t.TokenHash == hash);

if (record is null || record.RevokedAt is not null)
{
return AuthenticateResult.Fail("Unknown or revoked API token.");
}

if (record.ExpiresAt is not null && record.ExpiresAt.Value <= DateTimeOffset.UtcNow)
{
return AuthenticateResult.Fail("API token expired.");
}

record.LastUsedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync();

var identity = new ClaimsIdentity(TawnyAuthSchemes.ApiToken);
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, record.CreatedByUserId?.ToString() ?? Guid.Empty.ToString()));
identity.AddClaim(new Claim(ClaimTypes.Role, record.Role.ToString()));
identity.AddClaim(new Claim(TenantClaimExtensions.TenantIdClaim, record.TenantId.ToString()));
identity.AddClaim(new Claim("api_token_id", record.Id.ToString()));

return AuthenticateResult.Success(new AuthenticationTicket(
new ClaimsPrincipal(identity), TawnyAuthSchemes.ApiToken));
}

public static string HashToken(string token)
{
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))).ToLowerInvariant();
}

public static (string Token, string Prefix) Generate()
{
// 32 random bytes, base64url-encoded — enough entropy to resist guessing.
Span<byte> bytes = stackalloc byte[32];
RandomNumberGenerator.Fill(bytes);
var secret = Convert.ToBase64String(bytes)
.Replace("+", "-")
.Replace("/", "_")
.TrimEnd('=');
var token = $"{TokenPrefix}{secret}";
return (token, token[..12]);
}
}
1 change: 1 addition & 0 deletions backend/src/Tawny.Api/Auth/TawnyAuthSchemes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ public static class TawnyAuthSchemes
{
public const string AgentJwt = "AgentJwt";
public const string WebUser = "WebUser";
public const string ApiToken = "ApiToken";
}
101 changes: 101 additions & 0 deletions backend/src/Tawny.Api/Controllers/AgentEventStreamController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Tawny.Api.Auth;
using Tawny.Api.Services;
using Tawny.Infrastructure;

namespace Tawny.Api.Controllers;

[ApiController]
[Route("api/agents")]
[Authorize(AuthenticationSchemes = TawnyAuthSchemes.WebUser)]
public class AgentEventStreamController(
TawnyDbContext db,
AgentEventBroker broker) : ControllerBase
{
private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
};

/// <summary>
/// Server-Sent Events stream of new telemetry for a single agent.
/// The client receives one JSON-encoded event per `data:` frame and a
/// `: keep-alive` comment every 15s to keep proxies happy.
/// </summary>
[HttpGet("{id:guid}/events/stream")]
public async Task Stream(Guid id, CancellationToken ct)
{
var tenantId = User.GetTenantId();
if (!await db.Agents.AnyAsync(a => a.Id == id && a.TenantId == tenantId, ct))
{
Response.StatusCode = StatusCodes.Status404NotFound;
return;
}

Response.Headers["Content-Type"] = "text/event-stream";
Response.Headers["Cache-Control"] = "no-cache, no-transform";
Response.Headers["X-Accel-Buffering"] = "no";

await Response.WriteAsync("retry: 5000\n\n", ct);
await Response.Body.FlushAsync(ct);

using var sub = broker.Subscribe(tenantId, id, out var channel);
var reader = channel.Reader;
var keepAlive = TimeSpan.FromSeconds(15);
var lastWrite = DateTimeOffset.UtcNow;

try
{
while (!ct.IsCancellationRequested)
{
using var heartbeatCts = new CancellationTokenSource(keepAlive);
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, heartbeatCts.Token);

StreamedEvent next;
try
{
next = await reader.ReadAsync(linked.Token);
}
catch (OperationCanceledException) when (heartbeatCts.IsCancellationRequested && !ct.IsCancellationRequested)
{
await Response.WriteAsync(": keep-alive\n\n", ct);
await Response.Body.FlushAsync(ct);
lastWrite = DateTimeOffset.UtcNow;
continue;
}
catch (OperationCanceledException)
{
break;
}

var payload = JsonSerializer.Serialize(new
{
id = next.Id,
agent_id = next.AgentId,
type = WireName(next.EventType),
occurred_at = next.OccurredAt,
received_at = next.ReceivedAt,
payload = next.Payload,
}, JsonOpts);
await Response.WriteAsync($"data: {payload}\n\n", ct);
await Response.Body.FlushAsync(ct);
lastWrite = DateTimeOffset.UtcNow;
}
}
catch (OperationCanceledException) { }
}

private static string WireName(Domain.TelemetryEventType t) => t switch
{
Domain.TelemetryEventType.ProcessSnapshot => "process_snapshot",
Domain.TelemetryEventType.NetworkSnapshot => "network_snapshot",
Domain.TelemetryEventType.UserSession => "user_session",
Domain.TelemetryEventType.SystemInfo => "system_info",
Domain.TelemetryEventType.FileIntegrity => "file_integrity",
Domain.TelemetryEventType.Heartbeat => "heartbeat",
_ => t.ToString().ToLowerInvariant(),
};
}
25 changes: 23 additions & 2 deletions backend/src/Tawny.Api/Controllers/AlertRulesController.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
Expand Down Expand Up @@ -25,9 +26,8 @@ public async Task<ActionResult<IReadOnlyList<AlertRuleResponse>>> List(Cancellat
var rows = await db.AlertRules
.AsNoTracking()
.OrderBy(r => r.Name)
.Select(r => ToResponse(r))
.ToListAsync(ct);
return Ok(rows);
return Ok(rows.Select(ToResponse).ToList());
}

[HttpPost]
Expand All @@ -52,6 +52,7 @@ public async Task<ActionResult<AlertRuleResponse>> Create(CreateAlertRuleRequest
PayloadPath = Normalize(req.PayloadPath),
MatchValue = Normalize(req.MatchValue),
IsEnabled = req.IsEnabled ?? true,
MitreTechniquesJson = SerializeTechniques(req.MitreTechniques),
CreatedAt = now,
UpdatedAt = now,
};
Expand Down Expand Up @@ -163,6 +164,7 @@ public async Task<ActionResult<AlertRuleResponse>> Update(Guid id, UpdateAlertRu
rule.MatchValue = Normalize(req.MatchValue);
rule.SourceDefinition = null;
rule.IsEnabled = req.IsEnabled;
rule.MitreTechniquesJson = SerializeTechniques(req.MitreTechniques);
rule.UpdatedAt = DateTimeOffset.UtcNow;
audit.Add(User, "alert_rule.update", rule.Id.ToString(), new
{
Expand Down Expand Up @@ -213,9 +215,28 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
r.MatchValue,
r.SourceDefinition,
r.IsEnabled,
DeserializeTechniques(r.MitreTechniquesJson),
r.CreatedAt,
r.UpdatedAt);

private static IReadOnlyList<string> DeserializeTechniques(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return [];
try { return JsonSerializer.Deserialize<List<string>>(json) ?? []; }
catch { return []; }
}

private static string? SerializeTechniques(IReadOnlyList<string>? techniques)
{
if (techniques is null || techniques.Count == 0) return null;
var normalized = techniques
.Where(t => !string.IsNullOrWhiteSpace(t))
.Select(t => t.Trim().ToUpperInvariant())
.Distinct()
.ToList();
return normalized.Count == 0 ? null : JsonSerializer.Serialize(normalized);
}

private ActionResult<AlertRuleResponse>? ValidateRule(
string name,
AlertRuleOperator op,
Expand Down
96 changes: 96 additions & 0 deletions backend/src/Tawny.Api/Controllers/ApiTokensController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Tawny.Api.Auth;
using Tawny.Api.Models;
using Tawny.Api.Services;
using Tawny.Domain;
using Tawny.Domain.Entities;
using Tawny.Infrastructure;

namespace Tawny.Api.Controllers;

[ApiController]
[Route("api/api-tokens")]
[Authorize(AuthenticationSchemes = TawnyAuthSchemes.WebUser, Roles = "Admin")]
public class ApiTokensController(
TawnyDbContext db,
AuditLogger audit) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<IReadOnlyList<ApiTokenResponse>>> List(CancellationToken ct)
{
var tenantId = User.GetTenantId();
var rows = await db.ApiTokens
.AsNoTracking()
.Where(t => t.TenantId == tenantId)
.OrderByDescending(t => t.CreatedAt)
.Select(t => new ApiTokenResponse(
t.Id, t.Name, t.TokenPrefix, t.Role,
t.CreatedAt, t.ExpiresAt, t.LastUsedAt, t.RevokedAt))
.ToListAsync(ct);
return Ok(rows);
}

[HttpPost]
public async Task<ActionResult<CreatedApiTokenResponse>> Create(
[FromBody] CreateApiTokenRequest req,
CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(req.Name) || req.Name.Length > 160)
{
return Problem(statusCode: 400, title: "name is required and must be 160 characters or fewer.");
}

var (token, prefix) = ApiTokenAuthHandler.Generate();
var tenantId = User.GetTenantId();
var record = new ApiToken
{
Id = Guid.NewGuid(),
TenantId = tenantId,
Name = req.Name.Trim(),
TokenHash = ApiTokenAuthHandler.HashToken(token),
TokenPrefix = prefix,
Role = req.Role ?? UserRole.Viewer,
CreatedByUserId = TryGetUserId(),
CreatedAt = DateTimeOffset.UtcNow,
ExpiresAt = req.ExpiresAt,
};
db.ApiTokens.Add(record);
audit.Add(User, "api_token.create", record.Id.ToString(), new
{
record.Name, record.Role, record.ExpiresAt,
});
await db.SaveChangesAsync(ct);

return CreatedAtAction(nameof(List), new { id = record.Id },
new CreatedApiTokenResponse(
record.Id,
record.Name,
token,
record.TokenPrefix,
record.Role,
record.CreatedAt,
record.ExpiresAt));
}

[HttpDelete("{id:guid}")]
public async Task<IActionResult> Revoke(Guid id, CancellationToken ct)
{
var tenantId = User.GetTenantId();
var record = await db.ApiTokens.FirstOrDefaultAsync(t => t.Id == id && t.TenantId == tenantId, ct);
if (record is null) return NotFound();
if (record.RevokedAt is not null) return NoContent();

record.RevokedAt = DateTimeOffset.UtcNow;
audit.Add(User, "api_token.revoke", id.ToString());
await db.SaveChangesAsync(ct);
return NoContent();
}

private Guid? TryGetUserId()
{
var raw = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
return Guid.TryParse(raw, out var id) ? id : null;
}
}
Loading
Loading