feat: Implement Policy Engine Evaluation Service and Cache with unit tests
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Temp commit to debug
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
| PLG4-6.CAPABILITIES | BLOCKED (2025-10-12) | BE-Auth Plugin, Docs Guild | PLG1–PLG3 | Finalise capability metadata exposure, config validation, and developer guide updates; remaining action is Docs polish/diagram export. | ✅ Capability metadata + validation merged; ✅ Plugin guide updated with final copy & diagrams; ✅ Release notes mention new toggles. <br>⛔ Blocked awaiting Authority rate-limiter stream (CORE8/SEC3) to resume so doc updates reflect final limiter behaviour. |
|
||||
| PLG7.RFC | DONE (2025-11-03) | BE-Auth Plugin, Security Guild | PLG4 | Socialize LDAP plugin RFC (`docs/rfcs/authority-plugin-ldap.md`) and capture guild feedback. | ✅ Guild review sign-off recorded; ✅ Follow-up issues filed in module boards. |
|
||||
| PLG7.IMPL-001 | DONE (2025-11-03) | BE-Auth Plugin | PLG7.RFC | Scaffold `StellaOps.Authority.Plugin.Ldap` + tests, bind configuration (client certificate, trust-store, insecure toggle) with validation and docs samples. | ✅ Project + test harness build; ✅ Configuration bound & validated; ✅ Sample config updated. |
|
||||
| PLG7.IMPL-002 | DOING (2025-11-03) | BE-Auth Plugin, Security Guild | PLG7.IMPL-001 | Implement LDAP credential store with TLS/mutual TLS enforcement, deterministic retry/backoff, and structured logging/metrics. | ✅ Credential store passes integration tests (OpenLDAP + mtls); ✅ Metrics/logs emitted; ✅ Error mapping documented. |
|
||||
| PLG7.IMPL-002 | DONE (2025-11-04) | BE-Auth Plugin, Security Guild | PLG7.IMPL-001 | Implement LDAP credential store with TLS/mutual TLS enforcement, deterministic retry/backoff, and structured logging/metrics. | ✅ Credential store passes integration tests (OpenLDAP + mtls); ✅ Metrics/logs emitted; ✅ Error mapping documented.<br>2025-11-04: DirectoryServices factory now enforces TLS/mTLS options, credential store retries use deterministic backoff with metrics, audit logging includes failure codes, and unit suite (`dotnet test src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap.Tests`) remains green. |
|
||||
| PLG7.IMPL-003 | TODO | BE-Auth Plugin | PLG7.IMPL-001 | Deliver claims enricher with DN-to-role dictionary and regex mapping plus Mongo cache, including determinism + eviction tests. | ✅ Regex mapping deterministic; ✅ Cache TTL + invalidation tested; ✅ Claims doc updated. |
|
||||
| PLG7.IMPL-004 | TODO | BE-Auth Plugin, DevOps Guild | PLG7.IMPL-002 | Implement client provisioning store with LDAP write toggles, Mongo audit mirror, bootstrap validation, and health reporting. | ✅ Audit mirror records persisted; ✅ Bootstrap validation logs capability summary; ✅ Health checks cover LDAP + audit mirror. |
|
||||
| PLG7.IMPL-005 | TODO | BE-Auth Plugin, Docs Guild | PLG7.IMPL-001..004 | Update developer guide, samples, and release notes for LDAP plugin (mutual TLS, regex mapping, audit mirror) and ensure Offline Kit coverage. | ✅ Docs merged; ✅ Release notes drafted; ✅ Offline kit config templates updated. |
|
||||
|
||||
@@ -1,282 +1,282 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using System.Net.Http.Headers;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Tests.Infrastructure;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Configuration;
|
||||
using Xunit;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
|
||||
namespace StellaOps.Authority.Tests.AdvisoryAi;
|
||||
|
||||
public sealed class AdvisoryAiRemoteInferenceEndpointTests : IClassFixture<AuthorityWebApplicationFactory>
|
||||
{
|
||||
private readonly AuthorityWebApplicationFactory factory;
|
||||
|
||||
public AdvisoryAiRemoteInferenceEndpointTests(AuthorityWebApplicationFactory factory)
|
||||
{
|
||||
this.factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoteInference_ReturnsForbidden_WhenDisabled()
|
||||
{
|
||||
using var client = CreateClient(
|
||||
configureOptions: options =>
|
||||
{
|
||||
options.AdvisoryAi.RemoteInference.Enabled = false;
|
||||
options.AdvisoryAi.RemoteInference.AllowedProfiles.Clear();
|
||||
options.AdvisoryAi.RemoteInference.AllowedProfiles.Add("cloud-openai");
|
||||
});
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/advisory-ai/remote-inference/logs",
|
||||
CreatePayload(profile: "cloud-openai"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<Dictionary<string, string>>();
|
||||
Assert.NotNull(body);
|
||||
Assert.Equal("remote_inference_disabled", body!["error"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoteInference_ReturnsForbidden_WhenConsentMissing()
|
||||
{
|
||||
using var client = CreateClient(
|
||||
configureOptions: options =>
|
||||
{
|
||||
SeedRemoteInferenceEnabled(options);
|
||||
SeedTenantConsent(options);
|
||||
options.Tenants[0].AdvisoryAi.RemoteInference.ConsentGranted = false;
|
||||
options.Tenants[0].AdvisoryAi.RemoteInference.ConsentVersion = null;
|
||||
options.Tenants[0].AdvisoryAi.RemoteInference.ConsentedAt = null;
|
||||
options.Tenants[0].AdvisoryAi.RemoteInference.ConsentedBy = null;
|
||||
});
|
||||
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/advisory-ai/remote-inference/logs",
|
||||
CreatePayload(profile: "cloud-openai"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<Dictionary<string, string>>();
|
||||
Assert.NotNull(body);
|
||||
Assert.Equal("remote_inference_consent_required", body!["error"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoteInference_ReturnsBadRequest_WhenProfileNotAllowed()
|
||||
{
|
||||
using var client = CreateClient(
|
||||
configureOptions: options =>
|
||||
{
|
||||
SeedRemoteInferenceEnabled(options);
|
||||
SeedTenantConsent(options);
|
||||
});
|
||||
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/advisory-ai/remote-inference/logs",
|
||||
CreatePayload(profile: "other-profile"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<Dictionary<string, string>>();
|
||||
Assert.NotNull(body);
|
||||
Assert.Equal("profile_not_allowed", body!["error"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoteInference_LogsPrompt_WhenConsentGranted()
|
||||
{
|
||||
using var client = CreateClient(
|
||||
configureOptions: options =>
|
||||
{
|
||||
SeedRemoteInferenceEnabled(options);
|
||||
SeedTenantConsent(options);
|
||||
});
|
||||
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
|
||||
|
||||
var database = new MongoClient(factory.ConnectionString).GetDatabase("authority-tests");
|
||||
var collection = database.GetCollection<BsonDocument>("authority_login_attempts");
|
||||
await collection.DeleteManyAsync(FilterDefinition<BsonDocument>.Empty);
|
||||
|
||||
var payload = CreatePayload(profile: "cloud-openai", prompt: "Generate remediation plan.");
|
||||
var response = await client.PostAsJsonAsync("/advisory-ai/remote-inference/logs", payload);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<Dictionary<string, string>>();
|
||||
Assert.NotNull(body);
|
||||
Assert.Equal("logged", body!["status"]);
|
||||
|
||||
var expectedHash = ComputeSha256(payload.Prompt);
|
||||
Assert.Equal(expectedHash, body["prompt_hash"]);
|
||||
|
||||
var doc = await collection.Find(Builders<BsonDocument>.Filter.Eq("eventType", "authority.advisory_ai.remote_inference")).SingleAsync();
|
||||
Assert.Equal("authority.advisory_ai.remote_inference", doc["eventType"].AsString);
|
||||
|
||||
var properties = ExtractProperties(doc);
|
||||
Assert.Equal(expectedHash, properties["advisory_ai.prompt.hash"]);
|
||||
Assert.Equal("sha256", properties["advisory_ai.prompt.algorithm"]);
|
||||
Assert.Equal(payload.Profile, properties["advisory_ai.profile"]);
|
||||
Assert.False(properties.ContainsKey("advisory_ai.prompt.raw"));
|
||||
}
|
||||
|
||||
private HttpClient CreateClient(Action<StellaOpsAuthorityOptions>? configureOptions = null)
|
||||
{
|
||||
const string schemeName = "StellaOpsBearer";
|
||||
|
||||
var builder = factory.WithWebHostBuilder(hostBuilder =>
|
||||
{
|
||||
hostBuilder.ConfigureTestServices(services =>
|
||||
{
|
||||
services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = schemeName;
|
||||
options.DefaultChallengeScheme = schemeName;
|
||||
})
|
||||
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(schemeName, _ => { });
|
||||
|
||||
services.PostConfigure<StellaOpsAuthorityOptions>(opts =>
|
||||
{
|
||||
opts.Issuer ??= new Uri("https://authority.test");
|
||||
if (string.IsNullOrWhiteSpace(opts.Storage.ConnectionString))
|
||||
{
|
||||
opts.Storage.ConnectionString = factory.ConnectionString;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(opts.Storage.DatabaseName))
|
||||
{
|
||||
opts.Storage.DatabaseName = "authority-tests";
|
||||
}
|
||||
|
||||
opts.AdvisoryAi.RemoteInference.Enabled = true;
|
||||
opts.AdvisoryAi.RemoteInference.RequireTenantConsent = true;
|
||||
opts.AdvisoryAi.RemoteInference.AllowedProfiles.Clear();
|
||||
opts.AdvisoryAi.RemoteInference.AllowedProfiles.Add("cloud-openai");
|
||||
|
||||
opts.Tenants.Clear();
|
||||
opts.Tenants.Add(new AuthorityTenantOptions
|
||||
{
|
||||
Id = "tenant-default",
|
||||
DisplayName = "Tenant Default",
|
||||
AdvisoryAi =
|
||||
{
|
||||
RemoteInference =
|
||||
{
|
||||
ConsentGranted = true,
|
||||
ConsentVersion = "2025-10",
|
||||
ConsentedAt = DateTimeOffset.Parse("2025-10-31T12:34:56Z"),
|
||||
ConsentedBy = "legal@example.com"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
configureOptions?.Invoke(opts);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
var client = builder.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(schemeName);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static void SeedRemoteInferenceEnabled(StellaOpsAuthorityOptions options)
|
||||
{
|
||||
options.AdvisoryAi.RemoteInference.Enabled = true;
|
||||
options.AdvisoryAi.RemoteInference.RequireTenantConsent = true;
|
||||
options.AdvisoryAi.RemoteInference.AllowedProfiles.Clear();
|
||||
options.AdvisoryAi.RemoteInference.AllowedProfiles.Add("cloud-openai");
|
||||
}
|
||||
|
||||
private static void SeedTenantConsent(StellaOpsAuthorityOptions options)
|
||||
{
|
||||
if (options.Tenants.Count == 0)
|
||||
{
|
||||
options.Tenants.Add(new AuthorityTenantOptions { Id = "tenant-default", DisplayName = "Tenant Default" });
|
||||
}
|
||||
|
||||
var tenant = options.Tenants[0];
|
||||
tenant.Id = "tenant-default";
|
||||
tenant.DisplayName = "Tenant Default";
|
||||
tenant.AdvisoryAi.RemoteInference.ConsentGranted = true;
|
||||
tenant.AdvisoryAi.RemoteInference.ConsentVersion = "2025-10";
|
||||
tenant.AdvisoryAi.RemoteInference.ConsentedAt = DateTimeOffset.Parse("2025-10-31T12:34:56Z");
|
||||
tenant.AdvisoryAi.RemoteInference.ConsentedBy = "legal@example.com";
|
||||
}
|
||||
|
||||
private static string ComputeSha256(string value)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(value));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ExtractProperties(BsonDocument document)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
if (!document.TryGetValue("properties", out var propertiesValue))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
foreach (var item in propertiesValue.AsBsonArray)
|
||||
{
|
||||
if (item is not BsonDocument property)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = property.TryGetValue("name", out var nameValue) ? nameValue.AsString : null;
|
||||
var value = property.TryGetValue("value", out var valueNode) ? valueNode.AsString : null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
result[name] = value ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static RemoteInferencePayload CreatePayload(string profile, string prompt = "Summarize remedations.")
|
||||
{
|
||||
return new RemoteInferencePayload(
|
||||
TaskType: "summary",
|
||||
Profile: profile,
|
||||
ModelId: "gpt-4o-mini",
|
||||
Prompt: prompt,
|
||||
ContextDigest: "sha256:context",
|
||||
OutputHash: "sha256:output",
|
||||
TaskId: "task-123",
|
||||
Metadata: new Dictionary<string, string>
|
||||
{
|
||||
["channel"] = "cli",
|
||||
["env"] = "test"
|
||||
});
|
||||
}
|
||||
|
||||
private sealed record RemoteInferencePayload(
|
||||
[property: JsonPropertyName("taskType")] string TaskType,
|
||||
[property: JsonPropertyName("profile")] string Profile,
|
||||
[property: JsonPropertyName("modelId")] string ModelId,
|
||||
[property: JsonPropertyName("prompt")] string Prompt,
|
||||
[property: JsonPropertyName("contextDigest")] string ContextDigest,
|
||||
[property: JsonPropertyName("outputHash")] string OutputHash,
|
||||
[property: JsonPropertyName("taskId")] string TaskId,
|
||||
[property: JsonPropertyName("metadata")] IDictionary<string, string> Metadata);
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using System.Net.Http.Headers;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Tests.Infrastructure;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Configuration;
|
||||
using Xunit;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
|
||||
namespace StellaOps.Authority.Tests.AdvisoryAi;
|
||||
|
||||
public sealed class AdvisoryAiRemoteInferenceEndpointTests : IClassFixture<AuthorityWebApplicationFactory>
|
||||
{
|
||||
private readonly AuthorityWebApplicationFactory factory;
|
||||
|
||||
public AdvisoryAiRemoteInferenceEndpointTests(AuthorityWebApplicationFactory factory)
|
||||
{
|
||||
this.factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoteInference_ReturnsForbidden_WhenDisabled()
|
||||
{
|
||||
using var client = CreateClient(
|
||||
configureOptions: options =>
|
||||
{
|
||||
options.AdvisoryAi.RemoteInference.Enabled = false;
|
||||
options.AdvisoryAi.RemoteInference.AllowedProfiles.Clear();
|
||||
options.AdvisoryAi.RemoteInference.AllowedProfiles.Add("cloud-openai");
|
||||
});
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/advisory-ai/remote-inference/logs",
|
||||
CreatePayload(profile: "cloud-openai"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<Dictionary<string, string>>();
|
||||
Assert.NotNull(body);
|
||||
Assert.Equal("remote_inference_disabled", body!["error"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoteInference_ReturnsForbidden_WhenConsentMissing()
|
||||
{
|
||||
using var client = CreateClient(
|
||||
configureOptions: options =>
|
||||
{
|
||||
SeedRemoteInferenceEnabled(options);
|
||||
SeedTenantConsent(options);
|
||||
options.Tenants[0].AdvisoryAi.RemoteInference.ConsentGranted = false;
|
||||
options.Tenants[0].AdvisoryAi.RemoteInference.ConsentVersion = null;
|
||||
options.Tenants[0].AdvisoryAi.RemoteInference.ConsentedAt = null;
|
||||
options.Tenants[0].AdvisoryAi.RemoteInference.ConsentedBy = null;
|
||||
});
|
||||
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/advisory-ai/remote-inference/logs",
|
||||
CreatePayload(profile: "cloud-openai"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<Dictionary<string, string>>();
|
||||
Assert.NotNull(body);
|
||||
Assert.Equal("remote_inference_consent_required", body!["error"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoteInference_ReturnsBadRequest_WhenProfileNotAllowed()
|
||||
{
|
||||
using var client = CreateClient(
|
||||
configureOptions: options =>
|
||||
{
|
||||
SeedRemoteInferenceEnabled(options);
|
||||
SeedTenantConsent(options);
|
||||
});
|
||||
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/advisory-ai/remote-inference/logs",
|
||||
CreatePayload(profile: "other-profile"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<Dictionary<string, string>>();
|
||||
Assert.NotNull(body);
|
||||
Assert.Equal("profile_not_allowed", body!["error"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoteInference_LogsPrompt_WhenConsentGranted()
|
||||
{
|
||||
using var client = CreateClient(
|
||||
configureOptions: options =>
|
||||
{
|
||||
SeedRemoteInferenceEnabled(options);
|
||||
SeedTenantConsent(options);
|
||||
});
|
||||
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
|
||||
|
||||
var database = new MongoClient(factory.ConnectionString).GetDatabase("authority-tests");
|
||||
var collection = database.GetCollection<BsonDocument>("authority_login_attempts");
|
||||
await collection.DeleteManyAsync(FilterDefinition<BsonDocument>.Empty);
|
||||
|
||||
var payload = CreatePayload(profile: "cloud-openai", prompt: "Generate remediation plan.");
|
||||
var response = await client.PostAsJsonAsync("/advisory-ai/remote-inference/logs", payload);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<Dictionary<string, string>>();
|
||||
Assert.NotNull(body);
|
||||
Assert.Equal("logged", body!["status"]);
|
||||
|
||||
var expectedHash = ComputeSha256(payload.Prompt);
|
||||
Assert.Equal(expectedHash, body["prompt_hash"]);
|
||||
|
||||
var doc = await collection.Find(Builders<BsonDocument>.Filter.Eq("eventType", "authority.advisory_ai.remote_inference")).SingleAsync();
|
||||
Assert.Equal("authority.advisory_ai.remote_inference", doc["eventType"].AsString);
|
||||
|
||||
var properties = ExtractProperties(doc);
|
||||
Assert.Equal(expectedHash, properties["advisory_ai.prompt.hash"]);
|
||||
Assert.Equal("sha256", properties["advisory_ai.prompt.algorithm"]);
|
||||
Assert.Equal(payload.Profile, properties["advisory_ai.profile"]);
|
||||
Assert.False(properties.ContainsKey("advisory_ai.prompt.raw"));
|
||||
}
|
||||
|
||||
private HttpClient CreateClient(Action<StellaOpsAuthorityOptions>? configureOptions = null)
|
||||
{
|
||||
const string schemeName = "StellaOpsBearer";
|
||||
|
||||
var builder = factory.WithWebHostBuilder(hostBuilder =>
|
||||
{
|
||||
hostBuilder.ConfigureTestServices(services =>
|
||||
{
|
||||
services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = schemeName;
|
||||
options.DefaultChallengeScheme = schemeName;
|
||||
})
|
||||
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(schemeName, _ => { });
|
||||
|
||||
services.PostConfigure<StellaOpsAuthorityOptions>(opts =>
|
||||
{
|
||||
opts.Issuer ??= new Uri("https://authority.test");
|
||||
if (string.IsNullOrWhiteSpace(opts.Storage.ConnectionString))
|
||||
{
|
||||
opts.Storage.ConnectionString = factory.ConnectionString;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(opts.Storage.DatabaseName))
|
||||
{
|
||||
opts.Storage.DatabaseName = "authority-tests";
|
||||
}
|
||||
|
||||
opts.AdvisoryAi.RemoteInference.Enabled = true;
|
||||
opts.AdvisoryAi.RemoteInference.RequireTenantConsent = true;
|
||||
opts.AdvisoryAi.RemoteInference.AllowedProfiles.Clear();
|
||||
opts.AdvisoryAi.RemoteInference.AllowedProfiles.Add("cloud-openai");
|
||||
|
||||
opts.Tenants.Clear();
|
||||
opts.Tenants.Add(new AuthorityTenantOptions
|
||||
{
|
||||
Id = "tenant-default",
|
||||
DisplayName = "Tenant Default",
|
||||
AdvisoryAi =
|
||||
{
|
||||
RemoteInference =
|
||||
{
|
||||
ConsentGranted = true,
|
||||
ConsentVersion = "2025-10",
|
||||
ConsentedAt = DateTimeOffset.Parse("2025-10-31T12:34:56Z"),
|
||||
ConsentedBy = "legal@example.com"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
configureOptions?.Invoke(opts);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
var client = builder.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(schemeName);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static void SeedRemoteInferenceEnabled(StellaOpsAuthorityOptions options)
|
||||
{
|
||||
options.AdvisoryAi.RemoteInference.Enabled = true;
|
||||
options.AdvisoryAi.RemoteInference.RequireTenantConsent = true;
|
||||
options.AdvisoryAi.RemoteInference.AllowedProfiles.Clear();
|
||||
options.AdvisoryAi.RemoteInference.AllowedProfiles.Add("cloud-openai");
|
||||
}
|
||||
|
||||
private static void SeedTenantConsent(StellaOpsAuthorityOptions options)
|
||||
{
|
||||
if (options.Tenants.Count == 0)
|
||||
{
|
||||
options.Tenants.Add(new AuthorityTenantOptions { Id = "tenant-default", DisplayName = "Tenant Default" });
|
||||
}
|
||||
|
||||
var tenant = options.Tenants[0];
|
||||
tenant.Id = "tenant-default";
|
||||
tenant.DisplayName = "Tenant Default";
|
||||
tenant.AdvisoryAi.RemoteInference.ConsentGranted = true;
|
||||
tenant.AdvisoryAi.RemoteInference.ConsentVersion = "2025-10";
|
||||
tenant.AdvisoryAi.RemoteInference.ConsentedAt = DateTimeOffset.Parse("2025-10-31T12:34:56Z");
|
||||
tenant.AdvisoryAi.RemoteInference.ConsentedBy = "legal@example.com";
|
||||
}
|
||||
|
||||
private static string ComputeSha256(string value)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(value));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ExtractProperties(BsonDocument document)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
if (!document.TryGetValue("properties", out var propertiesValue))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
foreach (var item in propertiesValue.AsBsonArray)
|
||||
{
|
||||
if (item is not BsonDocument property)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = property.TryGetValue("name", out var nameValue) ? nameValue.AsString : null;
|
||||
var value = property.TryGetValue("value", out var valueNode) ? valueNode.AsString : null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
result[name] = value ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static RemoteInferencePayload CreatePayload(string profile, string prompt = "Summarize remedations.")
|
||||
{
|
||||
return new RemoteInferencePayload(
|
||||
TaskType: "summary",
|
||||
Profile: profile,
|
||||
ModelId: "gpt-4o-mini",
|
||||
Prompt: prompt,
|
||||
ContextDigest: "sha256:context",
|
||||
OutputHash: "sha256:output",
|
||||
TaskId: "task-123",
|
||||
Metadata: new Dictionary<string, string>
|
||||
{
|
||||
["channel"] = "cli",
|
||||
["env"] = "test"
|
||||
});
|
||||
}
|
||||
|
||||
private sealed record RemoteInferencePayload(
|
||||
[property: JsonPropertyName("taskType")] string TaskType,
|
||||
[property: JsonPropertyName("profile")] string Profile,
|
||||
[property: JsonPropertyName("modelId")] string ModelId,
|
||||
[property: JsonPropertyName("prompt")] string Prompt,
|
||||
[property: JsonPropertyName("contextDigest")] string ContextDigest,
|
||||
[property: JsonPropertyName("outputHash")] string OutputHash,
|
||||
[property: JsonPropertyName("taskId")] string TaskId,
|
||||
[property: JsonPropertyName("metadata")] IDictionary<string, string> Metadata);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,44 +1,44 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Authority.Tests.Infrastructure;
|
||||
|
||||
internal sealed class EnvironmentVariableScope : IDisposable
|
||||
{
|
||||
private readonly Dictionary<string, string?> originals = new(StringComparer.Ordinal);
|
||||
private bool disposed;
|
||||
|
||||
public EnvironmentVariableScope(IEnumerable<KeyValuePair<string, string?>> overrides)
|
||||
{
|
||||
if (overrides is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(overrides));
|
||||
}
|
||||
|
||||
foreach (var kvp in overrides)
|
||||
{
|
||||
if (originals.ContainsKey(kvp.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
originals.Add(kvp.Key, Environment.GetEnvironmentVariable(kvp.Key));
|
||||
Environment.SetEnvironmentVariable(kvp.Key, kvp.Value);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var kvp in originals)
|
||||
{
|
||||
Environment.SetEnvironmentVariable(kvp.Key, kvp.Value);
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Authority.Tests.Infrastructure;
|
||||
|
||||
internal sealed class EnvironmentVariableScope : IDisposable
|
||||
{
|
||||
private readonly Dictionary<string, string?> originals = new(StringComparer.Ordinal);
|
||||
private bool disposed;
|
||||
|
||||
public EnvironmentVariableScope(IEnumerable<KeyValuePair<string, string?>> overrides)
|
||||
{
|
||||
if (overrides is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(overrides));
|
||||
}
|
||||
|
||||
foreach (var kvp in overrides)
|
||||
{
|
||||
if (originals.ContainsKey(kvp.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
originals.Add(kvp.Key, Environment.GetEnvironmentVariable(kvp.Key));
|
||||
Environment.SetEnvironmentVariable(kvp.Key, kvp.Value);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var kvp in originals)
|
||||
{
|
||||
Environment.SetEnvironmentVariable(kvp.Key, kvp.Value);
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,57 +1,57 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.Tests.Infrastructure;
|
||||
|
||||
internal sealed class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
public const string SchemeName = "TestAuth";
|
||||
|
||||
public TestAuthHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder)
|
||||
: base(options, logger, encoder)
|
||||
{
|
||||
}
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
var tenantHeader = Request.Headers.TryGetValue("X-Test-Tenant", out var tenantValues)
|
||||
? tenantValues.ToString()
|
||||
: "tenant-default";
|
||||
|
||||
var scopesHeader = Request.Headers.TryGetValue("X-Test-Scopes", out var scopeValues)
|
||||
? scopeValues.ToString()
|
||||
: StellaOpsScopes.AdvisoryAiOperate;
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new Claim(StellaOpsClaimTypes.ClientId, "test-client")
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tenantHeader) &&
|
||||
!string.Equals(tenantHeader, "none", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
claims.Add(new Claim(StellaOpsClaimTypes.Tenant, tenantHeader.Trim()));
|
||||
}
|
||||
|
||||
var scopes = scopesHeader.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
foreach (var scope in scopes)
|
||||
{
|
||||
claims.Add(new Claim(StellaOpsClaimTypes.ScopeItem, scope));
|
||||
}
|
||||
|
||||
var identity = new ClaimsIdentity(claims, Scheme.Name);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
var ticket = new AuthenticationTicket(principal, Scheme.Name);
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.Tests.Infrastructure;
|
||||
|
||||
internal sealed class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
public const string SchemeName = "TestAuth";
|
||||
|
||||
public TestAuthHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder)
|
||||
: base(options, logger, encoder)
|
||||
{
|
||||
}
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
var tenantHeader = Request.Headers.TryGetValue("X-Test-Tenant", out var tenantValues)
|
||||
? tenantValues.ToString()
|
||||
: "tenant-default";
|
||||
|
||||
var scopesHeader = Request.Headers.TryGetValue("X-Test-Scopes", out var scopeValues)
|
||||
? scopeValues.ToString()
|
||||
: StellaOpsScopes.AdvisoryAiOperate;
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new Claim(StellaOpsClaimTypes.ClientId, "test-client")
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tenantHeader) &&
|
||||
!string.Equals(tenantHeader, "none", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
claims.Add(new Claim(StellaOpsClaimTypes.Tenant, tenantHeader.Trim()));
|
||||
}
|
||||
|
||||
var scopes = scopesHeader.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
foreach (var scope in scopes)
|
||||
{
|
||||
claims.Add(new Claim(StellaOpsClaimTypes.ScopeItem, scope));
|
||||
}
|
||||
|
||||
var identity = new ClaimsIdentity(claims, Scheme.Name);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
var ticket = new AuthenticationTicket(principal, Scheme.Name);
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,259 +1,259 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Authority;
|
||||
using StellaOps.Authority.Tests.Infrastructure;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using StellaOps.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Tests.Notifications;
|
||||
|
||||
public sealed class NotifyAckTokenRotationEndpointTests : IClassFixture<AuthorityWebApplicationFactory>
|
||||
{
|
||||
private readonly AuthorityWebApplicationFactory factory;
|
||||
|
||||
public NotifyAckTokenRotationEndpointTests(AuthorityWebApplicationFactory factory)
|
||||
{
|
||||
this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rotate_ReturnsOk_AndEmitsAuditEvent()
|
||||
{
|
||||
const string AckEnabledKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__ACKTOKENS__ENABLED";
|
||||
const string AckActiveKeyIdKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__ACKTOKENS__ACTIVEKEYID";
|
||||
const string AckKeyPathKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__ACKTOKENS__KEYPATH";
|
||||
const string AckKeySourceKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__ACKTOKENS__KEYSOURCE";
|
||||
const string AckAlgorithmKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__ACKTOKENS__ALGORITHM";
|
||||
const string WebhooksEnabledKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__WEBHOOKS__ENABLED";
|
||||
const string WebhooksAllowedHost0Key = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__WEBHOOKS__ALLOWEDHOSTS__0";
|
||||
|
||||
var tempDir = Directory.CreateTempSubdirectory("ack-rotation-success");
|
||||
try
|
||||
{
|
||||
var key1Path = Path.Combine(tempDir.FullName, "ack-key-1.pem");
|
||||
var key2Path = Path.Combine(tempDir.FullName, "ack-key-2.pem");
|
||||
CreateEcPrivateKey(key1Path);
|
||||
CreateEcPrivateKey(key2Path);
|
||||
|
||||
using var env = new EnvironmentVariableScope(new[]
|
||||
{
|
||||
new KeyValuePair<string, string?>(AckEnabledKey, "true"),
|
||||
new KeyValuePair<string, string?>(AckActiveKeyIdKey, "ack-key-1"),
|
||||
new KeyValuePair<string, string?>(AckKeyPathKey, key1Path),
|
||||
new KeyValuePair<string, string?>(AckKeySourceKey, "file"),
|
||||
new KeyValuePair<string, string?>(AckAlgorithmKey, SignatureAlgorithms.Es256),
|
||||
new KeyValuePair<string, string?>(WebhooksEnabledKey, "true"),
|
||||
new KeyValuePair<string, string?>(WebhooksAllowedHost0Key, "hooks.slack.com")
|
||||
});
|
||||
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T12:00:00Z"));
|
||||
|
||||
using var scopedFactory = factory.WithWebHostBuilder(host =>
|
||||
{
|
||||
host.ConfigureAppConfiguration((_, configuration) =>
|
||||
{
|
||||
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Notifications:AckTokens:Enabled"] = "true",
|
||||
["Authority:Notifications:AckTokens:ActiveKeyId"] = "ack-key-1",
|
||||
["Authority:Notifications:AckTokens:KeyPath"] = key1Path,
|
||||
["Authority:Notifications:AckTokens:KeySource"] = "file",
|
||||
["Authority:Notifications:AckTokens:Algorithm"] = SignatureAlgorithms.Es256,
|
||||
["Authority:Notifications:Webhooks:Enabled"] = "true",
|
||||
["Authority:Notifications:Webhooks:AllowedHosts:0"] = "hooks.slack.com",
|
||||
["Authority:Notifications:Escalation:Scope"] = "notify.escalate",
|
||||
["Authority:Notifications:Escalation:RequireAdminScope"] = "true"
|
||||
});
|
||||
});
|
||||
|
||||
host.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IAuthEventSink>();
|
||||
services.AddSingleton<IAuthEventSink>(sink);
|
||||
services.Replace(ServiceDescriptor.Singleton<TimeProvider>(timeProvider));
|
||||
services.PostConfigure<StellaOpsAuthorityOptions>(options =>
|
||||
{
|
||||
options.Notifications.AckTokens.Enabled = true;
|
||||
options.Notifications.AckTokens.ActiveKeyId = "ack-key-1";
|
||||
options.Notifications.AckTokens.KeyPath = key1Path;
|
||||
options.Notifications.AckTokens.KeySource = "file";
|
||||
options.Notifications.AckTokens.Algorithm = SignatureAlgorithms.Es256;
|
||||
});
|
||||
var authBuilder = services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
|
||||
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
|
||||
});
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(StellaOpsAuthenticationDefaults.AuthenticationScheme, _ => { });
|
||||
});
|
||||
});
|
||||
|
||||
using var client = scopedFactory.CreateClient();
|
||||
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.NotifyAdmin);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var response = await client.PostAsJsonAsync("/notify/ack-tokens/rotate", new
|
||||
{
|
||||
keyId = "ack-key-2",
|
||||
location = key2Path
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<AckRotateResponse>();
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal("ack-key-2", payload!.ActiveKeyId);
|
||||
Assert.Equal("ack-key-1", payload.PreviousKeyId);
|
||||
|
||||
var rotationEvent = Assert.Single(sink.Events, evt => evt.EventType == "notify.ack.key_rotated");
|
||||
Assert.Equal(AuthEventOutcome.Success, rotationEvent.Outcome);
|
||||
Assert.Contains(rotationEvent.Properties, property =>
|
||||
string.Equals(property.Name, "notify.ack.key_id", StringComparison.Ordinal) &&
|
||||
string.Equals(property.Value.Value, "ack-key-2", StringComparison.Ordinal));
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rotate_ReturnsBadRequest_WhenKeyIdMissing_AndAuditsFailure()
|
||||
{
|
||||
var tempDir = Directory.CreateTempSubdirectory("ack-rotation-failure");
|
||||
try
|
||||
{
|
||||
var key1Path = Path.Combine(tempDir.FullName, "ack-key-1.pem");
|
||||
var key2Path = Path.Combine(tempDir.FullName, "ack-key-2.pem");
|
||||
CreateEcPrivateKey(key1Path);
|
||||
CreateEcPrivateKey(key2Path);
|
||||
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T13:00:00Z"));
|
||||
|
||||
using var app = factory.WithWebHostBuilder(host =>
|
||||
{
|
||||
host.ConfigureAppConfiguration((_, configuration) =>
|
||||
{
|
||||
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Notifications:AckTokens:Enabled"] = "true",
|
||||
["Authority:Notifications:AckTokens:ActiveKeyId"] = "ack-key-1",
|
||||
["Authority:Notifications:AckTokens:KeyPath"] = key1Path,
|
||||
["Authority:Notifications:AckTokens:KeySource"] = "file",
|
||||
["Authority:Notifications:AckTokens:Algorithm"] = SignatureAlgorithms.Es256,
|
||||
["Authority:Notifications:Webhooks:Enabled"] = "true",
|
||||
["Authority:Notifications:Webhooks:AllowedHosts:0"] = "hooks.slack.com"
|
||||
});
|
||||
});
|
||||
|
||||
host.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IAuthEventSink>();
|
||||
services.AddSingleton<IAuthEventSink>(sink);
|
||||
services.Replace(ServiceDescriptor.Singleton<TimeProvider>(timeProvider));
|
||||
services.PostConfigure<StellaOpsAuthorityOptions>(options =>
|
||||
{
|
||||
options.Notifications.AckTokens.Enabled = true;
|
||||
options.Notifications.AckTokens.ActiveKeyId = "ack-key-1";
|
||||
options.Notifications.AckTokens.KeyPath = key1Path;
|
||||
options.Notifications.AckTokens.KeySource = "file";
|
||||
options.Notifications.AckTokens.Algorithm = SignatureAlgorithms.Es256;
|
||||
});
|
||||
var authBuilder = services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
|
||||
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
|
||||
});
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(StellaOpsAuthenticationDefaults.AuthenticationScheme, _ => { });
|
||||
});
|
||||
});
|
||||
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.NotifyAdmin);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var response = await client.PostAsJsonAsync("/notify/ack-tokens/rotate", new
|
||||
{
|
||||
location = key2Path
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
|
||||
var failureEvent = Assert.Single(sink.Events, evt => evt.EventType == "notify.ack.key_rotation_failed");
|
||||
Assert.Equal(AuthEventOutcome.Failure, failureEvent.Outcome);
|
||||
Assert.Contains("keyId", failureEvent.Reason, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
private static void CreateEcPrivateKey(string path)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
File.WriteAllText(path, ecdsa.ExportECPrivateKeyPem());
|
||||
}
|
||||
|
||||
private static void TryDeleteDirectory(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
Directory.Delete(path, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore cleanup failures in tests.
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record AckRotateResponse(
|
||||
string ActiveKeyId,
|
||||
string? Provider,
|
||||
string? Source,
|
||||
string? Location,
|
||||
string? PreviousKeyId,
|
||||
IReadOnlyCollection<string> RetiredKeyIds);
|
||||
|
||||
private sealed class RecordingAuthEventSink : IAuthEventSink
|
||||
{
|
||||
private readonly ConcurrentQueue<AuthEventRecord> events = new();
|
||||
|
||||
public IReadOnlyCollection<AuthEventRecord> Events => events.ToArray();
|
||||
|
||||
public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
events.Enqueue(record);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Authority;
|
||||
using StellaOps.Authority.Tests.Infrastructure;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using StellaOps.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Tests.Notifications;
|
||||
|
||||
public sealed class NotifyAckTokenRotationEndpointTests : IClassFixture<AuthorityWebApplicationFactory>
|
||||
{
|
||||
private readonly AuthorityWebApplicationFactory factory;
|
||||
|
||||
public NotifyAckTokenRotationEndpointTests(AuthorityWebApplicationFactory factory)
|
||||
{
|
||||
this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rotate_ReturnsOk_AndEmitsAuditEvent()
|
||||
{
|
||||
const string AckEnabledKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__ACKTOKENS__ENABLED";
|
||||
const string AckActiveKeyIdKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__ACKTOKENS__ACTIVEKEYID";
|
||||
const string AckKeyPathKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__ACKTOKENS__KEYPATH";
|
||||
const string AckKeySourceKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__ACKTOKENS__KEYSOURCE";
|
||||
const string AckAlgorithmKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__ACKTOKENS__ALGORITHM";
|
||||
const string WebhooksEnabledKey = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__WEBHOOKS__ENABLED";
|
||||
const string WebhooksAllowedHost0Key = "STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__WEBHOOKS__ALLOWEDHOSTS__0";
|
||||
|
||||
var tempDir = Directory.CreateTempSubdirectory("ack-rotation-success");
|
||||
try
|
||||
{
|
||||
var key1Path = Path.Combine(tempDir.FullName, "ack-key-1.pem");
|
||||
var key2Path = Path.Combine(tempDir.FullName, "ack-key-2.pem");
|
||||
CreateEcPrivateKey(key1Path);
|
||||
CreateEcPrivateKey(key2Path);
|
||||
|
||||
using var env = new EnvironmentVariableScope(new[]
|
||||
{
|
||||
new KeyValuePair<string, string?>(AckEnabledKey, "true"),
|
||||
new KeyValuePair<string, string?>(AckActiveKeyIdKey, "ack-key-1"),
|
||||
new KeyValuePair<string, string?>(AckKeyPathKey, key1Path),
|
||||
new KeyValuePair<string, string?>(AckKeySourceKey, "file"),
|
||||
new KeyValuePair<string, string?>(AckAlgorithmKey, SignatureAlgorithms.Es256),
|
||||
new KeyValuePair<string, string?>(WebhooksEnabledKey, "true"),
|
||||
new KeyValuePair<string, string?>(WebhooksAllowedHost0Key, "hooks.slack.com")
|
||||
});
|
||||
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T12:00:00Z"));
|
||||
|
||||
using var scopedFactory = factory.WithWebHostBuilder(host =>
|
||||
{
|
||||
host.ConfigureAppConfiguration((_, configuration) =>
|
||||
{
|
||||
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Notifications:AckTokens:Enabled"] = "true",
|
||||
["Authority:Notifications:AckTokens:ActiveKeyId"] = "ack-key-1",
|
||||
["Authority:Notifications:AckTokens:KeyPath"] = key1Path,
|
||||
["Authority:Notifications:AckTokens:KeySource"] = "file",
|
||||
["Authority:Notifications:AckTokens:Algorithm"] = SignatureAlgorithms.Es256,
|
||||
["Authority:Notifications:Webhooks:Enabled"] = "true",
|
||||
["Authority:Notifications:Webhooks:AllowedHosts:0"] = "hooks.slack.com",
|
||||
["Authority:Notifications:Escalation:Scope"] = "notify.escalate",
|
||||
["Authority:Notifications:Escalation:RequireAdminScope"] = "true"
|
||||
});
|
||||
});
|
||||
|
||||
host.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IAuthEventSink>();
|
||||
services.AddSingleton<IAuthEventSink>(sink);
|
||||
services.Replace(ServiceDescriptor.Singleton<TimeProvider>(timeProvider));
|
||||
services.PostConfigure<StellaOpsAuthorityOptions>(options =>
|
||||
{
|
||||
options.Notifications.AckTokens.Enabled = true;
|
||||
options.Notifications.AckTokens.ActiveKeyId = "ack-key-1";
|
||||
options.Notifications.AckTokens.KeyPath = key1Path;
|
||||
options.Notifications.AckTokens.KeySource = "file";
|
||||
options.Notifications.AckTokens.Algorithm = SignatureAlgorithms.Es256;
|
||||
});
|
||||
var authBuilder = services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
|
||||
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
|
||||
});
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(StellaOpsAuthenticationDefaults.AuthenticationScheme, _ => { });
|
||||
});
|
||||
});
|
||||
|
||||
using var client = scopedFactory.CreateClient();
|
||||
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.NotifyAdmin);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var response = await client.PostAsJsonAsync("/notify/ack-tokens/rotate", new
|
||||
{
|
||||
keyId = "ack-key-2",
|
||||
location = key2Path
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<AckRotateResponse>();
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal("ack-key-2", payload!.ActiveKeyId);
|
||||
Assert.Equal("ack-key-1", payload.PreviousKeyId);
|
||||
|
||||
var rotationEvent = Assert.Single(sink.Events, evt => evt.EventType == "notify.ack.key_rotated");
|
||||
Assert.Equal(AuthEventOutcome.Success, rotationEvent.Outcome);
|
||||
Assert.Contains(rotationEvent.Properties, property =>
|
||||
string.Equals(property.Name, "notify.ack.key_id", StringComparison.Ordinal) &&
|
||||
string.Equals(property.Value.Value, "ack-key-2", StringComparison.Ordinal));
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rotate_ReturnsBadRequest_WhenKeyIdMissing_AndAuditsFailure()
|
||||
{
|
||||
var tempDir = Directory.CreateTempSubdirectory("ack-rotation-failure");
|
||||
try
|
||||
{
|
||||
var key1Path = Path.Combine(tempDir.FullName, "ack-key-1.pem");
|
||||
var key2Path = Path.Combine(tempDir.FullName, "ack-key-2.pem");
|
||||
CreateEcPrivateKey(key1Path);
|
||||
CreateEcPrivateKey(key2Path);
|
||||
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T13:00:00Z"));
|
||||
|
||||
using var app = factory.WithWebHostBuilder(host =>
|
||||
{
|
||||
host.ConfigureAppConfiguration((_, configuration) =>
|
||||
{
|
||||
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Notifications:AckTokens:Enabled"] = "true",
|
||||
["Authority:Notifications:AckTokens:ActiveKeyId"] = "ack-key-1",
|
||||
["Authority:Notifications:AckTokens:KeyPath"] = key1Path,
|
||||
["Authority:Notifications:AckTokens:KeySource"] = "file",
|
||||
["Authority:Notifications:AckTokens:Algorithm"] = SignatureAlgorithms.Es256,
|
||||
["Authority:Notifications:Webhooks:Enabled"] = "true",
|
||||
["Authority:Notifications:Webhooks:AllowedHosts:0"] = "hooks.slack.com"
|
||||
});
|
||||
});
|
||||
|
||||
host.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IAuthEventSink>();
|
||||
services.AddSingleton<IAuthEventSink>(sink);
|
||||
services.Replace(ServiceDescriptor.Singleton<TimeProvider>(timeProvider));
|
||||
services.PostConfigure<StellaOpsAuthorityOptions>(options =>
|
||||
{
|
||||
options.Notifications.AckTokens.Enabled = true;
|
||||
options.Notifications.AckTokens.ActiveKeyId = "ack-key-1";
|
||||
options.Notifications.AckTokens.KeyPath = key1Path;
|
||||
options.Notifications.AckTokens.KeySource = "file";
|
||||
options.Notifications.AckTokens.Algorithm = SignatureAlgorithms.Es256;
|
||||
});
|
||||
var authBuilder = services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
|
||||
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
|
||||
});
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(StellaOpsAuthenticationDefaults.AuthenticationScheme, _ => { });
|
||||
});
|
||||
});
|
||||
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.NotifyAdmin);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var response = await client.PostAsJsonAsync("/notify/ack-tokens/rotate", new
|
||||
{
|
||||
location = key2Path
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
|
||||
var failureEvent = Assert.Single(sink.Events, evt => evt.EventType == "notify.ack.key_rotation_failed");
|
||||
Assert.Equal(AuthEventOutcome.Failure, failureEvent.Outcome);
|
||||
Assert.Contains("keyId", failureEvent.Reason, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
private static void CreateEcPrivateKey(string path)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
File.WriteAllText(path, ecdsa.ExportECPrivateKeyPem());
|
||||
}
|
||||
|
||||
private static void TryDeleteDirectory(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
Directory.Delete(path, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore cleanup failures in tests.
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record AckRotateResponse(
|
||||
string ActiveKeyId,
|
||||
string? Provider,
|
||||
string? Source,
|
||||
string? Location,
|
||||
string? PreviousKeyId,
|
||||
IReadOnlyCollection<string> RetiredKeyIds);
|
||||
|
||||
private sealed class RecordingAuthEventSink : IAuthEventSink
|
||||
{
|
||||
private readonly ConcurrentQueue<AuthEventRecord> events = new();
|
||||
|
||||
public IReadOnlyCollection<AuthEventRecord> Events => events.ToArray();
|
||||
|
||||
public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
events.Enqueue(record);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,48 +1,48 @@
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Authority.Tests.Infrastructure;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Tests.OpenIddict;
|
||||
|
||||
public sealed class DiscoveryMetadataTests : IClassFixture<AuthorityWebApplicationFactory>
|
||||
{
|
||||
private readonly AuthorityWebApplicationFactory factory;
|
||||
|
||||
public DiscoveryMetadataTests(AuthorityWebApplicationFactory factory)
|
||||
{
|
||||
this.factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenIdDiscovery_IncludesAdvisoryAiMetadata()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var response = await client.GetAsync("/.well-known/openid-configuration");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var payload = await response.Content.ReadAsStringAsync();
|
||||
using var document = JsonDocument.Parse(payload);
|
||||
|
||||
var root = document.RootElement;
|
||||
Assert.True(root.TryGetProperty("stellaops_advisory_ai_scopes_supported", out var scopesNode));
|
||||
|
||||
var scopes = scopesNode.EnumerateArray().Select(element => element.GetString()).ToArray();
|
||||
Assert.Contains(StellaOpsScopes.AdvisoryAiView, scopes);
|
||||
Assert.Contains(StellaOpsScopes.AdvisoryAiOperate, scopes);
|
||||
Assert.Contains(StellaOpsScopes.AdvisoryAiAdmin, scopes);
|
||||
|
||||
Assert.True(root.TryGetProperty("stellaops_advisory_ai_remote_inference", out var remoteNode));
|
||||
Assert.False(remoteNode.GetProperty("enabled").GetBoolean());
|
||||
Assert.True(remoteNode.GetProperty("require_tenant_consent").GetBoolean());
|
||||
|
||||
var profiles = remoteNode.GetProperty("allowed_profiles").EnumerateArray().ToArray();
|
||||
Assert.Empty(profiles);
|
||||
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Authority.Tests.Infrastructure;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Tests.OpenIddict;
|
||||
|
||||
public sealed class DiscoveryMetadataTests : IClassFixture<AuthorityWebApplicationFactory>
|
||||
{
|
||||
private readonly AuthorityWebApplicationFactory factory;
|
||||
|
||||
public DiscoveryMetadataTests(AuthorityWebApplicationFactory factory)
|
||||
{
|
||||
this.factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenIdDiscovery_IncludesAdvisoryAiMetadata()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var response = await client.GetAsync("/.well-known/openid-configuration");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var payload = await response.Content.ReadAsStringAsync();
|
||||
using var document = JsonDocument.Parse(payload);
|
||||
|
||||
var root = document.RootElement;
|
||||
Assert.True(root.TryGetProperty("stellaops_advisory_ai_scopes_supported", out var scopesNode));
|
||||
|
||||
var scopes = scopesNode.EnumerateArray().Select(element => element.GetString()).ToArray();
|
||||
Assert.Contains(StellaOpsScopes.AdvisoryAiView, scopes);
|
||||
Assert.Contains(StellaOpsScopes.AdvisoryAiOperate, scopes);
|
||||
Assert.Contains(StellaOpsScopes.AdvisoryAiAdmin, scopes);
|
||||
|
||||
Assert.True(root.TryGetProperty("stellaops_advisory_ai_remote_inference", out var remoteNode));
|
||||
Assert.False(remoteNode.GetProperty("enabled").GetBoolean());
|
||||
Assert.True(remoteNode.GetProperty("require_tenant_consent").GetBoolean());
|
||||
|
||||
var profiles = remoteNode.GetProperty("allowed_profiles").EnumerateArray().ToArray();
|
||||
Assert.Empty(profiles);
|
||||
|
||||
Assert.True(root.TryGetProperty("stellaops_airgap_scopes_supported", out var airgapNode));
|
||||
var airgapScopes = airgapNode.EnumerateArray().Select(element => element.GetString()).ToArray();
|
||||
Assert.Contains(StellaOpsScopes.AirgapSeal, airgapScopes);
|
||||
@@ -61,10 +61,10 @@ public sealed class DiscoveryMetadataTests : IClassFixture<AuthorityWebApplicati
|
||||
Assert.Contains(StellaOpsScopes.ObservabilityRead, observabilityScopes);
|
||||
Assert.Contains(StellaOpsScopes.TimelineRead, observabilityScopes);
|
||||
Assert.Contains(StellaOpsScopes.TimelineWrite, observabilityScopes);
|
||||
Assert.Contains(StellaOpsScopes.EvidenceCreate, observabilityScopes);
|
||||
Assert.Contains(StellaOpsScopes.EvidenceRead, observabilityScopes);
|
||||
Assert.Contains(StellaOpsScopes.EvidenceHold, observabilityScopes);
|
||||
Assert.Contains(StellaOpsScopes.AttestRead, observabilityScopes);
|
||||
Assert.Contains(StellaOpsScopes.ObservabilityIncident, observabilityScopes);
|
||||
}
|
||||
}
|
||||
Assert.Contains(StellaOpsScopes.EvidenceCreate, observabilityScopes);
|
||||
Assert.Contains(StellaOpsScopes.EvidenceRead, observabilityScopes);
|
||||
Assert.Contains(StellaOpsScopes.EvidenceHold, observabilityScopes);
|
||||
Assert.Contains(StellaOpsScopes.AttestRead, observabilityScopes);
|
||||
Assert.Contains(StellaOpsScopes.ObservabilityIncident, observabilityScopes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,112 +1,112 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Authority.Tests.Infrastructure;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Tests.OpenIddict;
|
||||
|
||||
public sealed class LegacyAuthDeprecationTests : IClassFixture<AuthorityWebApplicationFactory>
|
||||
{
|
||||
private static readonly string ExpectedDeprecationHeader = new DateTimeOffset(2025, 11, 1, 0, 0, 0, TimeSpan.Zero)
|
||||
.UtcDateTime.ToString("r", CultureInfo.InvariantCulture);
|
||||
|
||||
private static readonly string ExpectedSunsetHeader = new DateTimeOffset(2026, 5, 1, 0, 0, 0, TimeSpan.Zero)
|
||||
.UtcDateTime.ToString("r", CultureInfo.InvariantCulture);
|
||||
|
||||
private static readonly string ExpectedSunsetIso = new DateTimeOffset(2026, 5, 1, 0, 0, 0, TimeSpan.Zero)
|
||||
.ToString("O", CultureInfo.InvariantCulture);
|
||||
|
||||
private readonly AuthorityWebApplicationFactory factory;
|
||||
|
||||
public LegacyAuthDeprecationTests(AuthorityWebApplicationFactory factory)
|
||||
=> this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyTokenEndpoint_IncludesDeprecationHeaders()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var response = await client.PostAsync(
|
||||
"/oauth/token",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "client_credentials"
|
||||
}));
|
||||
|
||||
Assert.NotNull(response);
|
||||
Assert.True(response.Headers.TryGetValues("Deprecation", out var deprecationValues));
|
||||
Assert.Contains(ExpectedDeprecationHeader, deprecationValues);
|
||||
|
||||
Assert.True(response.Headers.TryGetValues("Sunset", out var sunsetValues));
|
||||
Assert.Contains(ExpectedSunsetHeader, sunsetValues);
|
||||
|
||||
Assert.True(response.Headers.TryGetValues("Warning", out var warningValues));
|
||||
Assert.Contains(warningValues, warning => warning.Contains("Legacy Authority endpoint", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
Assert.True(response.Headers.TryGetValues("Link", out var linkValues));
|
||||
Assert.Contains(linkValues, value => value.Contains("rel=\"sunset\"", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyTokenEndpoint_EmitsAuditEvent()
|
||||
{
|
||||
var sink = new RecordingAuthEventSink();
|
||||
|
||||
using var customFactory = factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IAuthEventSink>();
|
||||
services.AddSingleton<IAuthEventSink>(sink);
|
||||
});
|
||||
});
|
||||
|
||||
using var client = customFactory.CreateClient();
|
||||
|
||||
using var response = await client.PostAsync(
|
||||
"/oauth/token",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "client_credentials"
|
||||
}));
|
||||
|
||||
Assert.NotNull(response);
|
||||
|
||||
var record = Assert.Single(sink.Events);
|
||||
Assert.Equal("authority.api.legacy_endpoint", record.EventType);
|
||||
|
||||
Assert.Contains(record.Properties, property =>
|
||||
string.Equals(property.Name, "legacy.endpoint.original", StringComparison.Ordinal) &&
|
||||
string.Equals(property.Value.Value, "/oauth/token", StringComparison.Ordinal));
|
||||
|
||||
Assert.Contains(record.Properties, property =>
|
||||
string.Equals(property.Name, "legacy.endpoint.canonical", StringComparison.Ordinal) &&
|
||||
string.Equals(property.Value.Value, "/token", StringComparison.Ordinal));
|
||||
|
||||
Assert.Contains(record.Properties, property =>
|
||||
string.Equals(property.Name, "legacy.sunset_at", StringComparison.Ordinal) &&
|
||||
string.Equals(property.Value.Value, ExpectedSunsetIso, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private sealed class RecordingAuthEventSink : IAuthEventSink
|
||||
{
|
||||
private readonly ConcurrentQueue<AuthEventRecord> events = new();
|
||||
|
||||
public IReadOnlyCollection<AuthEventRecord> Events => events.ToArray();
|
||||
|
||||
public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
events.Enqueue(record);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Authority.Tests.Infrastructure;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Tests.OpenIddict;
|
||||
|
||||
public sealed class LegacyAuthDeprecationTests : IClassFixture<AuthorityWebApplicationFactory>
|
||||
{
|
||||
private static readonly string ExpectedDeprecationHeader = new DateTimeOffset(2025, 11, 1, 0, 0, 0, TimeSpan.Zero)
|
||||
.UtcDateTime.ToString("r", CultureInfo.InvariantCulture);
|
||||
|
||||
private static readonly string ExpectedSunsetHeader = new DateTimeOffset(2026, 5, 1, 0, 0, 0, TimeSpan.Zero)
|
||||
.UtcDateTime.ToString("r", CultureInfo.InvariantCulture);
|
||||
|
||||
private static readonly string ExpectedSunsetIso = new DateTimeOffset(2026, 5, 1, 0, 0, 0, TimeSpan.Zero)
|
||||
.ToString("O", CultureInfo.InvariantCulture);
|
||||
|
||||
private readonly AuthorityWebApplicationFactory factory;
|
||||
|
||||
public LegacyAuthDeprecationTests(AuthorityWebApplicationFactory factory)
|
||||
=> this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyTokenEndpoint_IncludesDeprecationHeaders()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var response = await client.PostAsync(
|
||||
"/oauth/token",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "client_credentials"
|
||||
}));
|
||||
|
||||
Assert.NotNull(response);
|
||||
Assert.True(response.Headers.TryGetValues("Deprecation", out var deprecationValues));
|
||||
Assert.Contains(ExpectedDeprecationHeader, deprecationValues);
|
||||
|
||||
Assert.True(response.Headers.TryGetValues("Sunset", out var sunsetValues));
|
||||
Assert.Contains(ExpectedSunsetHeader, sunsetValues);
|
||||
|
||||
Assert.True(response.Headers.TryGetValues("Warning", out var warningValues));
|
||||
Assert.Contains(warningValues, warning => warning.Contains("Legacy Authority endpoint", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
Assert.True(response.Headers.TryGetValues("Link", out var linkValues));
|
||||
Assert.Contains(linkValues, value => value.Contains("rel=\"sunset\"", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyTokenEndpoint_EmitsAuditEvent()
|
||||
{
|
||||
var sink = new RecordingAuthEventSink();
|
||||
|
||||
using var customFactory = factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IAuthEventSink>();
|
||||
services.AddSingleton<IAuthEventSink>(sink);
|
||||
});
|
||||
});
|
||||
|
||||
using var client = customFactory.CreateClient();
|
||||
|
||||
using var response = await client.PostAsync(
|
||||
"/oauth/token",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "client_credentials"
|
||||
}));
|
||||
|
||||
Assert.NotNull(response);
|
||||
|
||||
var record = Assert.Single(sink.Events);
|
||||
Assert.Equal("authority.api.legacy_endpoint", record.EventType);
|
||||
|
||||
Assert.Contains(record.Properties, property =>
|
||||
string.Equals(property.Name, "legacy.endpoint.original", StringComparison.Ordinal) &&
|
||||
string.Equals(property.Value.Value, "/oauth/token", StringComparison.Ordinal));
|
||||
|
||||
Assert.Contains(record.Properties, property =>
|
||||
string.Equals(property.Name, "legacy.endpoint.canonical", StringComparison.Ordinal) &&
|
||||
string.Equals(property.Value.Value, "/token", StringComparison.Ordinal));
|
||||
|
||||
Assert.Contains(record.Properties, property =>
|
||||
string.Equals(property.Name, "legacy.sunset_at", StringComparison.Ordinal) &&
|
||||
string.Equals(property.Value.Value, ExpectedSunsetIso, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private sealed class RecordingAuthEventSink : IAuthEventSink
|
||||
{
|
||||
private readonly ConcurrentQueue<AuthEventRecord> events = new();
|
||||
|
||||
public IReadOnlyCollection<AuthEventRecord> Events => events.ToArray();
|
||||
|
||||
public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
events.Enqueue(record);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,21 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Authority\StellaOps.Authority.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Auth.Security/StellaOps.Auth.Security.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Authority\StellaOps.Authority.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Auth.Security/StellaOps.Auth.Security.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="../../../../tests/shared/OpenSslLegacyShim.cs" Link="Infrastructure/OpenSslLegacyShim.cs" />
|
||||
<None Include="../../../../tests/native/openssl-1.1/linux-x64/*" Link="native/linux-x64/%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,457 +1,457 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Authority;
|
||||
using StellaOps.Authority.Tests.Infrastructure;
|
||||
using StellaOps.Authority.Vulnerability.Attachments;
|
||||
using StellaOps.Authority.Vulnerability.Workflow;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Tests.Vulnerability;
|
||||
|
||||
public sealed class VulnWorkflowTokenEndpointTests : IClassFixture<AuthorityWebApplicationFactory>
|
||||
{
|
||||
private readonly AuthorityWebApplicationFactory factory;
|
||||
private const string SigningEnabledKey = "STELLAOPS_AUTHORITY_AUTHORITY__SIGNING__ENABLED";
|
||||
private const string SigningActiveKeyIdKey = "STELLAOPS_AUTHORITY_AUTHORITY__SIGNING__ACTIVEKEYID";
|
||||
private const string SigningKeyPathKey = "STELLAOPS_AUTHORITY_AUTHORITY__SIGNING__KEYPATH";
|
||||
private const string SigningKeySourceKey = "STELLAOPS_AUTHORITY_AUTHORITY__SIGNING__KEYSOURCE";
|
||||
private const string SigningAlgorithmKey = "STELLAOPS_AUTHORITY_AUTHORITY__SIGNING__ALGORITHM";
|
||||
|
||||
public VulnWorkflowTokenEndpointTests(AuthorityWebApplicationFactory factory)
|
||||
{
|
||||
this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IssueAndVerifyWorkflowToken_SucceedsAndAudits()
|
||||
{
|
||||
var tempDir = Directory.CreateTempSubdirectory("workflow-token-success");
|
||||
var keyPath = Path.Combine(tempDir.FullName, "signing-key.pem");
|
||||
|
||||
try
|
||||
{
|
||||
CreateEcPrivateKey(keyPath);
|
||||
|
||||
using var env = new EnvironmentVariableScope(new[]
|
||||
{
|
||||
new KeyValuePair<string, string?>(SigningEnabledKey, "true"),
|
||||
new KeyValuePair<string, string?>(SigningActiveKeyIdKey, "workflow-key"),
|
||||
new KeyValuePair<string, string?>(SigningKeyPathKey, keyPath),
|
||||
new KeyValuePair<string, string?>(SigningKeySourceKey, "file"),
|
||||
new KeyValuePair<string, string?>(SigningAlgorithmKey, SignatureAlgorithms.Es256)
|
||||
});
|
||||
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T09:00:00Z"));
|
||||
|
||||
using var app = CreateSignedAuthorityApp(sink, timeProvider, "workflow-key", keyPath);
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.VulnOperate);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var issuePayload = new
|
||||
{
|
||||
tenant = "tenant-default",
|
||||
actions = new[] { "assign", "comment" },
|
||||
context = new Dictionary<string, string> { ["finding_id"] = "F-123" },
|
||||
nonce = "workflow-nonce-123456",
|
||||
expiresInSeconds = 600
|
||||
};
|
||||
|
||||
var issueResponse = await client.PostAsJsonAsync("/vuln/workflow/anti-forgery/issue", issuePayload);
|
||||
var issueBody = await issueResponse.Content.ReadAsStringAsync();
|
||||
Assert.True(issueResponse.StatusCode == HttpStatusCode.OK, $"Issue anti-forgery failed: {issueResponse.StatusCode} {issueBody}");
|
||||
|
||||
var issued = System.Text.Json.JsonSerializer.Deserialize<VulnWorkflowAntiForgeryIssueResponse>(
|
||||
issueBody,
|
||||
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
Assert.NotNull(issued);
|
||||
Assert.Equal("workflow-nonce-123456", issued!.Nonce);
|
||||
Assert.Contains("assign", issued.Actions);
|
||||
Assert.Contains("comment", issued.Actions);
|
||||
|
||||
var verifyPayload = new VulnWorkflowAntiForgeryVerifyRequest
|
||||
{
|
||||
Token = issued.Token,
|
||||
RequiredAction = "assign",
|
||||
Tenant = "tenant-default",
|
||||
Nonce = "workflow-nonce-123456"
|
||||
};
|
||||
|
||||
var verifyResponse = await client.PostAsJsonAsync("/vuln/workflow/anti-forgery/verify", verifyPayload);
|
||||
var verifyBody = await verifyResponse.Content.ReadAsStringAsync();
|
||||
Assert.True(verifyResponse.StatusCode == HttpStatusCode.OK, $"Verify anti-forgery failed: {verifyResponse.StatusCode} {verifyBody}");
|
||||
|
||||
var verified = System.Text.Json.JsonSerializer.Deserialize<VulnWorkflowAntiForgeryVerifyResponse>(
|
||||
verifyBody,
|
||||
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
Assert.NotNull(verified);
|
||||
Assert.Equal("tenant-default", verified!.Tenant);
|
||||
Assert.Equal("workflow-nonce-123456", verified.Nonce);
|
||||
|
||||
var issuedEvent = Assert.Single(sink.Events, evt => evt.EventType == "vuln.workflow.csrf.issued");
|
||||
Assert.Contains(issuedEvent.Properties, property => property.Name == "vuln.workflow.actor");
|
||||
|
||||
var verifiedEvent = Assert.Single(sink.Events, evt => evt.EventType == "vuln.workflow.csrf.verified");
|
||||
Assert.Contains(verifiedEvent.Properties, property => property.Name == "vuln.workflow.nonce" && property.Value.Value == "workflow-nonce-123456");
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IssueWorkflowToken_ReturnsBadRequest_WhenActionsMissing()
|
||||
{
|
||||
var tempDir = Directory.CreateTempSubdirectory("workflow-token-missing-actions");
|
||||
var keyPath = Path.Combine(tempDir.FullName, "signing-key.pem");
|
||||
|
||||
try
|
||||
{
|
||||
CreateEcPrivateKey(keyPath);
|
||||
|
||||
using var env = new EnvironmentVariableScope(new[]
|
||||
{
|
||||
new KeyValuePair<string, string?>(SigningEnabledKey, "true"),
|
||||
new KeyValuePair<string, string?>(SigningActiveKeyIdKey, "workflow-key"),
|
||||
new KeyValuePair<string, string?>(SigningKeyPathKey, keyPath),
|
||||
new KeyValuePair<string, string?>(SigningKeySourceKey, "file"),
|
||||
new KeyValuePair<string, string?>(SigningAlgorithmKey, SignatureAlgorithms.Es256)
|
||||
});
|
||||
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T09:10:00Z"));
|
||||
|
||||
using var app = CreateSignedAuthorityApp(sink, timeProvider, "workflow-key", keyPath);
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.VulnOperate);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var issuePayload = new
|
||||
{
|
||||
tenant = "tenant-default",
|
||||
actions = Array.Empty<string>()
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/vuln/workflow/anti-forgery/issue", issuePayload);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
|
||||
var error = await response.Content.ReadFromJsonAsync<Dictionary<string, string>>();
|
||||
Assert.NotNull(error);
|
||||
Assert.Equal("invalid_request", error!["error"]);
|
||||
Assert.Contains("action", error["message"], StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
Assert.DoesNotContain(sink.Events, evt => evt.EventType == "vuln.workflow.csrf.issued");
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyWorkflowToken_ReturnsBadRequest_WhenActionNotPermitted()
|
||||
{
|
||||
var tempDir = Directory.CreateTempSubdirectory("workflow-token-invalid-action");
|
||||
var keyPath = Path.Combine(tempDir.FullName, "signing-key.pem");
|
||||
|
||||
try
|
||||
{
|
||||
CreateEcPrivateKey(keyPath);
|
||||
|
||||
using var env = new EnvironmentVariableScope(new[]
|
||||
{
|
||||
new KeyValuePair<string, string?>(SigningEnabledKey, "true"),
|
||||
new KeyValuePair<string, string?>(SigningActiveKeyIdKey, "workflow-key"),
|
||||
new KeyValuePair<string, string?>(SigningKeyPathKey, keyPath),
|
||||
new KeyValuePair<string, string?>(SigningKeySourceKey, "file"),
|
||||
new KeyValuePair<string, string?>(SigningAlgorithmKey, SignatureAlgorithms.Es256)
|
||||
});
|
||||
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T09:20:00Z"));
|
||||
|
||||
using var app = CreateSignedAuthorityApp(sink, timeProvider, "workflow-key", keyPath);
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.VulnOperate);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var issuePayload = new
|
||||
{
|
||||
tenant = "tenant-default",
|
||||
actions = new[] { "assign" },
|
||||
nonce = "workflow-nonce-789012"
|
||||
};
|
||||
|
||||
var issueResponse = await client.PostAsJsonAsync("/vuln/workflow/anti-forgery/issue", issuePayload);
|
||||
Assert.Equal(HttpStatusCode.OK, issueResponse.StatusCode);
|
||||
var issued = await issueResponse.Content.ReadFromJsonAsync<VulnWorkflowAntiForgeryIssueResponse>();
|
||||
Assert.NotNull(issued);
|
||||
|
||||
var verifyPayload = new VulnWorkflowAntiForgeryVerifyRequest
|
||||
{
|
||||
Token = issued!.Token,
|
||||
RequiredAction = "close",
|
||||
Tenant = "tenant-default",
|
||||
Nonce = "workflow-nonce-789012"
|
||||
};
|
||||
|
||||
var verifyResponse = await client.PostAsJsonAsync("/vuln/workflow/anti-forgery/verify", verifyPayload);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, verifyResponse.StatusCode);
|
||||
|
||||
var error = await verifyResponse.Content.ReadFromJsonAsync<Dictionary<string, string>>();
|
||||
Assert.NotNull(error);
|
||||
Assert.Equal("invalid_token", error!["error"]);
|
||||
Assert.Contains("Token does not permit action", error["message"], StringComparison.Ordinal);
|
||||
|
||||
Assert.Single(sink.Events, evt => evt.EventType == "vuln.workflow.csrf.issued");
|
||||
Assert.DoesNotContain(sink.Events, evt => evt.EventType == "vuln.workflow.csrf.verified");
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IssueAndVerifyAttachmentToken_SucceedsAndAudits()
|
||||
{
|
||||
var tempDir = Directory.CreateTempSubdirectory("attachment-token-success");
|
||||
var keyPath = Path.Combine(tempDir.FullName, "attachment-key.pem");
|
||||
|
||||
try
|
||||
{
|
||||
CreateEcPrivateKey(keyPath);
|
||||
|
||||
using var env = new EnvironmentVariableScope(new[]
|
||||
{
|
||||
new KeyValuePair<string, string?>(SigningEnabledKey, "true"),
|
||||
new KeyValuePair<string, string?>(SigningActiveKeyIdKey, "attachment-key"),
|
||||
new KeyValuePair<string, string?>(SigningKeyPathKey, keyPath),
|
||||
new KeyValuePair<string, string?>(SigningKeySourceKey, "file"),
|
||||
new KeyValuePair<string, string?>(SigningAlgorithmKey, SignatureAlgorithms.Es256)
|
||||
});
|
||||
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T11:00:00Z"));
|
||||
|
||||
using var app = CreateSignedAuthorityApp(sink, timeProvider, "attachment-key", keyPath);
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.VulnInvestigate);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var issuePayload = new VulnAttachmentTokenIssueRequest
|
||||
{
|
||||
Tenant = "tenant-default",
|
||||
LedgerEventHash = "ledger-hash-001",
|
||||
AttachmentId = "attach-123",
|
||||
FindingId = "find-456",
|
||||
ContentHash = "sha256:abc123",
|
||||
ContentType = "application/pdf",
|
||||
Metadata = new Dictionary<string, string?> { ["origin"] = "vuln-workflow" }
|
||||
};
|
||||
|
||||
var issueResponse = await client.PostAsJsonAsync("/vuln/attachments/tokens/issue", issuePayload);
|
||||
Assert.Equal(HttpStatusCode.OK, issueResponse.StatusCode);
|
||||
var issued = await issueResponse.Content.ReadFromJsonAsync<VulnAttachmentTokenIssueResponse>();
|
||||
Assert.NotNull(issued);
|
||||
Assert.Equal("attach-123", issued!.AttachmentId);
|
||||
|
||||
var verifyPayload = new VulnAttachmentTokenVerifyRequest
|
||||
{
|
||||
Token = issued.Token,
|
||||
Tenant = "tenant-default",
|
||||
LedgerEventHash = "ledger-hash-001",
|
||||
AttachmentId = "attach-123"
|
||||
};
|
||||
|
||||
var verifyResponse = await client.PostAsJsonAsync("/vuln/attachments/tokens/verify", verifyPayload);
|
||||
Assert.Equal(HttpStatusCode.OK, verifyResponse.StatusCode);
|
||||
var verified = await verifyResponse.Content.ReadFromJsonAsync<VulnAttachmentTokenVerifyResponse>();
|
||||
Assert.NotNull(verified);
|
||||
Assert.Equal("ledger-hash-001", verified!.LedgerEventHash);
|
||||
|
||||
var issuedEvent = Assert.Single(sink.Events, evt => evt.EventType == "vuln.attachment.token.issued");
|
||||
Assert.Contains(issuedEvent.Properties, property => property.Name == "vuln.attachment.ledger_hash" && property.Value.Value == "ledger-hash-001");
|
||||
|
||||
var verifiedEvent = Assert.Single(sink.Events, evt => evt.EventType == "vuln.attachment.token.verified");
|
||||
Assert.Contains(verifiedEvent.Properties, property => property.Name == "vuln.attachment.ledger_hash" && property.Value.Value == "ledger-hash-001");
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttachmentToken_ReturnsBadRequest_WhenLedgerMismatch()
|
||||
{
|
||||
var tempDir = Directory.CreateTempSubdirectory("attachment-token-ledger-mismatch");
|
||||
var keyPath = Path.Combine(tempDir.FullName, "attachment-key.pem");
|
||||
|
||||
try
|
||||
{
|
||||
CreateEcPrivateKey(keyPath);
|
||||
|
||||
using var env = new EnvironmentVariableScope(new[]
|
||||
{
|
||||
new KeyValuePair<string, string?>(SigningEnabledKey, "true"),
|
||||
new KeyValuePair<string, string?>(SigningActiveKeyIdKey, "attachment-key"),
|
||||
new KeyValuePair<string, string?>(SigningKeyPathKey, keyPath),
|
||||
new KeyValuePair<string, string?>(SigningKeySourceKey, "file"),
|
||||
new KeyValuePair<string, string?>(SigningAlgorithmKey, SignatureAlgorithms.Es256)
|
||||
});
|
||||
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T11:10:00Z"));
|
||||
|
||||
using var app = CreateSignedAuthorityApp(sink, timeProvider, "attachment-key", keyPath);
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.VulnInvestigate);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var issuePayload = new VulnAttachmentTokenIssueRequest
|
||||
{
|
||||
Tenant = "tenant-default",
|
||||
LedgerEventHash = "ledger-hash-001",
|
||||
AttachmentId = "attach-123"
|
||||
};
|
||||
|
||||
var issueResponse = await client.PostAsJsonAsync("/vuln/attachments/tokens/issue", issuePayload);
|
||||
Assert.Equal(HttpStatusCode.OK, issueResponse.StatusCode);
|
||||
var issued = await issueResponse.Content.ReadFromJsonAsync<VulnAttachmentTokenIssueResponse>();
|
||||
Assert.NotNull(issued);
|
||||
|
||||
var verifyPayload = new VulnAttachmentTokenVerifyRequest
|
||||
{
|
||||
Token = issued!.Token,
|
||||
Tenant = "tenant-default",
|
||||
LedgerEventHash = "ledger-hash-999",
|
||||
AttachmentId = "attach-123"
|
||||
};
|
||||
|
||||
var verifyResponse = await client.PostAsJsonAsync("/vuln/attachments/tokens/verify", verifyPayload);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, verifyResponse.StatusCode);
|
||||
|
||||
var error = await verifyResponse.Content.ReadFromJsonAsync<Dictionary<string, string>>();
|
||||
Assert.NotNull(error);
|
||||
Assert.Equal("invalid_token", error!["error"]);
|
||||
Assert.Contains("ledger reference", error["message"], StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
Assert.Single(sink.Events, evt => evt.EventType == "vuln.attachment.token.issued");
|
||||
Assert.DoesNotContain(sink.Events, evt => evt.EventType == "vuln.attachment.token.verified");
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
private WebApplicationFactory<Program> CreateSignedAuthorityApp(
|
||||
RecordingAuthEventSink sink,
|
||||
FakeTimeProvider timeProvider,
|
||||
string signingKeyId,
|
||||
string signingKeyPath)
|
||||
{
|
||||
return factory.WithWebHostBuilder(host =>
|
||||
{
|
||||
host.ConfigureAppConfiguration((_, configuration) =>
|
||||
{
|
||||
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Signing:Enabled"] = "true",
|
||||
["Authority:Signing:ActiveKeyId"] = signingKeyId,
|
||||
["Authority:Signing:KeyPath"] = signingKeyPath,
|
||||
["Authority:Signing:KeySource"] = "file",
|
||||
["Authority:Signing:Algorithm"] = SignatureAlgorithms.Es256
|
||||
});
|
||||
});
|
||||
|
||||
host.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IAuthEventSink>();
|
||||
services.AddSingleton<IAuthEventSink>(sink);
|
||||
services.Replace(ServiceDescriptor.Singleton<TimeProvider>(timeProvider));
|
||||
services.PostConfigure<StellaOpsAuthorityOptions>(options =>
|
||||
{
|
||||
options.Signing.Enabled = true;
|
||||
options.Signing.ActiveKeyId = signingKeyId;
|
||||
options.Signing.KeyPath = signingKeyPath;
|
||||
options.Signing.KeySource = "file";
|
||||
options.Signing.Algorithm = SignatureAlgorithms.Es256;
|
||||
options.VulnerabilityExplorer.Workflow.AntiForgery.Enabled = true;
|
||||
options.VulnerabilityExplorer.Attachments.Enabled = true;
|
||||
});
|
||||
|
||||
var authBuilder = services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
|
||||
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
|
||||
});
|
||||
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(StellaOpsAuthenticationDefaults.AuthenticationScheme, _ => { });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static void CreateEcPrivateKey(string path)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
File.WriteAllText(path, ecdsa.ExportECPrivateKeyPem());
|
||||
}
|
||||
|
||||
private static void TryDeleteDirectory(string directory)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignored during cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingAuthEventSink : IAuthEventSink
|
||||
{
|
||||
private readonly List<AuthEventRecord> events = new();
|
||||
|
||||
public IReadOnlyList<AuthEventRecord> Events => events;
|
||||
|
||||
public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
events.Add(record);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Authority;
|
||||
using StellaOps.Authority.Tests.Infrastructure;
|
||||
using StellaOps.Authority.Vulnerability.Attachments;
|
||||
using StellaOps.Authority.Vulnerability.Workflow;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Tests.Vulnerability;
|
||||
|
||||
public sealed class VulnWorkflowTokenEndpointTests : IClassFixture<AuthorityWebApplicationFactory>
|
||||
{
|
||||
private readonly AuthorityWebApplicationFactory factory;
|
||||
private const string SigningEnabledKey = "STELLAOPS_AUTHORITY_AUTHORITY__SIGNING__ENABLED";
|
||||
private const string SigningActiveKeyIdKey = "STELLAOPS_AUTHORITY_AUTHORITY__SIGNING__ACTIVEKEYID";
|
||||
private const string SigningKeyPathKey = "STELLAOPS_AUTHORITY_AUTHORITY__SIGNING__KEYPATH";
|
||||
private const string SigningKeySourceKey = "STELLAOPS_AUTHORITY_AUTHORITY__SIGNING__KEYSOURCE";
|
||||
private const string SigningAlgorithmKey = "STELLAOPS_AUTHORITY_AUTHORITY__SIGNING__ALGORITHM";
|
||||
|
||||
public VulnWorkflowTokenEndpointTests(AuthorityWebApplicationFactory factory)
|
||||
{
|
||||
this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IssueAndVerifyWorkflowToken_SucceedsAndAudits()
|
||||
{
|
||||
var tempDir = Directory.CreateTempSubdirectory("workflow-token-success");
|
||||
var keyPath = Path.Combine(tempDir.FullName, "signing-key.pem");
|
||||
|
||||
try
|
||||
{
|
||||
CreateEcPrivateKey(keyPath);
|
||||
|
||||
using var env = new EnvironmentVariableScope(new[]
|
||||
{
|
||||
new KeyValuePair<string, string?>(SigningEnabledKey, "true"),
|
||||
new KeyValuePair<string, string?>(SigningActiveKeyIdKey, "workflow-key"),
|
||||
new KeyValuePair<string, string?>(SigningKeyPathKey, keyPath),
|
||||
new KeyValuePair<string, string?>(SigningKeySourceKey, "file"),
|
||||
new KeyValuePair<string, string?>(SigningAlgorithmKey, SignatureAlgorithms.Es256)
|
||||
});
|
||||
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T09:00:00Z"));
|
||||
|
||||
using var app = CreateSignedAuthorityApp(sink, timeProvider, "workflow-key", keyPath);
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.VulnOperate);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var issuePayload = new
|
||||
{
|
||||
tenant = "tenant-default",
|
||||
actions = new[] { "assign", "comment" },
|
||||
context = new Dictionary<string, string> { ["finding_id"] = "F-123" },
|
||||
nonce = "workflow-nonce-123456",
|
||||
expiresInSeconds = 600
|
||||
};
|
||||
|
||||
var issueResponse = await client.PostAsJsonAsync("/vuln/workflow/anti-forgery/issue", issuePayload);
|
||||
var issueBody = await issueResponse.Content.ReadAsStringAsync();
|
||||
Assert.True(issueResponse.StatusCode == HttpStatusCode.OK, $"Issue anti-forgery failed: {issueResponse.StatusCode} {issueBody}");
|
||||
|
||||
var issued = System.Text.Json.JsonSerializer.Deserialize<VulnWorkflowAntiForgeryIssueResponse>(
|
||||
issueBody,
|
||||
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
Assert.NotNull(issued);
|
||||
Assert.Equal("workflow-nonce-123456", issued!.Nonce);
|
||||
Assert.Contains("assign", issued.Actions);
|
||||
Assert.Contains("comment", issued.Actions);
|
||||
|
||||
var verifyPayload = new VulnWorkflowAntiForgeryVerifyRequest
|
||||
{
|
||||
Token = issued.Token,
|
||||
RequiredAction = "assign",
|
||||
Tenant = "tenant-default",
|
||||
Nonce = "workflow-nonce-123456"
|
||||
};
|
||||
|
||||
var verifyResponse = await client.PostAsJsonAsync("/vuln/workflow/anti-forgery/verify", verifyPayload);
|
||||
var verifyBody = await verifyResponse.Content.ReadAsStringAsync();
|
||||
Assert.True(verifyResponse.StatusCode == HttpStatusCode.OK, $"Verify anti-forgery failed: {verifyResponse.StatusCode} {verifyBody}");
|
||||
|
||||
var verified = System.Text.Json.JsonSerializer.Deserialize<VulnWorkflowAntiForgeryVerifyResponse>(
|
||||
verifyBody,
|
||||
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
Assert.NotNull(verified);
|
||||
Assert.Equal("tenant-default", verified!.Tenant);
|
||||
Assert.Equal("workflow-nonce-123456", verified.Nonce);
|
||||
|
||||
var issuedEvent = Assert.Single(sink.Events, evt => evt.EventType == "vuln.workflow.csrf.issued");
|
||||
Assert.Contains(issuedEvent.Properties, property => property.Name == "vuln.workflow.actor");
|
||||
|
||||
var verifiedEvent = Assert.Single(sink.Events, evt => evt.EventType == "vuln.workflow.csrf.verified");
|
||||
Assert.Contains(verifiedEvent.Properties, property => property.Name == "vuln.workflow.nonce" && property.Value.Value == "workflow-nonce-123456");
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IssueWorkflowToken_ReturnsBadRequest_WhenActionsMissing()
|
||||
{
|
||||
var tempDir = Directory.CreateTempSubdirectory("workflow-token-missing-actions");
|
||||
var keyPath = Path.Combine(tempDir.FullName, "signing-key.pem");
|
||||
|
||||
try
|
||||
{
|
||||
CreateEcPrivateKey(keyPath);
|
||||
|
||||
using var env = new EnvironmentVariableScope(new[]
|
||||
{
|
||||
new KeyValuePair<string, string?>(SigningEnabledKey, "true"),
|
||||
new KeyValuePair<string, string?>(SigningActiveKeyIdKey, "workflow-key"),
|
||||
new KeyValuePair<string, string?>(SigningKeyPathKey, keyPath),
|
||||
new KeyValuePair<string, string?>(SigningKeySourceKey, "file"),
|
||||
new KeyValuePair<string, string?>(SigningAlgorithmKey, SignatureAlgorithms.Es256)
|
||||
});
|
||||
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T09:10:00Z"));
|
||||
|
||||
using var app = CreateSignedAuthorityApp(sink, timeProvider, "workflow-key", keyPath);
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.VulnOperate);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var issuePayload = new
|
||||
{
|
||||
tenant = "tenant-default",
|
||||
actions = Array.Empty<string>()
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/vuln/workflow/anti-forgery/issue", issuePayload);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
|
||||
var error = await response.Content.ReadFromJsonAsync<Dictionary<string, string>>();
|
||||
Assert.NotNull(error);
|
||||
Assert.Equal("invalid_request", error!["error"]);
|
||||
Assert.Contains("action", error["message"], StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
Assert.DoesNotContain(sink.Events, evt => evt.EventType == "vuln.workflow.csrf.issued");
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyWorkflowToken_ReturnsBadRequest_WhenActionNotPermitted()
|
||||
{
|
||||
var tempDir = Directory.CreateTempSubdirectory("workflow-token-invalid-action");
|
||||
var keyPath = Path.Combine(tempDir.FullName, "signing-key.pem");
|
||||
|
||||
try
|
||||
{
|
||||
CreateEcPrivateKey(keyPath);
|
||||
|
||||
using var env = new EnvironmentVariableScope(new[]
|
||||
{
|
||||
new KeyValuePair<string, string?>(SigningEnabledKey, "true"),
|
||||
new KeyValuePair<string, string?>(SigningActiveKeyIdKey, "workflow-key"),
|
||||
new KeyValuePair<string, string?>(SigningKeyPathKey, keyPath),
|
||||
new KeyValuePair<string, string?>(SigningKeySourceKey, "file"),
|
||||
new KeyValuePair<string, string?>(SigningAlgorithmKey, SignatureAlgorithms.Es256)
|
||||
});
|
||||
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T09:20:00Z"));
|
||||
|
||||
using var app = CreateSignedAuthorityApp(sink, timeProvider, "workflow-key", keyPath);
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.VulnOperate);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var issuePayload = new
|
||||
{
|
||||
tenant = "tenant-default",
|
||||
actions = new[] { "assign" },
|
||||
nonce = "workflow-nonce-789012"
|
||||
};
|
||||
|
||||
var issueResponse = await client.PostAsJsonAsync("/vuln/workflow/anti-forgery/issue", issuePayload);
|
||||
Assert.Equal(HttpStatusCode.OK, issueResponse.StatusCode);
|
||||
var issued = await issueResponse.Content.ReadFromJsonAsync<VulnWorkflowAntiForgeryIssueResponse>();
|
||||
Assert.NotNull(issued);
|
||||
|
||||
var verifyPayload = new VulnWorkflowAntiForgeryVerifyRequest
|
||||
{
|
||||
Token = issued!.Token,
|
||||
RequiredAction = "close",
|
||||
Tenant = "tenant-default",
|
||||
Nonce = "workflow-nonce-789012"
|
||||
};
|
||||
|
||||
var verifyResponse = await client.PostAsJsonAsync("/vuln/workflow/anti-forgery/verify", verifyPayload);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, verifyResponse.StatusCode);
|
||||
|
||||
var error = await verifyResponse.Content.ReadFromJsonAsync<Dictionary<string, string>>();
|
||||
Assert.NotNull(error);
|
||||
Assert.Equal("invalid_token", error!["error"]);
|
||||
Assert.Contains("Token does not permit action", error["message"], StringComparison.Ordinal);
|
||||
|
||||
Assert.Single(sink.Events, evt => evt.EventType == "vuln.workflow.csrf.issued");
|
||||
Assert.DoesNotContain(sink.Events, evt => evt.EventType == "vuln.workflow.csrf.verified");
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IssueAndVerifyAttachmentToken_SucceedsAndAudits()
|
||||
{
|
||||
var tempDir = Directory.CreateTempSubdirectory("attachment-token-success");
|
||||
var keyPath = Path.Combine(tempDir.FullName, "attachment-key.pem");
|
||||
|
||||
try
|
||||
{
|
||||
CreateEcPrivateKey(keyPath);
|
||||
|
||||
using var env = new EnvironmentVariableScope(new[]
|
||||
{
|
||||
new KeyValuePair<string, string?>(SigningEnabledKey, "true"),
|
||||
new KeyValuePair<string, string?>(SigningActiveKeyIdKey, "attachment-key"),
|
||||
new KeyValuePair<string, string?>(SigningKeyPathKey, keyPath),
|
||||
new KeyValuePair<string, string?>(SigningKeySourceKey, "file"),
|
||||
new KeyValuePair<string, string?>(SigningAlgorithmKey, SignatureAlgorithms.Es256)
|
||||
});
|
||||
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T11:00:00Z"));
|
||||
|
||||
using var app = CreateSignedAuthorityApp(sink, timeProvider, "attachment-key", keyPath);
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.VulnInvestigate);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var issuePayload = new VulnAttachmentTokenIssueRequest
|
||||
{
|
||||
Tenant = "tenant-default",
|
||||
LedgerEventHash = "ledger-hash-001",
|
||||
AttachmentId = "attach-123",
|
||||
FindingId = "find-456",
|
||||
ContentHash = "sha256:abc123",
|
||||
ContentType = "application/pdf",
|
||||
Metadata = new Dictionary<string, string?> { ["origin"] = "vuln-workflow" }
|
||||
};
|
||||
|
||||
var issueResponse = await client.PostAsJsonAsync("/vuln/attachments/tokens/issue", issuePayload);
|
||||
Assert.Equal(HttpStatusCode.OK, issueResponse.StatusCode);
|
||||
var issued = await issueResponse.Content.ReadFromJsonAsync<VulnAttachmentTokenIssueResponse>();
|
||||
Assert.NotNull(issued);
|
||||
Assert.Equal("attach-123", issued!.AttachmentId);
|
||||
|
||||
var verifyPayload = new VulnAttachmentTokenVerifyRequest
|
||||
{
|
||||
Token = issued.Token,
|
||||
Tenant = "tenant-default",
|
||||
LedgerEventHash = "ledger-hash-001",
|
||||
AttachmentId = "attach-123"
|
||||
};
|
||||
|
||||
var verifyResponse = await client.PostAsJsonAsync("/vuln/attachments/tokens/verify", verifyPayload);
|
||||
Assert.Equal(HttpStatusCode.OK, verifyResponse.StatusCode);
|
||||
var verified = await verifyResponse.Content.ReadFromJsonAsync<VulnAttachmentTokenVerifyResponse>();
|
||||
Assert.NotNull(verified);
|
||||
Assert.Equal("ledger-hash-001", verified!.LedgerEventHash);
|
||||
|
||||
var issuedEvent = Assert.Single(sink.Events, evt => evt.EventType == "vuln.attachment.token.issued");
|
||||
Assert.Contains(issuedEvent.Properties, property => property.Name == "vuln.attachment.ledger_hash" && property.Value.Value == "ledger-hash-001");
|
||||
|
||||
var verifiedEvent = Assert.Single(sink.Events, evt => evt.EventType == "vuln.attachment.token.verified");
|
||||
Assert.Contains(verifiedEvent.Properties, property => property.Name == "vuln.attachment.ledger_hash" && property.Value.Value == "ledger-hash-001");
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAttachmentToken_ReturnsBadRequest_WhenLedgerMismatch()
|
||||
{
|
||||
var tempDir = Directory.CreateTempSubdirectory("attachment-token-ledger-mismatch");
|
||||
var keyPath = Path.Combine(tempDir.FullName, "attachment-key.pem");
|
||||
|
||||
try
|
||||
{
|
||||
CreateEcPrivateKey(keyPath);
|
||||
|
||||
using var env = new EnvironmentVariableScope(new[]
|
||||
{
|
||||
new KeyValuePair<string, string?>(SigningEnabledKey, "true"),
|
||||
new KeyValuePair<string, string?>(SigningActiveKeyIdKey, "attachment-key"),
|
||||
new KeyValuePair<string, string?>(SigningKeyPathKey, keyPath),
|
||||
new KeyValuePair<string, string?>(SigningKeySourceKey, "file"),
|
||||
new KeyValuePair<string, string?>(SigningAlgorithmKey, SignatureAlgorithms.Es256)
|
||||
});
|
||||
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T11:10:00Z"));
|
||||
|
||||
using var app = CreateSignedAuthorityApp(sink, timeProvider, "attachment-key", keyPath);
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.SchemeName);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Scopes", StellaOpsScopes.VulnInvestigate);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", "tenant-default");
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
|
||||
|
||||
var issuePayload = new VulnAttachmentTokenIssueRequest
|
||||
{
|
||||
Tenant = "tenant-default",
|
||||
LedgerEventHash = "ledger-hash-001",
|
||||
AttachmentId = "attach-123"
|
||||
};
|
||||
|
||||
var issueResponse = await client.PostAsJsonAsync("/vuln/attachments/tokens/issue", issuePayload);
|
||||
Assert.Equal(HttpStatusCode.OK, issueResponse.StatusCode);
|
||||
var issued = await issueResponse.Content.ReadFromJsonAsync<VulnAttachmentTokenIssueResponse>();
|
||||
Assert.NotNull(issued);
|
||||
|
||||
var verifyPayload = new VulnAttachmentTokenVerifyRequest
|
||||
{
|
||||
Token = issued!.Token,
|
||||
Tenant = "tenant-default",
|
||||
LedgerEventHash = "ledger-hash-999",
|
||||
AttachmentId = "attach-123"
|
||||
};
|
||||
|
||||
var verifyResponse = await client.PostAsJsonAsync("/vuln/attachments/tokens/verify", verifyPayload);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, verifyResponse.StatusCode);
|
||||
|
||||
var error = await verifyResponse.Content.ReadFromJsonAsync<Dictionary<string, string>>();
|
||||
Assert.NotNull(error);
|
||||
Assert.Equal("invalid_token", error!["error"]);
|
||||
Assert.Contains("ledger reference", error["message"], StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
Assert.Single(sink.Events, evt => evt.EventType == "vuln.attachment.token.issued");
|
||||
Assert.DoesNotContain(sink.Events, evt => evt.EventType == "vuln.attachment.token.verified");
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
private WebApplicationFactory<Program> CreateSignedAuthorityApp(
|
||||
RecordingAuthEventSink sink,
|
||||
FakeTimeProvider timeProvider,
|
||||
string signingKeyId,
|
||||
string signingKeyPath)
|
||||
{
|
||||
return factory.WithWebHostBuilder(host =>
|
||||
{
|
||||
host.ConfigureAppConfiguration((_, configuration) =>
|
||||
{
|
||||
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Signing:Enabled"] = "true",
|
||||
["Authority:Signing:ActiveKeyId"] = signingKeyId,
|
||||
["Authority:Signing:KeyPath"] = signingKeyPath,
|
||||
["Authority:Signing:KeySource"] = "file",
|
||||
["Authority:Signing:Algorithm"] = SignatureAlgorithms.Es256
|
||||
});
|
||||
});
|
||||
|
||||
host.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IAuthEventSink>();
|
||||
services.AddSingleton<IAuthEventSink>(sink);
|
||||
services.Replace(ServiceDescriptor.Singleton<TimeProvider>(timeProvider));
|
||||
services.PostConfigure<StellaOpsAuthorityOptions>(options =>
|
||||
{
|
||||
options.Signing.Enabled = true;
|
||||
options.Signing.ActiveKeyId = signingKeyId;
|
||||
options.Signing.KeyPath = signingKeyPath;
|
||||
options.Signing.KeySource = "file";
|
||||
options.Signing.Algorithm = SignatureAlgorithms.Es256;
|
||||
options.VulnerabilityExplorer.Workflow.AntiForgery.Enabled = true;
|
||||
options.VulnerabilityExplorer.Attachments.Enabled = true;
|
||||
});
|
||||
|
||||
var authBuilder = services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
|
||||
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
|
||||
});
|
||||
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(StellaOpsAuthenticationDefaults.AuthenticationScheme, _ => { });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static void CreateEcPrivateKey(string path)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
File.WriteAllText(path, ecdsa.ExportECPrivateKeyPem());
|
||||
}
|
||||
|
||||
private static void TryDeleteDirectory(string directory)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignored during cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingAuthEventSink : IAuthEventSink
|
||||
{
|
||||
private readonly List<AuthEventRecord> events = new();
|
||||
|
||||
public IReadOnlyList<AuthEventRecord> Events => events;
|
||||
|
||||
public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
events.Add(record);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,254 +1,254 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
|
||||
namespace StellaOps.Authority;
|
||||
|
||||
internal sealed class LegacyAuthDeprecationMiddleware
|
||||
{
|
||||
private const string LegacyEventType = "authority.api.legacy_endpoint";
|
||||
private const string SunsetHeaderName = "Sunset";
|
||||
|
||||
private static readonly IReadOnlyDictionary<PathString, PathString> LegacyEndpointMap =
|
||||
new Dictionary<PathString, PathString>(PathStringComparer.Instance)
|
||||
{
|
||||
[new PathString("/oauth/token")] = new PathString("/token"),
|
||||
[new PathString("/oauth/introspect")] = new PathString("/introspect"),
|
||||
[new PathString("/oauth/revoke")] = new PathString("/revoke")
|
||||
};
|
||||
|
||||
private readonly RequestDelegate next;
|
||||
private readonly AuthorityLegacyAuthEndpointOptions options;
|
||||
private readonly IAuthEventSink auditSink;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly ILogger<LegacyAuthDeprecationMiddleware> logger;
|
||||
|
||||
public LegacyAuthDeprecationMiddleware(
|
||||
RequestDelegate next,
|
||||
IOptions<StellaOpsAuthorityOptions> authorityOptions,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider clock,
|
||||
ILogger<LegacyAuthDeprecationMiddleware> logger)
|
||||
{
|
||||
this.next = next ?? throw new ArgumentNullException(nameof(next));
|
||||
if (authorityOptions is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(authorityOptions));
|
||||
}
|
||||
|
||||
options = authorityOptions.Value.ApiLifecycle.LegacyAuth ??
|
||||
throw new InvalidOperationException("Authority legacy auth endpoint options are not configured.");
|
||||
this.auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink));
|
||||
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
if (!options.Enabled)
|
||||
{
|
||||
await next(context).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryResolveLegacyPath(context.Request.Path, out var canonicalPath))
|
||||
{
|
||||
await next(context).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var originalPath = context.Request.Path;
|
||||
context.Request.Path = canonicalPath;
|
||||
|
||||
logger.LogInformation(
|
||||
"Legacy Authority endpoint {OriginalPath} invoked; routing to {CanonicalPath} and emitting deprecation headers.",
|
||||
originalPath,
|
||||
canonicalPath);
|
||||
|
||||
AppendDeprecationHeaders(context.Response);
|
||||
|
||||
await next(context).ConfigureAwait(false);
|
||||
|
||||
await EmitAuditAsync(context, originalPath, canonicalPath).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static bool TryResolveLegacyPath(PathString path, out PathString canonicalPath)
|
||||
{
|
||||
if (LegacyEndpointMap.TryGetValue(Normalize(path), out canonicalPath))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
canonicalPath = PathString.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static PathString Normalize(PathString value)
|
||||
{
|
||||
if (!value.HasValue)
|
||||
{
|
||||
return PathString.Empty;
|
||||
}
|
||||
|
||||
var trimmed = value.Value!.TrimEnd('/');
|
||||
return new PathString(trimmed.Length == 0 ? "/" : trimmed.ToLowerInvariant());
|
||||
}
|
||||
|
||||
private void AppendDeprecationHeaders(HttpResponse response)
|
||||
{
|
||||
if (response.HasStarted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var deprecation = FormatHttpDate(options.DeprecationDate);
|
||||
response.Headers["Deprecation"] = deprecation;
|
||||
|
||||
var sunset = FormatHttpDate(options.SunsetDate);
|
||||
response.Headers[SunsetHeaderName] = sunset;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.DocumentationUrl))
|
||||
{
|
||||
var linkValue = $"<{options.DocumentationUrl}>; rel=\"sunset\"";
|
||||
response.Headers.Append(HeaderNames.Link, linkValue);
|
||||
}
|
||||
|
||||
var warning = $"299 - \"Legacy Authority endpoint will be removed after {sunset}. Migrate to canonical endpoints before the sunset date.\"";
|
||||
response.Headers[HeaderNames.Warning] = warning;
|
||||
}
|
||||
|
||||
private async Task EmitAuditAsync(HttpContext context, PathString originalPath, PathString canonicalPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var correlation = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier;
|
||||
|
||||
var network = BuildNetwork(context);
|
||||
|
||||
var record = new AuthEventRecord
|
||||
{
|
||||
EventType = LegacyEventType,
|
||||
OccurredAt = clock.GetUtcNow(),
|
||||
CorrelationId = correlation,
|
||||
Outcome = AuthEventOutcome.Success,
|
||||
Reason = null,
|
||||
Subject = null,
|
||||
Client = null,
|
||||
Tenant = ClassifiedString.Empty,
|
||||
Project = ClassifiedString.Empty,
|
||||
Scopes = Array.Empty<string>(),
|
||||
Network = network,
|
||||
Properties = BuildProperties(
|
||||
("legacy.endpoint.original", originalPath.Value),
|
||||
("legacy.endpoint.canonical", canonicalPath.Value),
|
||||
("legacy.deprecation_at", options.DeprecationDate.ToString("O", CultureInfo.InvariantCulture)),
|
||||
("legacy.sunset_at", options.SunsetDate.ToString("O", CultureInfo.InvariantCulture)),
|
||||
("http.status_code", context.Response.StatusCode.ToString(CultureInfo.InvariantCulture)))
|
||||
};
|
||||
|
||||
await auditSink.WriteAsync(record, context.RequestAborted).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to emit legacy auth endpoint audit event.");
|
||||
}
|
||||
}
|
||||
|
||||
private static AuthEventNetwork? BuildNetwork(HttpContext context)
|
||||
{
|
||||
var remote = context.Connection.RemoteIpAddress?.ToString();
|
||||
var forwarded = context.Request.Headers["X-Forwarded-For"].ToString();
|
||||
var userAgent = context.Request.Headers.UserAgent.ToString();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(remote) &&
|
||||
string.IsNullOrWhiteSpace(forwarded) &&
|
||||
string.IsNullOrWhiteSpace(userAgent))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AuthEventNetwork
|
||||
{
|
||||
RemoteAddress = ClassifiedString.Personal(Normalize(remote)),
|
||||
ForwardedFor = ClassifiedString.Personal(Normalize(forwarded)),
|
||||
UserAgent = ClassifiedString.Personal(Normalize(userAgent))
|
||||
};
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
return trimmed.Length == 0 ? null : trimmed;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AuthEventProperty> BuildProperties(params (string Name, string? Value)[] entries)
|
||||
{
|
||||
if (entries.Length == 0)
|
||||
{
|
||||
return Array.Empty<AuthEventProperty>();
|
||||
}
|
||||
|
||||
var list = new List<AuthEventProperty>(entries.Length);
|
||||
foreach (var (name, value) in entries)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
list.Add(new AuthEventProperty
|
||||
{
|
||||
Name = name,
|
||||
Value = string.IsNullOrWhiteSpace(value)
|
||||
? ClassifiedString.Empty
|
||||
: ClassifiedString.Public(value)
|
||||
});
|
||||
}
|
||||
|
||||
return list.Count == 0 ? Array.Empty<AuthEventProperty>() : list;
|
||||
}
|
||||
|
||||
private static string FormatHttpDate(DateTimeOffset value)
|
||||
{
|
||||
return value.UtcDateTime.ToString("r", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private sealed class PathStringComparer : IEqualityComparer<PathString>
|
||||
{
|
||||
public static readonly PathStringComparer Instance = new();
|
||||
|
||||
public bool Equals(PathString x, PathString y)
|
||||
{
|
||||
return string.Equals(Normalize(x).Value, Normalize(y).Value, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public int GetHashCode(PathString obj)
|
||||
{
|
||||
return Normalize(obj).Value?.GetHashCode(StringComparison.Ordinal) ?? 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static class LegacyAuthDeprecationExtensions
|
||||
{
|
||||
public static IApplicationBuilder UseLegacyAuthDeprecation(this IApplicationBuilder app)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(app);
|
||||
return app.UseMiddleware<LegacyAuthDeprecationMiddleware>();
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
|
||||
namespace StellaOps.Authority;
|
||||
|
||||
internal sealed class LegacyAuthDeprecationMiddleware
|
||||
{
|
||||
private const string LegacyEventType = "authority.api.legacy_endpoint";
|
||||
private const string SunsetHeaderName = "Sunset";
|
||||
|
||||
private static readonly IReadOnlyDictionary<PathString, PathString> LegacyEndpointMap =
|
||||
new Dictionary<PathString, PathString>(PathStringComparer.Instance)
|
||||
{
|
||||
[new PathString("/oauth/token")] = new PathString("/token"),
|
||||
[new PathString("/oauth/introspect")] = new PathString("/introspect"),
|
||||
[new PathString("/oauth/revoke")] = new PathString("/revoke")
|
||||
};
|
||||
|
||||
private readonly RequestDelegate next;
|
||||
private readonly AuthorityLegacyAuthEndpointOptions options;
|
||||
private readonly IAuthEventSink auditSink;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly ILogger<LegacyAuthDeprecationMiddleware> logger;
|
||||
|
||||
public LegacyAuthDeprecationMiddleware(
|
||||
RequestDelegate next,
|
||||
IOptions<StellaOpsAuthorityOptions> authorityOptions,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider clock,
|
||||
ILogger<LegacyAuthDeprecationMiddleware> logger)
|
||||
{
|
||||
this.next = next ?? throw new ArgumentNullException(nameof(next));
|
||||
if (authorityOptions is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(authorityOptions));
|
||||
}
|
||||
|
||||
options = authorityOptions.Value.ApiLifecycle.LegacyAuth ??
|
||||
throw new InvalidOperationException("Authority legacy auth endpoint options are not configured.");
|
||||
this.auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink));
|
||||
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
if (!options.Enabled)
|
||||
{
|
||||
await next(context).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryResolveLegacyPath(context.Request.Path, out var canonicalPath))
|
||||
{
|
||||
await next(context).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var originalPath = context.Request.Path;
|
||||
context.Request.Path = canonicalPath;
|
||||
|
||||
logger.LogInformation(
|
||||
"Legacy Authority endpoint {OriginalPath} invoked; routing to {CanonicalPath} and emitting deprecation headers.",
|
||||
originalPath,
|
||||
canonicalPath);
|
||||
|
||||
AppendDeprecationHeaders(context.Response);
|
||||
|
||||
await next(context).ConfigureAwait(false);
|
||||
|
||||
await EmitAuditAsync(context, originalPath, canonicalPath).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static bool TryResolveLegacyPath(PathString path, out PathString canonicalPath)
|
||||
{
|
||||
if (LegacyEndpointMap.TryGetValue(Normalize(path), out canonicalPath))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
canonicalPath = PathString.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static PathString Normalize(PathString value)
|
||||
{
|
||||
if (!value.HasValue)
|
||||
{
|
||||
return PathString.Empty;
|
||||
}
|
||||
|
||||
var trimmed = value.Value!.TrimEnd('/');
|
||||
return new PathString(trimmed.Length == 0 ? "/" : trimmed.ToLowerInvariant());
|
||||
}
|
||||
|
||||
private void AppendDeprecationHeaders(HttpResponse response)
|
||||
{
|
||||
if (response.HasStarted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var deprecation = FormatHttpDate(options.DeprecationDate);
|
||||
response.Headers["Deprecation"] = deprecation;
|
||||
|
||||
var sunset = FormatHttpDate(options.SunsetDate);
|
||||
response.Headers[SunsetHeaderName] = sunset;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.DocumentationUrl))
|
||||
{
|
||||
var linkValue = $"<{options.DocumentationUrl}>; rel=\"sunset\"";
|
||||
response.Headers.Append(HeaderNames.Link, linkValue);
|
||||
}
|
||||
|
||||
var warning = $"299 - \"Legacy Authority endpoint will be removed after {sunset}. Migrate to canonical endpoints before the sunset date.\"";
|
||||
response.Headers[HeaderNames.Warning] = warning;
|
||||
}
|
||||
|
||||
private async Task EmitAuditAsync(HttpContext context, PathString originalPath, PathString canonicalPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var correlation = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier;
|
||||
|
||||
var network = BuildNetwork(context);
|
||||
|
||||
var record = new AuthEventRecord
|
||||
{
|
||||
EventType = LegacyEventType,
|
||||
OccurredAt = clock.GetUtcNow(),
|
||||
CorrelationId = correlation,
|
||||
Outcome = AuthEventOutcome.Success,
|
||||
Reason = null,
|
||||
Subject = null,
|
||||
Client = null,
|
||||
Tenant = ClassifiedString.Empty,
|
||||
Project = ClassifiedString.Empty,
|
||||
Scopes = Array.Empty<string>(),
|
||||
Network = network,
|
||||
Properties = BuildProperties(
|
||||
("legacy.endpoint.original", originalPath.Value),
|
||||
("legacy.endpoint.canonical", canonicalPath.Value),
|
||||
("legacy.deprecation_at", options.DeprecationDate.ToString("O", CultureInfo.InvariantCulture)),
|
||||
("legacy.sunset_at", options.SunsetDate.ToString("O", CultureInfo.InvariantCulture)),
|
||||
("http.status_code", context.Response.StatusCode.ToString(CultureInfo.InvariantCulture)))
|
||||
};
|
||||
|
||||
await auditSink.WriteAsync(record, context.RequestAborted).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to emit legacy auth endpoint audit event.");
|
||||
}
|
||||
}
|
||||
|
||||
private static AuthEventNetwork? BuildNetwork(HttpContext context)
|
||||
{
|
||||
var remote = context.Connection.RemoteIpAddress?.ToString();
|
||||
var forwarded = context.Request.Headers["X-Forwarded-For"].ToString();
|
||||
var userAgent = context.Request.Headers.UserAgent.ToString();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(remote) &&
|
||||
string.IsNullOrWhiteSpace(forwarded) &&
|
||||
string.IsNullOrWhiteSpace(userAgent))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AuthEventNetwork
|
||||
{
|
||||
RemoteAddress = ClassifiedString.Personal(Normalize(remote)),
|
||||
ForwardedFor = ClassifiedString.Personal(Normalize(forwarded)),
|
||||
UserAgent = ClassifiedString.Personal(Normalize(userAgent))
|
||||
};
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
return trimmed.Length == 0 ? null : trimmed;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AuthEventProperty> BuildProperties(params (string Name, string? Value)[] entries)
|
||||
{
|
||||
if (entries.Length == 0)
|
||||
{
|
||||
return Array.Empty<AuthEventProperty>();
|
||||
}
|
||||
|
||||
var list = new List<AuthEventProperty>(entries.Length);
|
||||
foreach (var (name, value) in entries)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
list.Add(new AuthEventProperty
|
||||
{
|
||||
Name = name,
|
||||
Value = string.IsNullOrWhiteSpace(value)
|
||||
? ClassifiedString.Empty
|
||||
: ClassifiedString.Public(value)
|
||||
});
|
||||
}
|
||||
|
||||
return list.Count == 0 ? Array.Empty<AuthEventProperty>() : list;
|
||||
}
|
||||
|
||||
private static string FormatHttpDate(DateTimeOffset value)
|
||||
{
|
||||
return value.UtcDateTime.ToString("r", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private sealed class PathStringComparer : IEqualityComparer<PathString>
|
||||
{
|
||||
public static readonly PathStringComparer Instance = new();
|
||||
|
||||
public bool Equals(PathString x, PathString y)
|
||||
{
|
||||
return string.Equals(Normalize(x).Value, Normalize(y).Value, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public int GetHashCode(PathString obj)
|
||||
{
|
||||
return Normalize(obj).Value?.GetHashCode(StringComparison.Ordinal) ?? 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static class LegacyAuthDeprecationExtensions
|
||||
{
|
||||
public static IApplicationBuilder UseLegacyAuthDeprecation(this IApplicationBuilder app)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(app);
|
||||
return app.UseMiddleware<LegacyAuthDeprecationMiddleware>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,181 +1,181 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Authority.Signing;
|
||||
|
||||
internal sealed class AuthorityJwksService
|
||||
{
|
||||
private const string CacheKey = "authority:jwks:current";
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly ICryptoProviderRegistry registry;
|
||||
private readonly ILogger<AuthorityJwksService> logger;
|
||||
private readonly IMemoryCache cache;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly StellaOpsAuthorityOptions authorityOptions;
|
||||
|
||||
public AuthorityJwksService(
|
||||
ICryptoProviderRegistry registry,
|
||||
ILogger<AuthorityJwksService> logger,
|
||||
IMemoryCache cache,
|
||||
TimeProvider timeProvider,
|
||||
IOptions<StellaOpsAuthorityOptions> authorityOptions)
|
||||
{
|
||||
this.registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
this.cache = cache ?? throw new ArgumentNullException(nameof(cache));
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
if (authorityOptions is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(authorityOptions));
|
||||
}
|
||||
|
||||
this.authorityOptions = authorityOptions.Value ?? throw new ArgumentNullException(nameof(authorityOptions));
|
||||
}
|
||||
|
||||
public AuthorityJwksResult Get()
|
||||
{
|
||||
if (cache.TryGetValue(CacheKey, out AuthorityJwksCacheEntry? cached) &&
|
||||
cached is not null &&
|
||||
cached.ExpiresAt > timeProvider.GetUtcNow())
|
||||
{
|
||||
return cached.Result;
|
||||
}
|
||||
|
||||
var response = new AuthorityJwksResponse(BuildKeys());
|
||||
var signingOptions = authorityOptions.Signing;
|
||||
var lifetime = signingOptions.JwksCacheLifetime > TimeSpan.Zero
|
||||
? signingOptions.JwksCacheLifetime
|
||||
: TimeSpan.FromMinutes(5);
|
||||
var expires = timeProvider.GetUtcNow().Add(lifetime);
|
||||
var etag = ComputeEtag(response, expires);
|
||||
var cacheControl = $"public, max-age={(int)lifetime.TotalSeconds}";
|
||||
|
||||
var result = new AuthorityJwksResult(response, etag, expires, cacheControl);
|
||||
var entry = new AuthorityJwksCacheEntry(result, expires);
|
||||
|
||||
cache.Set(CacheKey, entry, new MemoryCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = lifetime
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
public void Invalidate()
|
||||
{
|
||||
cache.Remove(CacheKey);
|
||||
}
|
||||
|
||||
private IReadOnlyCollection<JwksKeyEntry> BuildKeys()
|
||||
{
|
||||
var keys = new List<JwksKeyEntry>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var provider in registry.Providers)
|
||||
{
|
||||
foreach (var signingKey in provider.GetSigningKeys())
|
||||
{
|
||||
var keyId = signingKey.Reference.KeyId;
|
||||
if (!seen.Add(keyId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var signer = provider.GetSigner(signingKey.AlgorithmId, signingKey.Reference);
|
||||
var jwk = signer.ExportPublicJsonWebKey();
|
||||
var keyUse = signingKey.Metadata.TryGetValue("use", out var metadataUse) && !string.IsNullOrWhiteSpace(metadataUse)
|
||||
? metadataUse
|
||||
: jwk.Use;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(keyUse))
|
||||
{
|
||||
keyUse = "sig";
|
||||
}
|
||||
|
||||
var entry = new JwksKeyEntry
|
||||
{
|
||||
Kid = jwk.Kid,
|
||||
Kty = jwk.Kty,
|
||||
Use = keyUse,
|
||||
Alg = jwk.Alg,
|
||||
Crv = jwk.Crv,
|
||||
X = jwk.X,
|
||||
Y = jwk.Y,
|
||||
Status = signingKey.Metadata.TryGetValue("status", out var status) ? status : "active"
|
||||
};
|
||||
keys.Add(entry);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to export JWKS entry for key {KeyId}.", keyId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
keys.Sort(static (left, right) => string.Compare(left.Kid, right.Kid, StringComparison.Ordinal));
|
||||
return keys;
|
||||
}
|
||||
|
||||
private static string ComputeEtag(AuthorityJwksResponse response, DateTimeOffset expiresAt)
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(response, SerializerOptions);
|
||||
var buffer = Encoding.UTF8.GetBytes(payload + "|" + expiresAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture));
|
||||
var hash = SHA256.HashData(buffer);
|
||||
return $"\"{Convert.ToHexString(hash)}\"";
|
||||
}
|
||||
|
||||
private sealed record AuthorityJwksCacheEntry(AuthorityJwksResult Result, DateTimeOffset ExpiresAt);
|
||||
}
|
||||
|
||||
internal sealed record AuthorityJwksResponse([property: JsonPropertyName("keys")] IReadOnlyCollection<JwksKeyEntry> Keys);
|
||||
|
||||
internal sealed record AuthorityJwksResult(
|
||||
AuthorityJwksResponse Response,
|
||||
string ETag,
|
||||
DateTimeOffset ExpiresAt,
|
||||
string CacheControl);
|
||||
|
||||
internal sealed class JwksKeyEntry
|
||||
{
|
||||
[JsonPropertyName("kty")]
|
||||
public string? Kty { get; set; }
|
||||
|
||||
[JsonPropertyName("use")]
|
||||
public string? Use { get; set; }
|
||||
|
||||
[JsonPropertyName("kid")]
|
||||
public string? Kid { get; set; }
|
||||
|
||||
[JsonPropertyName("alg")]
|
||||
public string? Alg { get; set; }
|
||||
|
||||
[JsonPropertyName("crv")]
|
||||
public string? Crv { get; set; }
|
||||
|
||||
[JsonPropertyName("x")]
|
||||
public string? X { get; set; }
|
||||
|
||||
[JsonPropertyName("y")]
|
||||
public string? Y { get; set; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Status { get; set; }
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Authority.Signing;
|
||||
|
||||
internal sealed class AuthorityJwksService
|
||||
{
|
||||
private const string CacheKey = "authority:jwks:current";
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly ICryptoProviderRegistry registry;
|
||||
private readonly ILogger<AuthorityJwksService> logger;
|
||||
private readonly IMemoryCache cache;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly StellaOpsAuthorityOptions authorityOptions;
|
||||
|
||||
public AuthorityJwksService(
|
||||
ICryptoProviderRegistry registry,
|
||||
ILogger<AuthorityJwksService> logger,
|
||||
IMemoryCache cache,
|
||||
TimeProvider timeProvider,
|
||||
IOptions<StellaOpsAuthorityOptions> authorityOptions)
|
||||
{
|
||||
this.registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
this.cache = cache ?? throw new ArgumentNullException(nameof(cache));
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
if (authorityOptions is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(authorityOptions));
|
||||
}
|
||||
|
||||
this.authorityOptions = authorityOptions.Value ?? throw new ArgumentNullException(nameof(authorityOptions));
|
||||
}
|
||||
|
||||
public AuthorityJwksResult Get()
|
||||
{
|
||||
if (cache.TryGetValue(CacheKey, out AuthorityJwksCacheEntry? cached) &&
|
||||
cached is not null &&
|
||||
cached.ExpiresAt > timeProvider.GetUtcNow())
|
||||
{
|
||||
return cached.Result;
|
||||
}
|
||||
|
||||
var response = new AuthorityJwksResponse(BuildKeys());
|
||||
var signingOptions = authorityOptions.Signing;
|
||||
var lifetime = signingOptions.JwksCacheLifetime > TimeSpan.Zero
|
||||
? signingOptions.JwksCacheLifetime
|
||||
: TimeSpan.FromMinutes(5);
|
||||
var expires = timeProvider.GetUtcNow().Add(lifetime);
|
||||
var etag = ComputeEtag(response, expires);
|
||||
var cacheControl = $"public, max-age={(int)lifetime.TotalSeconds}";
|
||||
|
||||
var result = new AuthorityJwksResult(response, etag, expires, cacheControl);
|
||||
var entry = new AuthorityJwksCacheEntry(result, expires);
|
||||
|
||||
cache.Set(CacheKey, entry, new MemoryCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = lifetime
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
public void Invalidate()
|
||||
{
|
||||
cache.Remove(CacheKey);
|
||||
}
|
||||
|
||||
private IReadOnlyCollection<JwksKeyEntry> BuildKeys()
|
||||
{
|
||||
var keys = new List<JwksKeyEntry>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var provider in registry.Providers)
|
||||
{
|
||||
foreach (var signingKey in provider.GetSigningKeys())
|
||||
{
|
||||
var keyId = signingKey.Reference.KeyId;
|
||||
if (!seen.Add(keyId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var signer = provider.GetSigner(signingKey.AlgorithmId, signingKey.Reference);
|
||||
var jwk = signer.ExportPublicJsonWebKey();
|
||||
var keyUse = signingKey.Metadata.TryGetValue("use", out var metadataUse) && !string.IsNullOrWhiteSpace(metadataUse)
|
||||
? metadataUse
|
||||
: jwk.Use;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(keyUse))
|
||||
{
|
||||
keyUse = "sig";
|
||||
}
|
||||
|
||||
var entry = new JwksKeyEntry
|
||||
{
|
||||
Kid = jwk.Kid,
|
||||
Kty = jwk.Kty,
|
||||
Use = keyUse,
|
||||
Alg = jwk.Alg,
|
||||
Crv = jwk.Crv,
|
||||
X = jwk.X,
|
||||
Y = jwk.Y,
|
||||
Status = signingKey.Metadata.TryGetValue("status", out var status) ? status : "active"
|
||||
};
|
||||
keys.Add(entry);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to export JWKS entry for key {KeyId}.", keyId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
keys.Sort(static (left, right) => string.Compare(left.Kid, right.Kid, StringComparison.Ordinal));
|
||||
return keys;
|
||||
}
|
||||
|
||||
private static string ComputeEtag(AuthorityJwksResponse response, DateTimeOffset expiresAt)
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(response, SerializerOptions);
|
||||
var buffer = Encoding.UTF8.GetBytes(payload + "|" + expiresAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture));
|
||||
var hash = SHA256.HashData(buffer);
|
||||
return $"\"{Convert.ToHexString(hash)}\"";
|
||||
}
|
||||
|
||||
private sealed record AuthorityJwksCacheEntry(AuthorityJwksResult Result, DateTimeOffset ExpiresAt);
|
||||
}
|
||||
|
||||
internal sealed record AuthorityJwksResponse([property: JsonPropertyName("keys")] IReadOnlyCollection<JwksKeyEntry> Keys);
|
||||
|
||||
internal sealed record AuthorityJwksResult(
|
||||
AuthorityJwksResponse Response,
|
||||
string ETag,
|
||||
DateTimeOffset ExpiresAt,
|
||||
string CacheControl);
|
||||
|
||||
internal sealed class JwksKeyEntry
|
||||
{
|
||||
[JsonPropertyName("kty")]
|
||||
public string? Kty { get; set; }
|
||||
|
||||
[JsonPropertyName("use")]
|
||||
public string? Use { get; set; }
|
||||
|
||||
[JsonPropertyName("kid")]
|
||||
public string? Kid { get; set; }
|
||||
|
||||
[JsonPropertyName("alg")]
|
||||
public string? Alg { get; set; }
|
||||
|
||||
[JsonPropertyName("crv")]
|
||||
public string? Crv { get; set; }
|
||||
|
||||
[JsonPropertyName("x")]
|
||||
public string? X { get; set; }
|
||||
|
||||
[JsonPropertyName("y")]
|
||||
public string? Y { get; set; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Status { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,170 +1,170 @@
|
||||
# Authority Host Task Board — Epic 1: Aggregation-Only Contract
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-26: Rate limiter metadata/audit records now include tenants, password grant scopes/tenants enforced, token persistence + tests updated. Docs refresh tracked via AUTH-AOC-19-003.
|
||||
> 2025-10-27: Client credential ingestion scopes now require tenant assignment; access token validation backfills tenants and rejects cross-tenant mismatches with tests.
|
||||
> 2025-10-27: `dotnet test` blocked — Concelier build fails (`AdvisoryObservationQueryService` returns `ImmutableHashSet<string?>`), preventing Authority test suite run; waiting on Concelier fix before rerun.
|
||||
> 2025-10-26: Docs updated (`docs/11_AUTHORITY.md`, Concelier audit runbook, `docs/security/authority-scopes.md`); sample config highlights tenant-aware clients. Release notes + smoke verification pending (blocked on Concelier/Excititor smoke updates).
|
||||
> 2025-10-27: Scope catalogue aligned with `advisory:ingest/advisory:read/vex:ingest/vex:read`, `aoc:verify` pairing documented, console/CLI references refreshed, and `etc/authority.yaml.sample` updated to require read scopes for verification clients.
|
||||
> 2025-10-31: Client credentials and password grants now reject advisory/vex read or signals scopes without `aoc:verify`, enforce tenant assignment for `aoc:verify`, tag violations via `authority.aoc_scope_violation`, extend tests, and refresh scope catalogue docs/sample roles.
|
||||
|
||||
## Link-Not-Merge v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-29: Rejected legacy `concelier.merge` scope during client credential validation, removed it from known scope catalog, blocked discovery/issuance, added regression tests, and refreshed scope documentation.
|
||||
|
||||
## Policy Engine v2
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-26: Restricted `effective:write` to Policy Engine service identities with tenant requirement, registered full scope set, and tightened resource server default scope enforcement (unit tests pass).
|
||||
> 2025-10-26: Authority docs now detail policy scopes/service identity guardrails with checklist; `authority.yaml.sample` includes `properties.serviceIdentity` example.
|
||||
|
||||
## Graph Explorer v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
|
||||
## Policy Engine + Editor v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AUTH-POLICY-23-002 | BLOCKED (2025-10-29) | Authority Core & Security Guild | AUTH-POLICY-23-001 | Implement optional two-person rule for activation: require two distinct `policy:activate` approvals when configured; emit audit logs. | Activation endpoint enforces rule; audit logs contain approver IDs; tests cover 2-person path. |
|
||||
> Blocked: Policy Engine/Studio have not yet exposed activation workflow endpoints or approval payloads needed to enforce dual-control (`WEB-POLICY-23-002`, `POLICY-ENGINE-23-002`). Revisit once activation contract lands.
|
||||
| AUTH-POLICY-23-003 | BLOCKED (2025-10-29) | Authority Core & Docs Guild | AUTH-POLICY-23-001 | Update documentation and sample configs for policy roles, approval workflow, and signing requirements. | Docs updated with reviewer checklist; configuration examples validated. |
|
||||
> Blocked pending AUTH-POLICY-23-002 dual-approval implementation so docs can capture final activation behaviour.
|
||||
> 2025-10-27: Added `policy-cli` defaults to Authority config/secrets, refreshed CLI/CI documentation with the new scope bundle, recorded release migration guidance, and introduced `scripts/verify-policy-scopes.py` to guard against regressions.
|
||||
|
||||
## Graph & Vuln Explorer v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-27: Paused work after exploratory spike (scope enforcement still outstanding); no functional changes merged.
|
||||
|
||||
## Orchestrator Dashboard
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-31: Picked up during Console/Orchestrator alignment; focusing on scope catalog + tenant enforcement first.
|
||||
> 2025-10-31: `orch:read` added to scope catalogue and Authority runtime, Console defaults include the scope, `Orch.Viewer` role documented, and client-credential tests enforce tenant requirements.
|
||||
> 2025-10-27: Added `orch:operate` scope, enforced `operator_reason`/`operator_ticket` on token issuance, updated Authority configs/docs, and captured audit metadata for control actions.
|
||||
> 2025-10-28: Policy gateway + scanner now pass the expanded token client signature (`null` metadata by default), test stubs capture the optional parameters, and Policy Gateway/Scanner suites are green after fixing the Concelier storage build break.
|
||||
> 2025-10-28: Authority password-grant tests now hit the new constructors but still need updates to drop obsolete `IOptions` arguments before the suite can pass.
|
||||
| AUTH-ORCH-34-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-ORCH-33-001 | Introduce `Orch.Admin` role with quota/backfill scopes, enforce audit reason on quota changes, and update offline defaults/docs. | Admin role available; quotas/backfills require scope + reason; tests confirm tenant isolation; documentation updated. |
|
||||
> 2025-11-02: `orch:backfill` scope added with mandatory `backfill_reason`/`backfill_ticket`, client-credential validation and resource authorization paths emit audit fields, CLI picks up new configuration/env vars, and Authority docs/config samples updated for `Orch.Admin`.
|
||||
|
||||
## StellaOps Console (Sprint 23)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-29: Authorization code flow enabled with PKCE requirement, console client seeded in `authority.yaml.sample`, discovery docs updated, and console runbook guidance added.
|
||||
> 2025-10-31: Added `/console/tenants`, `/console/profile`, `/console/token/introspect` endpoints with tenant header filter, scope enforcement (`ui.read`, `authority:tenants.read`), and structured audit events. Console test harness covers success/mismatch cases.
|
||||
> 2025-10-28: `docs/security/console-security.md` drafted with PKCE + DPoP (120 s OpTok, 300 s fresh-auth) and scope table. Authority Core to confirm `/fresh-auth` semantics, token lifetimes, and scope bundles align before closing task.
|
||||
> 2025-10-31: Security guide expanded for `/console` endpoints & orchestrator scope, sample YAML annotated, ops runbook updated, and release note `docs/updates/2025-10-31-console-security-refresh.md` published.
|
||||
> 2025-10-31: Default access-token lifetime reduced to 120 s, console tests updated with dual auth schemes, docs/config/ops notes refreshed, release note logged.
|
||||
|
||||
## Policy Studio (Sprint 27)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-31: Added Policy Studio scope family (`policy:author/review/operate/audit`), updated OpenAPI + discovery headers, enforced tenant requirements in grant handlers, seeded new roles in Authority config/offline kit docs, and refreshed CLI/Console documentation + tests to validate the new catalogue.
|
||||
| AUTH-POLICY-27-002 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-POLICY-27-001, REGISTRY-API-27-007 | Provide attestation signing service bindings (OIDC token exchange, cosign integration) and enforce publish/promote scope checks, fresh-auth requirements, and audit logging. | Publish/promote requests require fresh auth + correct scopes; attestations signed with validated identity; audit logs enriched with digest + tenant; integration tests pass. |
|
||||
> Docs dependency: `DOCS-POLICY-27-009` awaiting signing guidance from this work.
|
||||
> 2025-11-02: Added `policy:publish`/`policy:promote` scopes with interactive-only enforcement, metadata parameters (`policy_reason`, `policy_ticket`, `policy_digest`), fresh-auth token validation, audit augmentations, and updated config/docs references.
|
||||
| AUTH-POLICY-27-003 | DONE (2025-11-04) | Authority Core & Docs Guild | AUTH-POLICY-27-001, AUTH-POLICY-27-002 | Update Authority configuration/docs for Policy Studio roles, signing policies, approval workflows, and CLI integration; include compliance checklist. | Docs merged; samples validated; governance checklist appended; release notes updated. |
|
||||
> 2025-11-04: Policy Studio roles/scopes documented across `docs/11_AUTHORITY.md`, sample configs, and OpenAPI; compliance checklist appended and Authority tests rerun to validate fresh-auth + scope enforcement.
|
||||
|
||||
## Exceptions v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-29: Added exception scopes + routing template options, enforced MFA requirement in password grant handlers, updated configuration samples.
|
||||
> 2025-10-31: Authority scopes/routing docs updated (`docs/security/authority-scopes.md`, `docs/11_AUTHORITY.md`, `docs/policy/exception-effects.md`), monitoring guide covers new MFA audit events, and `etc/authority.yaml.sample` now demonstrates exception clients/templates.
|
||||
|
||||
## Reachability v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-29: Signals scopes added with tenant + aoc:verify enforcement; sensors guided via SignalsUploader template; tests cover gating.
|
||||
|
||||
## Vulnerability Explorer (Sprint 29)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AUTH-VULN-29-001 | DONE (2025-11-03) | Authority Core & Security Guild | AUTH-POLICY-27-001 | Define Vuln Explorer scopes/roles (`vuln:view`, `vuln:investigate`, `vuln:operate`, `vuln:audit`) with ABAC attributes (env, owner, business_tier) and update discovery metadata/offline kit defaults. | Roles/scopes published; issuer templates updated; integration tests cover ABAC filters; docs refreshed. |
|
||||
| AUTH-VULN-29-002 | DONE (2025-11-03) | Authority Core & Security Guild | AUTH-VULN-29-001, LEDGER-29-002 | Enforce CSRF/anti-forgery tokens for workflow actions, sign attachment tokens, and record audit logs with ledger event hashes. | Workflow calls require valid tokens; audit logs include ledger references; security tests cover token expiry/abuse. |
|
||||
| AUTH-VULN-29-003 | DONE (2025-11-04) | Authority Core & Docs Guild | AUTH-VULN-29-001..002 | Update security docs/config samples for Vuln Explorer roles, ABAC policies, attachment signing, and ledger verification guidance. | Docs merged with compliance checklist; configuration examples validated; release notes updated. |
|
||||
> 2025-11-03: Vuln workflow CSRF + attachment token services live with audit enrichment and negative-path tests. Awaiting completion of full Authority suite run after repository-wide build finishes.
|
||||
> 2025-11-04: Verified Vuln Explorer RBAC/ABAC coverage in Authority docs/security guides, attachment token guidance, and offline samples; Authority tests rerun confirming ledger-token + anti-forgery behaviours.
|
||||
|
||||
## Advisory AI (Sprint 31)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AUTH-AIAI-31-001 | DONE (2025-11-01) | Authority Core & Security Guild | AUTH-VULN-29-001 | Define Advisory AI scopes (`advisory-ai:view`, `advisory-ai:operate`, `advisory-ai:admin`) and remote inference toggles; update discovery metadata/offline defaults. | Scopes/flags published; integration tests cover RBAC + opt-in settings; docs updated. |
|
||||
| AUTH-AIAI-31-002 | DONE (2025-11-01) | Authority Core & Security Guild | AUTH-AIAI-31-001, AIAI-31-006 | Enforce anonymized prompt logging, tenant consent for remote inference, and audit logging of assistant tasks. | Logging/audit flows verified; privacy review passed; docs updated. |
|
||||
|
||||
## Export Center
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
|
||||
## Notifications Studio
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AUTH-NOTIFY-38-001 | DONE (2025-11-01) | Authority Core & Security Guild | — | Define `Notify.Viewer`, `Notify.Operator`, `Notify.Admin` scopes/roles, update discovery metadata, offline defaults, and issuer templates. | Scopes available; metadata updated; tests ensure enforcement; offline kit defaults refreshed. |
|
||||
| AUTH-NOTIFY-40-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-NOTIFY-38-001, WEB-NOTIFY-40-001 | Implement signed ack token key rotation, webhook allowlists, admin-only escalation settings, and audit logging of ack actions. | Ack tokens signed/rotated; webhook allowlists enforced; admin enforcement validated; audit logs capture ack/resolution. |
|
||||
> 2025-11-02: `/notify/ack-tokens/rotate` exposed (notify.admin), emits `notify.ack.key_rotated|notify.ack.key_rotation_failed`, and DSSE rotation tests cover allowlist + scope enforcement.
|
||||
| AUTH-NOTIFY-42-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-NOTIFY-40-001 | Investigate ack token rotation 500 errors (test Rotate_ReturnsBadRequest_WhenKeyIdMissing_AndAuditsFailure still failing). Capture logs, identify root cause, and patch handler. | Failure mode understood; fix merged; regression test passes. |
|
||||
> 2025-11-02: Aliased `StellaOpsBearer` to the test auth handler, corrected bootstrap `/notifications/ack-tokens/rotate` defaults, and validated `Rotate_ReturnsBadRequest_WhenKeyIdMissing_AndAuditsFailure` via targeted `dotnet test`.
|
||||
|
||||
|
||||
## CLI Parity & Task Packs
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AUTH-PACKS-41-001 | DONE (2025-11-04) | Authority Core & Security Guild | AUTH-AOC-19-001 | Define CLI SSO profiles and pack scopes (`Packs.Read`, `Packs.Write`, `Packs.Run`, `Packs.Approve`), update discovery metadata, offline defaults, and issuer templates. | Scopes available; metadata updated; tests ensure enforcement; offline kit templates refreshed. |
|
||||
> 2025-11-02: Added Pack scope policies, Authority role defaults, and CLI profile guidance covering operator/publisher/approver flows.
|
||||
> 2025-11-02: Shared OpenSSL 1.1 shim feeds Authority & Signals Mongo2Go harnesses so pack scope coverage keeps running on OpenSSL 3 hosts (AUTH-PACKS-41-001).
|
||||
> 2025-11-04: Discovery metadata/OpenAPI advertise packs scopes, configs/offline kit templates bundle new roles, and Authority tests re-run to validate tenant gating for `packs.*`.
|
||||
| AUTH-PACKS-43-001 | BLOCKED (2025-10-27) | Authority Core & Security Guild | AUTH-PACKS-41-001, TASKRUN-42-001, ORCH-SVC-42-101 | Enforce pack signing policies, approval RBAC checks, CLI CI token scopes, and audit logging for approvals. | Signing policies enforced; approvals require correct roles; CI token scope tests pass; audit logs recorded. |
|
||||
> Blocked: Task Runner approval APIs (`ORCH-SVC-42-101`, `TASKRUN-42-001`) still outstanding. Pack scope catalog (AUTH-PACKS-41-001) landed 2025-11-04; resume once execution/approval contracts are published.
|
||||
|
||||
## Authority-Backed Scopes & Tenancy (Epic 14)
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-28: Tidied advisory raw idempotency migration to avoid LINQ-on-`BsonValue` (explicit array copy) while continuing duplicate guardrail validation; scoped scanner/policy token call sites updated to honor new metadata parameter.
|
||||
| AUTH-TEN-49-001 | DONE (2025-11-04) | Authority Core & Security Guild | AUTH-TEN-47-001 | Implement service accounts & delegation tokens (`act` chain), per-tenant quotas, audit stream of auth decisions, and revocation APIs. | Service tokens minted with scopes/TTL; delegation logged; quotas configurable; audit stream live; docs updated. |
|
||||
> 2025-11-02: Authority bootstrap test harness now seeds service accounts via AuthorityDelegation options; `/internal/service-accounts` endpoints validated with targeted vstest run.
|
||||
> 2025-11-02: Added Mongo service-account store, seeded options/collection initializers, token persistence metadata (`tokenKind`, `serviceAccountId`, `actorChain`), and docs/config samples. Introduced quota checks + tests covering service account issuance and persistence.
|
||||
> 2025-11-02: Documented bootstrap service-account admin APIs in `docs/11_AUTHORITY.md`, noting API key requirements and stable upsert behaviour.
|
||||
> 2025-11-03: Seeded explicit enabled service-account fixtures for integration tests and reran `StellaOps.Authority.Tests` to greenlight `/internal/service-accounts` listing + revocation scenarios.
|
||||
> 2025-11-04: Confirmed service-account docs/config examples, quota tuning, and audit stream wiring; Authority suite re-executed to cover issuance/listing/revocation flows.
|
||||
|
||||
## Observability & Forensics (Epic 15)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AUTH-OBS-50-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-AOC-19-001 | Introduce scopes `obs:read`, `timeline:read`, `timeline:write`, `evidence:create`, `evidence:read`, `evidence:hold`, `attest:read`, and `obs:incident` (all tenant-scoped). Update discovery metadata, offline defaults, and scope grammar docs. | Scopes exposed via metadata; issuer templates updated; offline kit seeded; integration tests cover new scopes. |
|
||||
| AUTH-OBS-52-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-OBS-50-001, TIMELINE-OBS-52-003, EVID-OBS-53-003 | Configure resource server policies for Timeline Indexer, Evidence Locker, Exporter, and Observability APIs enforcing new scopes + tenant claims. Emit audit events including scope usage and trace IDs. | Policies deployed; unauthorized access blocked; audit logs prove scope usage; contract tests updated. |
|
||||
| AUTH-OBS-55-001 | DONE (2025-11-02) | Authority Core & Security Guild, Ops Guild | AUTH-OBS-50-001, WEB-OBS-55-001 | Harden incident mode authorization: require `obs:incident` scope + fresh auth, log activation reason, and expose verification endpoint for auditors. Update docs/runbooks. | Incident activate/deactivate requires scope; audit entries logged; docs updated with imposed rule reminder. |
|
||||
|
||||
## Air-Gapped Mode (Epic 16)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AUTH-AIRGAP-56-001 | DONE (2025-11-04) | Authority Core & Security Guild | AIRGAP-CTL-56-001 | Provision new scopes (`airgap:seal`, `airgap:import`, `airgap:status:read`) in configuration metadata, offline kit defaults, and issuer templates. | Scopes exposed in discovery docs; offline kit updated; integration tests cover issuance. |
|
||||
| AUTH-AIRGAP-56-002 | DONE (2025-11-04) | Authority Core & Security Guild | AUTH-AIRGAP-56-001, AIRGAP-IMP-58-001 | Audit import actions with actor, tenant, bundle ID, and trace ID; expose `/authority/audit/airgap` endpoint. | Audit records persisted; endpoint paginates results; tests cover RBAC + filtering. |
|
||||
> 2025-11-04: Airgap scope constants are wired through discovery metadata, `etc/authority.yaml.sample`, and offline kit docs; scope issuance tests executed via `dotnet test`.
|
||||
> 2025-11-04: `/authority/audit/airgap` API persists tenant-scoped audit entries with pagination and authorization guards validated by the Authority integration suite (187 tests).
|
||||
| AUTH-AIRGAP-57-001 | BLOCKED (2025-11-01) | Authority Core & Security Guild, DevOps Guild | AUTH-AIRGAP-56-001, DEVOPS-AIRGAP-57-002 | Enforce sealed-mode CI gating by refusing token issuance when declared sealed install lacks sealing confirmation. | Awaiting clarified sealed-confirmation contract and configuration structure before implementation. |
|
||||
> 2025-11-01: AUTH-AIRGAP-57-001 blocked pending guidance on sealed-confirmation contract and configuration expectations before gating changes (Authority Core & Security Guild, DevOps Guild).
|
||||
|
||||
## SDKs & OpenAPI (Epic 17)
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-28: Auth OpenAPI authored at `src/Api/StellaOps.Api.OpenApi/authority/openapi.yaml` covering `/token`, `/introspect`, `/revoke`, `/jwks`, scope catalog, and error envelopes; parsed via PyYAML sanity check and referenced in Epic 17 docs.
|
||||
> 2025-10-28: Added `/.well-known/openapi` endpoint wiring cached spec metadata, YAML/JSON negotiation, HTTP cache headers, and tests verifying ETag + Accept handling. Authority spec (`src/Api/StellaOps.Api.OpenApi/authority/openapi.yaml`) now includes grant/scope extensions.
|
||||
| AUTH-OAS-62-001 | DONE (2025-11-02) | Authority Core & Security Guild, SDK Generator Guild | AUTH-OAS-61-001, SDKGEN-63-001 | Provide SDK helpers for OAuth2/PAT flows, tenancy override header; add integration tests. | SDKs expose auth helpers; tests cover token issuance; docs updated. |
|
||||
> 2025-11-02: `AddStellaOpsApiAuthentication` shipped (OAuth2 + PAT), tenant header injection added, and client tests updated for caching behaviour.
|
||||
| AUTH-OAS-63-001 | DONE (2025-11-02) | Authority Core & Security Guild, API Governance Guild | APIGOV-63-001 | Emit deprecation headers and notifications for legacy auth endpoints. | Headers emitted; notifications verified; migration guide published. |
|
||||
> 2025-11-02: AUTH-OAS-63-001 completed — legacy OAuth shims emit Deprecation/Sunset/Warning headers, audit events captured, and migration guide published (Authority Core & Security Guild, API Governance Guild).
|
||||
# Authority Host Task Board — Epic 1: Aggregation-Only Contract
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-26: Rate limiter metadata/audit records now include tenants, password grant scopes/tenants enforced, token persistence + tests updated. Docs refresh tracked via AUTH-AOC-19-003.
|
||||
> 2025-10-27: Client credential ingestion scopes now require tenant assignment; access token validation backfills tenants and rejects cross-tenant mismatches with tests.
|
||||
> 2025-10-27: `dotnet test` blocked — Concelier build fails (`AdvisoryObservationQueryService` returns `ImmutableHashSet<string?>`), preventing Authority test suite run; waiting on Concelier fix before rerun.
|
||||
> 2025-10-26: Docs updated (`docs/11_AUTHORITY.md`, Concelier audit runbook, `docs/security/authority-scopes.md`); sample config highlights tenant-aware clients. Release notes + smoke verification pending (blocked on Concelier/Excititor smoke updates).
|
||||
> 2025-10-27: Scope catalogue aligned with `advisory:ingest/advisory:read/vex:ingest/vex:read`, `aoc:verify` pairing documented, console/CLI references refreshed, and `etc/authority.yaml.sample` updated to require read scopes for verification clients.
|
||||
> 2025-10-31: Client credentials and password grants now reject advisory/vex read or signals scopes without `aoc:verify`, enforce tenant assignment for `aoc:verify`, tag violations via `authority.aoc_scope_violation`, extend tests, and refresh scope catalogue docs/sample roles.
|
||||
|
||||
## Link-Not-Merge v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-29: Rejected legacy `concelier.merge` scope during client credential validation, removed it from known scope catalog, blocked discovery/issuance, added regression tests, and refreshed scope documentation.
|
||||
|
||||
## Policy Engine v2
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-26: Restricted `effective:write` to Policy Engine service identities with tenant requirement, registered full scope set, and tightened resource server default scope enforcement (unit tests pass).
|
||||
> 2025-10-26: Authority docs now detail policy scopes/service identity guardrails with checklist; `authority.yaml.sample` includes `properties.serviceIdentity` example.
|
||||
|
||||
## Graph Explorer v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
|
||||
## Policy Engine + Editor v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AUTH-POLICY-23-002 | BLOCKED (2025-10-29) | Authority Core & Security Guild | AUTH-POLICY-23-001 | Implement optional two-person rule for activation: require two distinct `policy:activate` approvals when configured; emit audit logs. | Activation endpoint enforces rule; audit logs contain approver IDs; tests cover 2-person path. |
|
||||
> Blocked: Policy Engine/Studio have not yet exposed activation workflow endpoints or approval payloads needed to enforce dual-control (`WEB-POLICY-23-002`, `POLICY-ENGINE-23-002`). Revisit once activation contract lands.
|
||||
| AUTH-POLICY-23-003 | BLOCKED (2025-10-29) | Authority Core & Docs Guild | AUTH-POLICY-23-001 | Update documentation and sample configs for policy roles, approval workflow, and signing requirements. | Docs updated with reviewer checklist; configuration examples validated. |
|
||||
> Blocked pending AUTH-POLICY-23-002 dual-approval implementation so docs can capture final activation behaviour.
|
||||
> 2025-10-27: Added `policy-cli` defaults to Authority config/secrets, refreshed CLI/CI documentation with the new scope bundle, recorded release migration guidance, and introduced `scripts/verify-policy-scopes.py` to guard against regressions.
|
||||
|
||||
## Graph & Vuln Explorer v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-27: Paused work after exploratory spike (scope enforcement still outstanding); no functional changes merged.
|
||||
|
||||
## Orchestrator Dashboard
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-31: Picked up during Console/Orchestrator alignment; focusing on scope catalog + tenant enforcement first.
|
||||
> 2025-10-31: `orch:read` added to scope catalogue and Authority runtime, Console defaults include the scope, `Orch.Viewer` role documented, and client-credential tests enforce tenant requirements.
|
||||
> 2025-10-27: Added `orch:operate` scope, enforced `operator_reason`/`operator_ticket` on token issuance, updated Authority configs/docs, and captured audit metadata for control actions.
|
||||
> 2025-10-28: Policy gateway + scanner now pass the expanded token client signature (`null` metadata by default), test stubs capture the optional parameters, and Policy Gateway/Scanner suites are green after fixing the Concelier storage build break.
|
||||
> 2025-10-28: Authority password-grant tests now hit the new constructors but still need updates to drop obsolete `IOptions` arguments before the suite can pass.
|
||||
| AUTH-ORCH-34-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-ORCH-33-001 | Introduce `Orch.Admin` role with quota/backfill scopes, enforce audit reason on quota changes, and update offline defaults/docs. | Admin role available; quotas/backfills require scope + reason; tests confirm tenant isolation; documentation updated. |
|
||||
> 2025-11-02: `orch:backfill` scope added with mandatory `backfill_reason`/`backfill_ticket`, client-credential validation and resource authorization paths emit audit fields, CLI picks up new configuration/env vars, and Authority docs/config samples updated for `Orch.Admin`.
|
||||
|
||||
## StellaOps Console (Sprint 23)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-29: Authorization code flow enabled with PKCE requirement, console client seeded in `authority.yaml.sample`, discovery docs updated, and console runbook guidance added.
|
||||
> 2025-10-31: Added `/console/tenants`, `/console/profile`, `/console/token/introspect` endpoints with tenant header filter, scope enforcement (`ui.read`, `authority:tenants.read`), and structured audit events. Console test harness covers success/mismatch cases.
|
||||
> 2025-10-28: `docs/security/console-security.md` drafted with PKCE + DPoP (120 s OpTok, 300 s fresh-auth) and scope table. Authority Core to confirm `/fresh-auth` semantics, token lifetimes, and scope bundles align before closing task.
|
||||
> 2025-10-31: Security guide expanded for `/console` endpoints & orchestrator scope, sample YAML annotated, ops runbook updated, and release note `docs/updates/2025-10-31-console-security-refresh.md` published.
|
||||
> 2025-10-31: Default access-token lifetime reduced to 120 s, console tests updated with dual auth schemes, docs/config/ops notes refreshed, release note logged.
|
||||
|
||||
## Policy Studio (Sprint 27)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-31: Added Policy Studio scope family (`policy:author/review/operate/audit`), updated OpenAPI + discovery headers, enforced tenant requirements in grant handlers, seeded new roles in Authority config/offline kit docs, and refreshed CLI/Console documentation + tests to validate the new catalogue.
|
||||
| AUTH-POLICY-27-002 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-POLICY-27-001, REGISTRY-API-27-007 | Provide attestation signing service bindings (OIDC token exchange, cosign integration) and enforce publish/promote scope checks, fresh-auth requirements, and audit logging. | Publish/promote requests require fresh auth + correct scopes; attestations signed with validated identity; audit logs enriched with digest + tenant; integration tests pass. |
|
||||
> Docs dependency: `DOCS-POLICY-27-009` awaiting signing guidance from this work.
|
||||
> 2025-11-02: Added `policy:publish`/`policy:promote` scopes with interactive-only enforcement, metadata parameters (`policy_reason`, `policy_ticket`, `policy_digest`), fresh-auth token validation, audit augmentations, and updated config/docs references.
|
||||
| AUTH-POLICY-27-003 | DONE (2025-11-04) | Authority Core & Docs Guild | AUTH-POLICY-27-001, AUTH-POLICY-27-002 | Update Authority configuration/docs for Policy Studio roles, signing policies, approval workflows, and CLI integration; include compliance checklist. | Docs merged; samples validated; governance checklist appended; release notes updated. |
|
||||
> 2025-11-04: Policy Studio roles/scopes documented across `docs/11_AUTHORITY.md`, sample configs, and OpenAPI; compliance checklist appended and Authority tests rerun to validate fresh-auth + scope enforcement.
|
||||
|
||||
## Exceptions v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-29: Added exception scopes + routing template options, enforced MFA requirement in password grant handlers, updated configuration samples.
|
||||
> 2025-10-31: Authority scopes/routing docs updated (`docs/security/authority-scopes.md`, `docs/11_AUTHORITY.md`, `docs/policy/exception-effects.md`), monitoring guide covers new MFA audit events, and `etc/authority.yaml.sample` now demonstrates exception clients/templates.
|
||||
|
||||
## Reachability v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-29: Signals scopes added with tenant + aoc:verify enforcement; sensors guided via SignalsUploader template; tests cover gating.
|
||||
|
||||
## Vulnerability Explorer (Sprint 29)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AUTH-VULN-29-001 | DONE (2025-11-03) | Authority Core & Security Guild | AUTH-POLICY-27-001 | Define Vuln Explorer scopes/roles (`vuln:view`, `vuln:investigate`, `vuln:operate`, `vuln:audit`) with ABAC attributes (env, owner, business_tier) and update discovery metadata/offline kit defaults. | Roles/scopes published; issuer templates updated; integration tests cover ABAC filters; docs refreshed. |
|
||||
| AUTH-VULN-29-002 | DONE (2025-11-03) | Authority Core & Security Guild | AUTH-VULN-29-001, LEDGER-29-002 | Enforce CSRF/anti-forgery tokens for workflow actions, sign attachment tokens, and record audit logs with ledger event hashes. | Workflow calls require valid tokens; audit logs include ledger references; security tests cover token expiry/abuse. |
|
||||
| AUTH-VULN-29-003 | DONE (2025-11-04) | Authority Core & Docs Guild | AUTH-VULN-29-001..002 | Update security docs/config samples for Vuln Explorer roles, ABAC policies, attachment signing, and ledger verification guidance. | Docs merged with compliance checklist; configuration examples validated; release notes updated. |
|
||||
> 2025-11-03: Vuln workflow CSRF + attachment token services live with audit enrichment and negative-path tests. Awaiting completion of full Authority suite run after repository-wide build finishes.
|
||||
> 2025-11-04: Verified Vuln Explorer RBAC/ABAC coverage in Authority docs/security guides, attachment token guidance, and offline samples; Authority tests rerun confirming ledger-token + anti-forgery behaviours.
|
||||
|
||||
## Advisory AI (Sprint 31)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AUTH-AIAI-31-001 | DONE (2025-11-01) | Authority Core & Security Guild | AUTH-VULN-29-001 | Define Advisory AI scopes (`advisory-ai:view`, `advisory-ai:operate`, `advisory-ai:admin`) and remote inference toggles; update discovery metadata/offline defaults. | Scopes/flags published; integration tests cover RBAC + opt-in settings; docs updated. |
|
||||
| AUTH-AIAI-31-002 | DONE (2025-11-01) | Authority Core & Security Guild | AUTH-AIAI-31-001, AIAI-31-006 | Enforce anonymized prompt logging, tenant consent for remote inference, and audit logging of assistant tasks. | Logging/audit flows verified; privacy review passed; docs updated. |
|
||||
|
||||
## Export Center
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
|
||||
## Notifications Studio
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AUTH-NOTIFY-38-001 | DONE (2025-11-01) | Authority Core & Security Guild | — | Define `Notify.Viewer`, `Notify.Operator`, `Notify.Admin` scopes/roles, update discovery metadata, offline defaults, and issuer templates. | Scopes available; metadata updated; tests ensure enforcement; offline kit defaults refreshed. |
|
||||
| AUTH-NOTIFY-40-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-NOTIFY-38-001, WEB-NOTIFY-40-001 | Implement signed ack token key rotation, webhook allowlists, admin-only escalation settings, and audit logging of ack actions. | Ack tokens signed/rotated; webhook allowlists enforced; admin enforcement validated; audit logs capture ack/resolution. |
|
||||
> 2025-11-02: `/notify/ack-tokens/rotate` exposed (notify.admin), emits `notify.ack.key_rotated|notify.ack.key_rotation_failed`, and DSSE rotation tests cover allowlist + scope enforcement.
|
||||
| AUTH-NOTIFY-42-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-NOTIFY-40-001 | Investigate ack token rotation 500 errors (test Rotate_ReturnsBadRequest_WhenKeyIdMissing_AndAuditsFailure still failing). Capture logs, identify root cause, and patch handler. | Failure mode understood; fix merged; regression test passes. |
|
||||
> 2025-11-02: Aliased `StellaOpsBearer` to the test auth handler, corrected bootstrap `/notifications/ack-tokens/rotate` defaults, and validated `Rotate_ReturnsBadRequest_WhenKeyIdMissing_AndAuditsFailure` via targeted `dotnet test`.
|
||||
|
||||
|
||||
## CLI Parity & Task Packs
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AUTH-PACKS-41-001 | DONE (2025-11-04) | Authority Core & Security Guild | AUTH-AOC-19-001 | Define CLI SSO profiles and pack scopes (`Packs.Read`, `Packs.Write`, `Packs.Run`, `Packs.Approve`), update discovery metadata, offline defaults, and issuer templates. | Scopes available; metadata updated; tests ensure enforcement; offline kit templates refreshed. |
|
||||
> 2025-11-02: Added Pack scope policies, Authority role defaults, and CLI profile guidance covering operator/publisher/approver flows.
|
||||
> 2025-11-02: Shared OpenSSL 1.1 shim feeds Authority & Signals Mongo2Go harnesses so pack scope coverage keeps running on OpenSSL 3 hosts (AUTH-PACKS-41-001).
|
||||
> 2025-11-04: Discovery metadata/OpenAPI advertise packs scopes, configs/offline kit templates bundle new roles, and Authority tests re-run to validate tenant gating for `packs.*`.
|
||||
| AUTH-PACKS-43-001 | BLOCKED (2025-10-27) | Authority Core & Security Guild | AUTH-PACKS-41-001, TASKRUN-42-001, ORCH-SVC-42-101 | Enforce pack signing policies, approval RBAC checks, CLI CI token scopes, and audit logging for approvals. | Signing policies enforced; approvals require correct roles; CI token scope tests pass; audit logs recorded. |
|
||||
> Blocked: Task Runner approval APIs (`ORCH-SVC-42-101`, `TASKRUN-42-001`) still outstanding. Pack scope catalog (AUTH-PACKS-41-001) landed 2025-11-04; resume once execution/approval contracts are published.
|
||||
|
||||
## Authority-Backed Scopes & Tenancy (Epic 14)
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-28: Tidied advisory raw idempotency migration to avoid LINQ-on-`BsonValue` (explicit array copy) while continuing duplicate guardrail validation; scoped scanner/policy token call sites updated to honor new metadata parameter.
|
||||
| AUTH-TEN-49-001 | DONE (2025-11-04) | Authority Core & Security Guild | AUTH-TEN-47-001 | Implement service accounts & delegation tokens (`act` chain), per-tenant quotas, audit stream of auth decisions, and revocation APIs. | Service tokens minted with scopes/TTL; delegation logged; quotas configurable; audit stream live; docs updated. |
|
||||
> 2025-11-02: Authority bootstrap test harness now seeds service accounts via AuthorityDelegation options; `/internal/service-accounts` endpoints validated with targeted vstest run.
|
||||
> 2025-11-02: Added Mongo service-account store, seeded options/collection initializers, token persistence metadata (`tokenKind`, `serviceAccountId`, `actorChain`), and docs/config samples. Introduced quota checks + tests covering service account issuance and persistence.
|
||||
> 2025-11-02: Documented bootstrap service-account admin APIs in `docs/11_AUTHORITY.md`, noting API key requirements and stable upsert behaviour.
|
||||
> 2025-11-03: Seeded explicit enabled service-account fixtures for integration tests and reran `StellaOps.Authority.Tests` to greenlight `/internal/service-accounts` listing + revocation scenarios.
|
||||
> 2025-11-04: Confirmed service-account docs/config examples, quota tuning, and audit stream wiring; Authority suite re-executed to cover issuance/listing/revocation flows.
|
||||
|
||||
## Observability & Forensics (Epic 15)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AUTH-OBS-50-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-AOC-19-001 | Introduce scopes `obs:read`, `timeline:read`, `timeline:write`, `evidence:create`, `evidence:read`, `evidence:hold`, `attest:read`, and `obs:incident` (all tenant-scoped). Update discovery metadata, offline defaults, and scope grammar docs. | Scopes exposed via metadata; issuer templates updated; offline kit seeded; integration tests cover new scopes. |
|
||||
| AUTH-OBS-52-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-OBS-50-001, TIMELINE-OBS-52-003, EVID-OBS-53-003 | Configure resource server policies for Timeline Indexer, Evidence Locker, Exporter, and Observability APIs enforcing new scopes + tenant claims. Emit audit events including scope usage and trace IDs. | Policies deployed; unauthorized access blocked; audit logs prove scope usage; contract tests updated. |
|
||||
| AUTH-OBS-55-001 | DONE (2025-11-02) | Authority Core & Security Guild, Ops Guild | AUTH-OBS-50-001, WEB-OBS-55-001 | Harden incident mode authorization: require `obs:incident` scope + fresh auth, log activation reason, and expose verification endpoint for auditors. Update docs/runbooks. | Incident activate/deactivate requires scope; audit entries logged; docs updated with imposed rule reminder. |
|
||||
|
||||
## Air-Gapped Mode (Epic 16)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AUTH-AIRGAP-56-001 | DONE (2025-11-04) | Authority Core & Security Guild | AIRGAP-CTL-56-001 | Provision new scopes (`airgap:seal`, `airgap:import`, `airgap:status:read`) in configuration metadata, offline kit defaults, and issuer templates. | Scopes exposed in discovery docs; offline kit updated; integration tests cover issuance. |
|
||||
| AUTH-AIRGAP-56-002 | DONE (2025-11-04) | Authority Core & Security Guild | AUTH-AIRGAP-56-001, AIRGAP-IMP-58-001 | Audit import actions with actor, tenant, bundle ID, and trace ID; expose `/authority/audit/airgap` endpoint. | Audit records persisted; endpoint paginates results; tests cover RBAC + filtering. |
|
||||
> 2025-11-04: Airgap scope constants are wired through discovery metadata, `etc/authority.yaml.sample`, and offline kit docs; scope issuance tests executed via `dotnet test`.
|
||||
> 2025-11-04: `/authority/audit/airgap` API persists tenant-scoped audit entries with pagination and authorization guards validated by the Authority integration suite (187 tests).
|
||||
| AUTH-AIRGAP-57-001 | BLOCKED (2025-11-01) | Authority Core & Security Guild, DevOps Guild | AUTH-AIRGAP-56-001, DEVOPS-AIRGAP-57-002 | Enforce sealed-mode CI gating by refusing token issuance when declared sealed install lacks sealing confirmation. | Awaiting clarified sealed-confirmation contract and configuration structure before implementation. |
|
||||
> 2025-11-01: AUTH-AIRGAP-57-001 blocked pending guidance on sealed-confirmation contract and configuration expectations before gating changes (Authority Core & Security Guild, DevOps Guild).
|
||||
|
||||
## SDKs & OpenAPI (Epic 17)
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 2025-10-28: Auth OpenAPI authored at `src/Api/StellaOps.Api.OpenApi/authority/openapi.yaml` covering `/token`, `/introspect`, `/revoke`, `/jwks`, scope catalog, and error envelopes; parsed via PyYAML sanity check and referenced in Epic 17 docs.
|
||||
> 2025-10-28: Added `/.well-known/openapi` endpoint wiring cached spec metadata, YAML/JSON negotiation, HTTP cache headers, and tests verifying ETag + Accept handling. Authority spec (`src/Api/StellaOps.Api.OpenApi/authority/openapi.yaml`) now includes grant/scope extensions.
|
||||
| AUTH-OAS-62-001 | DONE (2025-11-02) | Authority Core & Security Guild, SDK Generator Guild | AUTH-OAS-61-001, SDKGEN-63-001 | Provide SDK helpers for OAuth2/PAT flows, tenancy override header; add integration tests. | SDKs expose auth helpers; tests cover token issuance; docs updated. |
|
||||
> 2025-11-02: `AddStellaOpsApiAuthentication` shipped (OAuth2 + PAT), tenant header injection added, and client tests updated for caching behaviour.
|
||||
| AUTH-OAS-63-001 | DONE (2025-11-02) | Authority Core & Security Guild, API Governance Guild | APIGOV-63-001 | Emit deprecation headers and notifications for legacy auth endpoints. | Headers emitted; notifications verified; migration guide published. |
|
||||
> 2025-11-02: AUTH-OAS-63-001 completed — legacy OAuth shims emit Deprecation/Sunset/Warning headers, audit events captured, and migration guide published (Authority Core & Security Guild, API Governance Guild).
|
||||
|
||||
Reference in New Issue
Block a user