Add Ruby language analyzer and related functionality
- Introduced global usings for Ruby analyzer. - Implemented RubyLockData, RubyLockEntry, and RubyLockParser for handling Gemfile.lock files. - Created RubyPackage and RubyPackageCollector to manage Ruby packages and vendor cache. - Developed RubyAnalyzerPlugin and RubyLanguageAnalyzer for analyzing Ruby projects. - Added tests for Ruby language analyzer with sample Gemfile.lock and expected output. - Included necessary project files and references for the Ruby analyzer. - Added third-party licenses for tree-sitter dependencies.
This commit is contained in:
@@ -1,56 +1,61 @@
|
||||
namespace StellaOps.Auth.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical claim type identifiers used across StellaOps services.
|
||||
/// </summary>
|
||||
public static class StellaOpsClaimTypes
|
||||
{
|
||||
/// <summary>
|
||||
/// Subject identifier claim (maps to <c>sub</c> in JWTs).
|
||||
/// </summary>
|
||||
public const string Subject = "sub";
|
||||
|
||||
/// <summary>
|
||||
/// StellaOps tenant identifier claim (multi-tenant deployments).
|
||||
/// </summary>
|
||||
public const string Tenant = "stellaops:tenant";
|
||||
|
||||
/// <summary>
|
||||
/// StellaOps project identifier claim (optional project scoping within a tenant).
|
||||
/// </summary>
|
||||
public const string Project = "stellaops:project";
|
||||
|
||||
/// <summary>
|
||||
/// OAuth2/OIDC client identifier claim (maps to <c>client_id</c>).
|
||||
/// </summary>
|
||||
public const string ClientId = "client_id";
|
||||
|
||||
/// <summary>
|
||||
/// Unique token identifier claim (maps to <c>jti</c>).
|
||||
/// </summary>
|
||||
public const string TokenId = "jti";
|
||||
|
||||
/// <summary>
|
||||
/// Authentication method reference claim (<c>amr</c>).
|
||||
/// </summary>
|
||||
public const string AuthenticationMethod = "amr";
|
||||
|
||||
/// <summary>
|
||||
/// Space separated scope list (<c>scope</c>).
|
||||
/// </summary>
|
||||
public const string Scope = "scope";
|
||||
|
||||
/// <summary>
|
||||
/// Individual scope items (<c>scp</c>).
|
||||
/// </summary>
|
||||
public const string ScopeItem = "scp";
|
||||
|
||||
/// <summary>
|
||||
/// OAuth2 resource audiences (<c>aud</c>).
|
||||
/// </summary>
|
||||
public const string Audience = "aud";
|
||||
|
||||
/// <summary>
|
||||
namespace StellaOps.Auth.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical claim type identifiers used across StellaOps services.
|
||||
/// </summary>
|
||||
public static class StellaOpsClaimTypes
|
||||
{
|
||||
/// <summary>
|
||||
/// Subject identifier claim (maps to <c>sub</c> in JWTs).
|
||||
/// </summary>
|
||||
public const string Subject = "sub";
|
||||
|
||||
/// <summary>
|
||||
/// StellaOps tenant identifier claim (multi-tenant deployments).
|
||||
/// </summary>
|
||||
public const string Tenant = "stellaops:tenant";
|
||||
|
||||
/// <summary>
|
||||
/// StellaOps project identifier claim (optional project scoping within a tenant).
|
||||
/// </summary>
|
||||
public const string Project = "stellaops:project";
|
||||
|
||||
/// <summary>
|
||||
/// OAuth2/OIDC client identifier claim (maps to <c>client_id</c>).
|
||||
/// </summary>
|
||||
public const string ClientId = "client_id";
|
||||
|
||||
/// <summary>
|
||||
/// Service account identifier associated with delegated tokens.
|
||||
/// </summary>
|
||||
public const string ServiceAccount = "stellaops:service_account";
|
||||
|
||||
/// <summary>
|
||||
/// Unique token identifier claim (maps to <c>jti</c>).
|
||||
/// </summary>
|
||||
public const string TokenId = "jti";
|
||||
|
||||
/// <summary>
|
||||
/// Authentication method reference claim (<c>amr</c>).
|
||||
/// </summary>
|
||||
public const string AuthenticationMethod = "amr";
|
||||
|
||||
/// <summary>
|
||||
/// Space separated scope list (<c>scope</c>).
|
||||
/// </summary>
|
||||
public const string Scope = "scope";
|
||||
|
||||
/// <summary>
|
||||
/// Individual scope items (<c>scp</c>).
|
||||
/// </summary>
|
||||
public const string ScopeItem = "scp";
|
||||
|
||||
/// <summary>
|
||||
/// OAuth2 resource audiences (<c>aud</c>).
|
||||
/// </summary>
|
||||
public const string Audience = "aud";
|
||||
|
||||
/// <summary>
|
||||
/// Identity provider hint for downstream services.
|
||||
/// </summary>
|
||||
public const string IdentityProvider = "stellaops:idp";
|
||||
|
||||
@@ -20,10 +20,10 @@ public sealed class AuthorityTokenDocument
|
||||
|
||||
[BsonElement("type")]
|
||||
public string Type { get; set; } = string.Empty;
|
||||
[BsonElement(tokenKind)]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? TokenKind { get; set; }
|
||||
|
||||
|
||||
[BsonElement("tokenKind")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? TokenKind { get; set; }
|
||||
|
||||
[BsonElement("subjectId")]
|
||||
[BsonIgnoreIfNull]
|
||||
@@ -97,12 +97,12 @@ public sealed class AuthorityTokenDocument
|
||||
[BsonElement("revokedMetadata")]
|
||||
[BsonIgnoreIfNull]
|
||||
public Dictionary<string, string?>? RevokedMetadata { get; set; }
|
||||
|
||||
[BsonElement(serviceAccountId)]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? ServiceAccountId { get; set; }
|
||||
|
||||
[BsonElement(actors)]
|
||||
[BsonIgnoreIfNull]
|
||||
public List<string>? ActorChain { get; set; }
|
||||
|
||||
[BsonElement("serviceAccountId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? ServiceAccountId { get; set; }
|
||||
|
||||
[BsonElement("actors")]
|
||||
[BsonIgnoreIfNull]
|
||||
public List<string>? ActorChain { get; set; }
|
||||
}
|
||||
|
||||
@@ -36,7 +36,18 @@ internal sealed class AuthorityTokenCollectionInitializer : IAuthorityCollection
|
||||
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_sender_thumbprint", Sparse = true })
|
||||
};
|
||||
|
||||
var expirationFilter = Builders<AuthorityTokenDocument>.Filter.Exists(t => t.ExpiresAt, true);
|
||||
var serviceAccountFilter = Builders<AuthorityTokenDocument>.Filter.Exists(t => t.ServiceAccountId, true);
|
||||
indexModels.Add(new CreateIndexModel<AuthorityTokenDocument>(
|
||||
Builders<AuthorityTokenDocument>.IndexKeys
|
||||
.Ascending(t => t.Tenant)
|
||||
.Ascending(t => t.ServiceAccountId),
|
||||
new CreateIndexOptions<AuthorityTokenDocument>
|
||||
{
|
||||
Name = "token_tenant_service_account",
|
||||
PartialFilterExpression = serviceAccountFilter
|
||||
}));
|
||||
|
||||
var expirationFilter = Builders<AuthorityTokenDocument>.Filter.Exists(t => t.ExpiresAt, true);
|
||||
indexModels.Add(new CreateIndexModel<AuthorityTokenDocument>(
|
||||
Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.ExpiresAt),
|
||||
new CreateIndexOptions<AuthorityTokenDocument>
|
||||
|
||||
@@ -13,6 +13,7 @@ namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
internal sealed class AuthorityTokenStore : IAuthorityTokenStore
|
||||
{
|
||||
private const string ServiceAccountTokenKind = "service_account";
|
||||
private readonly IMongoCollection<AuthorityTokenDocument> collection;
|
||||
private readonly ILogger<AuthorityTokenStore> logger;
|
||||
|
||||
@@ -190,6 +191,97 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
|
||||
return new TokenUsageUpdateResult(suspicious ? TokenUsageUpdateStatus.SuspectedReplay : TokenUsageUpdateStatus.Recorded, normalizedAddress, normalizedAgent);
|
||||
}
|
||||
|
||||
public async ValueTask<long> CountActiveDelegationTokensAsync(
|
||||
string tenant,
|
||||
string? serviceAccountId,
|
||||
CancellationToken cancellationToken,
|
||||
IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var normalizedTenant = tenant.Trim().ToLowerInvariant();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var filter = Builders<AuthorityTokenDocument>.Filter.And(new[]
|
||||
{
|
||||
Builders<AuthorityTokenDocument>.Filter.Eq(t => t.Status, "valid"),
|
||||
Builders<AuthorityTokenDocument>.Filter.Eq(t => t.Tenant, normalizedTenant),
|
||||
Builders<AuthorityTokenDocument>.Filter.Exists(t => t.ServiceAccountId, true),
|
||||
Builders<AuthorityTokenDocument>.Filter.Eq(t => t.TokenKind, ServiceAccountTokenKind),
|
||||
Builders<AuthorityTokenDocument>.Filter.Or(
|
||||
Builders<AuthorityTokenDocument>.Filter.Eq(t => t.ExpiresAt, null),
|
||||
Builders<AuthorityTokenDocument>.Filter.Gt(t => t.ExpiresAt, now))
|
||||
});
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(serviceAccountId))
|
||||
{
|
||||
var normalizedAccount = serviceAccountId.Trim();
|
||||
filter &= Builders<AuthorityTokenDocument>.Filter.Eq(t => t.ServiceAccountId, normalizedAccount);
|
||||
}
|
||||
|
||||
var query = session is { }
|
||||
? collection.Find(session, filter)
|
||||
: collection.Find(filter);
|
||||
|
||||
return await query.CountDocumentsAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListActiveDelegationTokensAsync(
|
||||
string tenant,
|
||||
string? serviceAccountId,
|
||||
CancellationToken cancellationToken,
|
||||
IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
return Array.Empty<AuthorityTokenDocument>();
|
||||
}
|
||||
|
||||
var normalizedTenant = tenant.Trim().ToLowerInvariant();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var filters = new List<FilterDefinition<AuthorityTokenDocument>>
|
||||
{
|
||||
Builders<AuthorityTokenDocument>.Filter.Eq(t => t.Status, "valid"),
|
||||
Builders<AuthorityTokenDocument>.Filter.Eq(t => t.Tenant, normalizedTenant),
|
||||
Builders<AuthorityTokenDocument>.Filter.Eq(t => t.TokenKind, ServiceAccountTokenKind),
|
||||
Builders<AuthorityTokenDocument>.Filter.Or(
|
||||
Builders<AuthorityTokenDocument>.Filter.Eq(t => t.ExpiresAt, null),
|
||||
Builders<AuthorityTokenDocument>.Filter.Gt(t => t.ExpiresAt, now))
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(serviceAccountId))
|
||||
{
|
||||
filters.Add(Builders<AuthorityTokenDocument>.Filter.Eq(
|
||||
t => t.ServiceAccountId,
|
||||
serviceAccountId.Trim()));
|
||||
}
|
||||
|
||||
var filter = Builders<AuthorityTokenDocument>.Filter.And(filters);
|
||||
var options = new FindOptions<AuthorityTokenDocument>
|
||||
{
|
||||
Sort = Builders<AuthorityTokenDocument>.Sort
|
||||
.Descending(t => t.CreatedAt)
|
||||
.Descending(t => t.TokenId)
|
||||
};
|
||||
|
||||
IAsyncCursor<AuthorityTokenDocument> cursor;
|
||||
if (session is { })
|
||||
{
|
||||
cursor = await collection.FindAsync(session, filter, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
cursor = await collection.FindAsync(filter, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var documents = await cursor.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
return documents;
|
||||
}
|
||||
|
||||
private static string? GetString(BsonDocument document, string name)
|
||||
{
|
||||
if (!document.TryGetValue(name, out var value))
|
||||
|
||||
@@ -6,7 +6,7 @@ using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
internal interface IAuthorityServiceAccountStore
|
||||
public interface IAuthorityServiceAccountStore
|
||||
{
|
||||
ValueTask<AuthorityServiceAccountDocument?> FindByAccountIdAsync(string accountId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
|
||||
|
||||
@@ -36,6 +36,18 @@ public interface IAuthorityTokenStore
|
||||
int limit,
|
||||
CancellationToken cancellationToken,
|
||||
IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<long> CountActiveDelegationTokensAsync(
|
||||
string tenant,
|
||||
string? serviceAccountId,
|
||||
CancellationToken cancellationToken,
|
||||
IClientSessionHandle? session = null);
|
||||
|
||||
ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListActiveDelegationTokensAsync(
|
||||
string tenant,
|
||||
string? serviceAccountId,
|
||||
CancellationToken cancellationToken,
|
||||
IClientSessionHandle? session = null);
|
||||
}
|
||||
|
||||
public enum TokenUsageUpdateStatus
|
||||
|
||||
@@ -0,0 +1,526 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Authority.OpenIddict;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.Tests.Infrastructure;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Tests.Bootstrap;
|
||||
|
||||
public sealed class ServiceAccountAdminEndpointsTests : IClassFixture<AuthorityWebApplicationFactory>
|
||||
{
|
||||
private const string BootstrapKey = "test-bootstrap-key";
|
||||
private const string TenantId = "tenant-default";
|
||||
private const string ServiceAccountId = "svc-observer";
|
||||
|
||||
private readonly AuthorityWebApplicationFactory factory;
|
||||
|
||||
public ServiceAccountAdminEndpointsTests(AuthorityWebApplicationFactory factory)
|
||||
{
|
||||
this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_ReturnsUnauthorized_WhenBootstrapKeyMissing()
|
||||
{
|
||||
using var app = CreateApplication(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
var authBuilder = services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
|
||||
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
|
||||
});
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
|
||||
});
|
||||
});
|
||||
|
||||
using var client = app.CreateClient();
|
||||
|
||||
var response = await client.GetAsync($"/internal/service-accounts?tenant={TenantId}");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_ReturnsBadRequest_WhenTenantMissing()
|
||||
{
|
||||
using var app = CreateApplication(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
var authBuilder = services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
|
||||
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
|
||||
});
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
|
||||
});
|
||||
});
|
||||
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Bootstrap-Key", BootstrapKey);
|
||||
|
||||
var response = await client.GetAsync("/internal/service-accounts");
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_ReturnsServiceAccountsForTenant()
|
||||
{
|
||||
using var app = CreateApplication(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
var authBuilder = services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
|
||||
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
|
||||
});
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
|
||||
});
|
||||
});
|
||||
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Bootstrap-Key", BootstrapKey);
|
||||
|
||||
var response = await client.GetAsync($"/internal/service-accounts?tenant={TenantId}");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<ServiceAccountResponse[]>(default);
|
||||
Assert.NotNull(payload);
|
||||
|
||||
var serviceAccount = Assert.Single(payload!);
|
||||
Assert.Equal(ServiceAccountId, serviceAccount.AccountId);
|
||||
Assert.Equal(TenantId, serviceAccount.Tenant);
|
||||
Assert.Equal("Observability Exporter", serviceAccount.DisplayName);
|
||||
Assert.True(serviceAccount.Enabled);
|
||||
Assert.Equal(new[] { "findings:read", "jobs:read" }, serviceAccount.AllowedScopes);
|
||||
Assert.Equal(new[] { "export-center-worker" }, serviceAccount.AuthorizedClients);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tokens_ReturnsActiveDelegationTokens()
|
||||
{
|
||||
using var app = CreateApplication();
|
||||
|
||||
await using (var scope = app.Services.CreateAsyncScope())
|
||||
{
|
||||
var tokenStore = scope.ServiceProvider.GetRequiredService<IAuthorityTokenStore>();
|
||||
var document = new AuthorityTokenDocument
|
||||
{
|
||||
TokenId = "token-1",
|
||||
ClientId = "export-center-worker",
|
||||
Status = "valid",
|
||||
Scope = new List<string> { "jobs:read", "findings:read" },
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-10),
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(20),
|
||||
Tenant = TenantId,
|
||||
ServiceAccountId = ServiceAccountId,
|
||||
TokenKind = "service_account"
|
||||
};
|
||||
|
||||
await tokenStore.InsertAsync(document, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Bootstrap-Key", BootstrapKey);
|
||||
|
||||
var response = await client.GetAsync($"/internal/service-accounts/{ServiceAccountId}/tokens");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<ServiceAccountTokenResponse[]>(default);
|
||||
Assert.NotNull(payload);
|
||||
|
||||
var token = Assert.Single(payload!);
|
||||
Assert.Equal("token-1", token.TokenId);
|
||||
Assert.Equal("export-center-worker", token.ClientId);
|
||||
Assert.Equal("valid", token.Status);
|
||||
Assert.Equal(new[] { "findings:read", "jobs:read" }, token.Scopes);
|
||||
Assert.Empty(token.Actors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tokens_ReturnsNotFound_WhenServiceAccountMissing()
|
||||
{
|
||||
using var app = CreateApplication(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
var authBuilder = services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
|
||||
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
|
||||
});
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
|
||||
});
|
||||
});
|
||||
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Bootstrap-Key", BootstrapKey);
|
||||
|
||||
var response = await client.GetAsync("/internal/service-accounts/svc-missing/tokens");
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revoke_RevokesAllActiveTokens_AndEmitsAuditEvent()
|
||||
{
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T18:00:00Z"));
|
||||
|
||||
using var app = CreateApplication(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IAuthEventSink>();
|
||||
services.AddSingleton<IAuthEventSink>(sink);
|
||||
services.Replace(ServiceDescriptor.Singleton<TimeProvider>(timeProvider));
|
||||
var authBuilder = services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
|
||||
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
|
||||
});
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
|
||||
});
|
||||
});
|
||||
|
||||
var tokenIds = new[] { "token-a", "token-b" };
|
||||
|
||||
await using (var scope = app.Services.CreateAsyncScope())
|
||||
{
|
||||
var tokenStore = scope.ServiceProvider.GetRequiredService<IAuthorityTokenStore>();
|
||||
|
||||
foreach (var tokenId in tokenIds)
|
||||
{
|
||||
await tokenStore.InsertAsync(new AuthorityTokenDocument
|
||||
{
|
||||
TokenId = tokenId,
|
||||
ClientId = "export-center-worker",
|
||||
Status = "valid",
|
||||
Scope = new List<string> { "jobs:read" },
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30),
|
||||
Tenant = TenantId,
|
||||
ServiceAccountId = ServiceAccountId,
|
||||
TokenKind = "service_account"
|
||||
}, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Bootstrap-Key", BootstrapKey);
|
||||
|
||||
var response = await client.PostAsJsonAsync($"/internal/service-accounts/{ServiceAccountId}/revocations", new
|
||||
{
|
||||
reason = "operator_request",
|
||||
reasonDescription = "Rotate credentials"
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<ServiceAccountRevokeResponse>(default);
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal(2, payload!.RevokedCount);
|
||||
Assert.Equal(tokenIds.OrderBy(id => id, StringComparer.Ordinal), payload.TokenIds.OrderBy(id => id, StringComparer.Ordinal));
|
||||
|
||||
await using (var scope = app.Services.CreateAsyncScope())
|
||||
{
|
||||
var tokenStore = scope.ServiceProvider.GetRequiredService<IAuthorityTokenStore>();
|
||||
|
||||
foreach (var tokenId in tokenIds)
|
||||
{
|
||||
var sessionAccessor = scope.ServiceProvider.GetRequiredService<IAuthorityMongoSessionAccessor>();
|
||||
var session = await sessionAccessor.GetSessionAsync().ConfigureAwait(false);
|
||||
var token = await tokenStore.FindByTokenIdAsync(tokenId, CancellationToken.None, session).ConfigureAwait(false);
|
||||
Assert.NotNull(token);
|
||||
Assert.Equal("revoked", token!.Status);
|
||||
}
|
||||
}
|
||||
|
||||
var audit = Assert.Single(sink.Events.Where(evt => evt.EventType == "authority.delegation.revoked"));
|
||||
Assert.Equal(AuthEventOutcome.Success, audit.Outcome);
|
||||
Assert.Equal("operator_request", audit.Reason);
|
||||
Assert.Contains(audit.Properties, property =>
|
||||
string.Equals(property.Name, "delegation.service_account", StringComparison.Ordinal) &&
|
||||
string.Equals(property.Value.Value, ServiceAccountId, StringComparison.Ordinal));
|
||||
Assert.Contains(audit.Properties, property =>
|
||||
string.Equals(property.Name, "delegation.revoked_count", StringComparison.Ordinal) &&
|
||||
string.Equals(property.Value.Value, "2", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revoke_ReturnsNotFound_WhenServiceAccountMissing()
|
||||
{
|
||||
var sink = new RecordingAuthEventSink();
|
||||
|
||||
using var app = CreateApplication(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IAuthEventSink>();
|
||||
services.AddSingleton<IAuthEventSink>(sink);
|
||||
var authBuilder = services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
|
||||
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
|
||||
});
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
|
||||
});
|
||||
});
|
||||
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Bootstrap-Key", BootstrapKey);
|
||||
|
||||
var response = await client.PostAsJsonAsync("/internal/service-accounts/svc-unknown/revocations", new { reason = "rotate" });
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
Assert.Empty(sink.Events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revoke_ReturnsNotFound_WhenTokenNotFound()
|
||||
{
|
||||
var sink = new RecordingAuthEventSink();
|
||||
|
||||
using var app = CreateApplication(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IAuthEventSink>();
|
||||
services.AddSingleton<IAuthEventSink>(sink);
|
||||
var authBuilder = services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
|
||||
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
|
||||
});
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
|
||||
});
|
||||
});
|
||||
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Bootstrap-Key", BootstrapKey);
|
||||
|
||||
var response = await client.PostAsJsonAsync($"/internal/service-accounts/{ServiceAccountId}/revocations", new { tokenId = "missing-token", reason = "cleanup" });
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
Assert.Empty(sink.Events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revoke_ReturnsFailure_WhenNoActiveTokens()
|
||||
{
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T09:00:00Z"));
|
||||
|
||||
using var app = CreateApplication(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IAuthEventSink>();
|
||||
services.AddSingleton<IAuthEventSink>(sink);
|
||||
services.Replace(ServiceDescriptor.Singleton<TimeProvider>(timeProvider));
|
||||
var authBuilder = services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
|
||||
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
|
||||
});
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
|
||||
});
|
||||
});
|
||||
|
||||
await using (var scope = app.Services.CreateAsyncScope())
|
||||
{
|
||||
var tokenStore = scope.ServiceProvider.GetRequiredService<IAuthorityTokenStore>();
|
||||
await tokenStore.InsertAsync(new AuthorityTokenDocument
|
||||
{
|
||||
TokenId = "token-revoked",
|
||||
ClientId = "export-center-worker",
|
||||
Status = "revoked",
|
||||
Scope = new List<string> { "jobs:read" },
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-20),
|
||||
Tenant = TenantId,
|
||||
ServiceAccountId = ServiceAccountId,
|
||||
TokenKind = "service_account"
|
||||
}, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Bootstrap-Key", BootstrapKey);
|
||||
|
||||
var response = await client.PostAsJsonAsync($"/internal/service-accounts/{ServiceAccountId}/revocations", new { reason = "cleanup" });
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<ServiceAccountRevokeResponse>(default);
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal(0, payload!.RevokedCount);
|
||||
Assert.Empty(payload.TokenIds);
|
||||
|
||||
var audit = Assert.Single(sink.Events);
|
||||
Assert.Equal(AuthEventOutcome.Failure, audit.Outcome);
|
||||
Assert.Equal("cleanup", audit.Reason);
|
||||
Assert.Equal("0", GetPropertyValue(audit, "delegation.revoked_count"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revoke_ReturnsSuccess_WhenPartiallyRevokingTokens()
|
||||
{
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T09:30:00Z"));
|
||||
|
||||
using var app = CreateApplication(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IAuthEventSink>();
|
||||
services.AddSingleton<IAuthEventSink>(sink);
|
||||
services.Replace(ServiceDescriptor.Singleton<TimeProvider>(timeProvider));
|
||||
var authBuilder = services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
|
||||
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
|
||||
});
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
|
||||
});
|
||||
});
|
||||
|
||||
await using (var scope = app.Services.CreateAsyncScope())
|
||||
{
|
||||
var tokenStore = scope.ServiceProvider.GetRequiredService<IAuthorityTokenStore>();
|
||||
|
||||
await tokenStore.InsertAsync(new AuthorityTokenDocument
|
||||
{
|
||||
TokenId = "token-active",
|
||||
ClientId = "export-center-worker",
|
||||
Status = "valid",
|
||||
Scope = new List<string> { "jobs:read" },
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-10),
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30),
|
||||
Tenant = TenantId,
|
||||
ServiceAccountId = ServiceAccountId,
|
||||
TokenKind = "service_account"
|
||||
}, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
await tokenStore.InsertAsync(new AuthorityTokenDocument
|
||||
{
|
||||
TokenId = "token-already-revoked",
|
||||
ClientId = "export-center-worker",
|
||||
Status = "revoked",
|
||||
Scope = new List<string> { "jobs:read" },
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-25),
|
||||
Tenant = TenantId,
|
||||
ServiceAccountId = ServiceAccountId,
|
||||
TokenKind = "service_account"
|
||||
}, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
using var client = app.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Bootstrap-Key", BootstrapKey);
|
||||
|
||||
var response = await client.PostAsJsonAsync($"/internal/service-accounts/{ServiceAccountId}/revocations", new { reason = "partial" });
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<ServiceAccountRevokeResponse>(default);
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal(1, payload!.RevokedCount);
|
||||
Assert.Equal(new[] { "token-active" }, payload.TokenIds);
|
||||
|
||||
var audit = Assert.Single(sink.Events);
|
||||
Assert.Equal(AuthEventOutcome.Success, audit.Outcome);
|
||||
Assert.Equal("partial", audit.Reason);
|
||||
Assert.Equal("1", GetPropertyValue(audit, "delegation.revoked_count"));
|
||||
Assert.Equal("token-active", GetPropertyValue(audit, "delegation.revoked_token[0]"));
|
||||
}
|
||||
|
||||
private WebApplicationFactory<Program> CreateApplication(Action<IWebHostBuilder>? configure = null)
|
||||
{
|
||||
return factory.WithWebHostBuilder(host =>
|
||||
{
|
||||
host.ConfigureAppConfiguration((_, configuration) =>
|
||||
{
|
||||
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Authority:Bootstrap:Enabled"] = "true",
|
||||
["Authority:Bootstrap:ApiKey"] = BootstrapKey,
|
||||
["Authority:Bootstrap:DefaultIdentityProvider"] = "standard",
|
||||
["Authority:Tenants:0:Id"] = TenantId,
|
||||
["Authority:Tenants:0:DisplayName"] = "Default Tenant",
|
||||
["Authority:Delegation:Quotas:MaxActiveTokens"] = "50",
|
||||
["Authority:Delegation:ServiceAccounts:0:AccountId"] = ServiceAccountId,
|
||||
["Authority:Delegation:ServiceAccounts:0:Tenant"] = TenantId,
|
||||
["Authority:Delegation:ServiceAccounts:0:DisplayName"] = "Observability Exporter",
|
||||
["Authority:Delegation:ServiceAccounts:0:Description"] = "Automates evidence exports.",
|
||||
["Authority:Delegation:ServiceAccounts:0:AllowedScopes:0"] = "jobs:read",
|
||||
["Authority:Delegation:ServiceAccounts:0:AllowedScopes:1"] = "findings:read",
|
||||
["Authority:Delegation:ServiceAccounts:0:AuthorizedClients:0"] = "export-center-worker"
|
||||
});
|
||||
});
|
||||
|
||||
configure?.Invoke(host);
|
||||
});
|
||||
}
|
||||
|
||||
private static string? GetPropertyValue(AuthEventRecord record, string name)
|
||||
{
|
||||
return record.Properties
|
||||
.FirstOrDefault(property => string.Equals(property.Name, name, StringComparison.Ordinal))
|
||||
?.Value.Value;
|
||||
}
|
||||
|
||||
private sealed record ServiceAccountResponse(
|
||||
string AccountId,
|
||||
string Tenant,
|
||||
string? DisplayName,
|
||||
string? Description,
|
||||
bool Enabled,
|
||||
IReadOnlyList<string> AllowedScopes,
|
||||
IReadOnlyList<string> AuthorizedClients);
|
||||
|
||||
private sealed record ServiceAccountTokenResponse(
|
||||
string TokenId,
|
||||
string? ClientId,
|
||||
string Status,
|
||||
IReadOnlyList<string> Scopes,
|
||||
IReadOnlyList<string> Actors);
|
||||
|
||||
private sealed record ServiceAccountRevokeResponse(int RevokedCount, IReadOnlyList<string> TokenIds);
|
||||
|
||||
private sealed class RecordingAuthEventSink : IAuthEventSink
|
||||
{
|
||||
private readonly List<AuthEventRecord> events = new();
|
||||
|
||||
public IReadOnlyList<AuthEventRecord> Events => events;
|
||||
|
||||
public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (events)
|
||||
{
|
||||
events.Add(record);
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -91,6 +93,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -123,6 +127,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -160,6 +166,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -176,6 +184,128 @@ public class ClientCredentialsHandlersTests
|
||||
Assert.Equal(new[] { "advisory:ingest" }, grantedScopes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_AllowsServiceAccountWhenAuthorized()
|
||||
{
|
||||
var clientDocument = CreateClient(
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "jobs:read",
|
||||
tenant: "tenant-alpha");
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var options = TestHelpers.CreateAuthorityOptions(opts =>
|
||||
{
|
||||
opts.Delegation.Quotas.MaxActiveTokens = 5;
|
||||
});
|
||||
|
||||
var serviceAccount = new AuthorityServiceAccountDocument
|
||||
{
|
||||
AccountId = "svc-observer",
|
||||
Tenant = "tenant-alpha",
|
||||
AllowedScopes = new List<string> { "jobs:read" },
|
||||
AuthorizedClients = new List<string> { clientDocument.ClientId }
|
||||
};
|
||||
|
||||
var serviceAccountStore = new TestServiceAccountStore(serviceAccount);
|
||||
var tokenStore = new TestTokenStore();
|
||||
var handler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
metadataAccessor,
|
||||
serviceAccountStore,
|
||||
tokenStore,
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
options,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
|
||||
transaction.Request.SetParameter(AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-observer");
|
||||
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}");
|
||||
Assert.True(context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ServiceAccountProperty, out var serviceAccountObj));
|
||||
var resolvedAccount = Assert.IsType<AuthorityServiceAccountDocument>(serviceAccountObj);
|
||||
Assert.Equal("svc-observer", resolvedAccount.AccountId);
|
||||
var grantedScopes = Assert.IsType<string[]>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]);
|
||||
Assert.Contains("jobs:read", grantedScopes);
|
||||
Assert.Equal("svc-observer", metadataAccessor.GetMetadata()?.SubjectId);
|
||||
Assert.Equal(AuthorityTokenKinds.ServiceAccount, context.Transaction.Properties[AuthorityOpenIddictConstants.TokenKindProperty]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_RejectsWhenServiceAccountQuotaExceeded()
|
||||
{
|
||||
var clientDocument = CreateClient(
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "jobs:read",
|
||||
tenant: "tenant-alpha");
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var options = TestHelpers.CreateAuthorityOptions(opts =>
|
||||
{
|
||||
opts.Delegation.Quotas.MaxActiveTokens = 1;
|
||||
});
|
||||
|
||||
var serviceAccount = new AuthorityServiceAccountDocument
|
||||
{
|
||||
AccountId = "svc-observer",
|
||||
Tenant = "tenant-alpha",
|
||||
AllowedScopes = new List<string> { "jobs:read" },
|
||||
AuthorizedClients = new List<string> { clientDocument.ClientId }
|
||||
};
|
||||
|
||||
var serviceAccountStore = new TestServiceAccountStore(serviceAccount);
|
||||
var tokenStore = new TestTokenStore
|
||||
{
|
||||
Inserted = new AuthorityTokenDocument
|
||||
{
|
||||
TokenId = "existing-token",
|
||||
Status = "valid",
|
||||
Tenant = "tenant-alpha",
|
||||
ClientId = clientDocument.ClientId,
|
||||
ServiceAccountId = "svc-observer",
|
||||
TokenKind = AuthorityTokenKinds.ServiceAccount,
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-1),
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5),
|
||||
Scope = new List<string> { "jobs:read" }
|
||||
}
|
||||
};
|
||||
|
||||
var handler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
metadataAccessor,
|
||||
serviceAccountStore,
|
||||
tokenStore,
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
options,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
|
||||
transaction.Request.SetParameter(AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-observer");
|
||||
|
||||
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.True(context.IsRejected);
|
||||
Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error);
|
||||
Assert.Equal("Delegation token quota exceeded for service account.", context.ErrorDescription);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateClientCredentials_RejectsAdvisoryReadWithoutAocVerify()
|
||||
{
|
||||
@@ -193,6 +323,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -227,6 +359,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -265,6 +399,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -304,6 +440,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -336,6 +474,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -370,6 +510,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -403,6 +545,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -437,6 +581,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -471,6 +617,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -503,6 +651,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -538,6 +688,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -575,6 +727,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -610,6 +764,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -646,6 +802,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -682,6 +840,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -717,6 +877,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -751,6 +913,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -787,6 +951,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -822,6 +988,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -857,6 +1025,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -894,6 +1064,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -931,6 +1103,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -971,6 +1145,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -1014,6 +1190,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -1050,6 +1228,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -1084,6 +1264,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -1120,6 +1302,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -1156,6 +1340,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -1196,6 +1382,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -1234,6 +1422,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -1268,6 +1458,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -1302,6 +1494,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -1349,6 +1543,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -1384,6 +1580,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -1418,6 +1616,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -1452,6 +1652,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -1494,6 +1696,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -1529,6 +1733,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -1562,6 +1768,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -1596,6 +1804,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -1631,6 +1841,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -1665,6 +1877,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -1700,6 +1914,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -1733,6 +1949,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -1767,6 +1985,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -1802,6 +2022,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -1837,6 +2059,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
sink,
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -2094,6 +2318,8 @@ public class ClientCredentialsHandlersTests
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var auditSink = new TestAuthEventSink();
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var serviceAccountStore = new TestServiceAccountStore();
|
||||
var tokenStore = new TestTokenStore();
|
||||
var httpContextAccessor = new HttpContextAccessor { HttpContext = new DefaultHttpContext() };
|
||||
httpContextAccessor.HttpContext!.Connection.ClientCertificate = certificate;
|
||||
|
||||
@@ -2105,6 +2331,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
auditSink,
|
||||
metadataAccessor,
|
||||
serviceAccountStore,
|
||||
tokenStore,
|
||||
TimeProvider.System,
|
||||
validator,
|
||||
httpContextAccessor,
|
||||
@@ -2152,6 +2380,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
validator,
|
||||
httpContextAccessor,
|
||||
@@ -2192,6 +2422,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -2238,6 +2470,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
new TestAuthEventSink(),
|
||||
new TestRateLimiterMetadataAccessor(),
|
||||
new TestServiceAccountStore(),
|
||||
new TestTokenStore(),
|
||||
TimeProvider.System,
|
||||
certificateValidator,
|
||||
httpContextAccessor,
|
||||
@@ -2272,6 +2506,7 @@ public class ClientCredentialsHandlersTests
|
||||
var sessionAccessor = new NullMongoSessionAccessor();
|
||||
var authSink = new TestAuthEventSink();
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var serviceAccountStore = new TestServiceAccountStore();
|
||||
var options = TestHelpers.CreateAuthorityOptions();
|
||||
var validateHandler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
@@ -2279,6 +2514,8 @@ public class ClientCredentialsHandlersTests
|
||||
TestActivitySource,
|
||||
authSink,
|
||||
metadataAccessor,
|
||||
serviceAccountStore,
|
||||
tokenStore,
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
@@ -2335,6 +2572,88 @@ public class ClientCredentialsHandlersTests
|
||||
Assert.Equal("tenant-alpha", persisted.Tenant);
|
||||
Assert.Equal(new[] { "jobs:trigger" }, persisted.Scope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleClientCredentials_PersistsServiceAccountMetadata()
|
||||
{
|
||||
var clientDocument = CreateClient(
|
||||
secret: "s3cr3t!",
|
||||
allowedGrantTypes: "client_credentials",
|
||||
allowedScopes: "jobs:read",
|
||||
tenant: "tenant-alpha");
|
||||
|
||||
var serviceAccount = new AuthorityServiceAccountDocument
|
||||
{
|
||||
AccountId = "svc-ops",
|
||||
Tenant = "tenant-alpha",
|
||||
AllowedScopes = new List<string> { "jobs:read" },
|
||||
AuthorizedClients = new List<string> { clientDocument.ClientId }
|
||||
};
|
||||
|
||||
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
|
||||
var tokenStore = new TestTokenStore();
|
||||
var sessionAccessor = new NullMongoSessionAccessor();
|
||||
var authSink = new TestAuthEventSink();
|
||||
var metadataAccessor = new TestRateLimiterMetadataAccessor();
|
||||
var serviceAccountStore = new TestServiceAccountStore(serviceAccount);
|
||||
var options = TestHelpers.CreateAuthorityOptions(opts =>
|
||||
{
|
||||
opts.Delegation.Quotas.MaxActiveTokens = 5;
|
||||
});
|
||||
|
||||
var validateHandler = new ValidateClientCredentialsHandler(
|
||||
new TestClientStore(clientDocument),
|
||||
registry,
|
||||
TestActivitySource,
|
||||
authSink,
|
||||
metadataAccessor,
|
||||
serviceAccountStore,
|
||||
tokenStore,
|
||||
TimeProvider.System,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
options,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
|
||||
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
|
||||
transaction.Options.AccessTokenLifetime = TimeSpan.FromMinutes(10);
|
||||
transaction.Request.SetParameter(AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-ops");
|
||||
|
||||
var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
|
||||
await validateHandler.HandleAsync(validateContext);
|
||||
Assert.False(validateContext.IsRejected);
|
||||
|
||||
var handleHandler = new HandleClientCredentialsHandler(
|
||||
registry,
|
||||
tokenStore,
|
||||
sessionAccessor,
|
||||
metadataAccessor,
|
||||
TimeProvider.System,
|
||||
TestActivitySource,
|
||||
NullLogger<HandleClientCredentialsHandler>.Instance);
|
||||
var persistHandler = new PersistTokensHandler(tokenStore, sessionAccessor, TimeProvider.System, TestActivitySource, NullLogger<PersistTokensHandler>.Instance);
|
||||
|
||||
var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction);
|
||||
await handleHandler.HandleAsync(handleContext);
|
||||
Assert.True(handleContext.IsRequestHandled);
|
||||
|
||||
var signInContext = new OpenIddictServerEvents.ProcessSignInContext(transaction)
|
||||
{
|
||||
Principal = handleContext.Principal,
|
||||
AccessTokenPrincipal = handleContext.Principal
|
||||
};
|
||||
|
||||
await persistHandler.HandleAsync(signInContext);
|
||||
|
||||
var inserted = tokenStore.Inserted;
|
||||
Assert.NotNull(inserted);
|
||||
Assert.Equal("svc-ops", inserted!.ServiceAccountId);
|
||||
Assert.Equal("service_account", inserted.TokenKind);
|
||||
Assert.NotNull(inserted.ActorChain);
|
||||
Assert.Contains(clientDocument.ClientId, inserted.ActorChain!);
|
||||
Assert.Equal("tenant-alpha", inserted.Tenant);
|
||||
Assert.Contains("jobs:read", inserted.Scope);
|
||||
}
|
||||
}
|
||||
|
||||
public class TokenValidationHandlersTests
|
||||
@@ -2953,6 +3272,65 @@ internal sealed class TestClientStore : IAuthorityClientStore
|
||||
=> ValueTask.FromResult(clients.Remove(clientId));
|
||||
}
|
||||
|
||||
internal sealed class TestServiceAccountStore : IAuthorityServiceAccountStore
|
||||
{
|
||||
private readonly Dictionary<string, AuthorityServiceAccountDocument> accounts = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public TestServiceAccountStore(params AuthorityServiceAccountDocument[] documents)
|
||||
{
|
||||
foreach (var document in documents)
|
||||
{
|
||||
accounts[NormalizeKey(document.AccountId)] = document;
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask<AuthorityServiceAccountDocument?> FindByAccountIdAsync(string accountId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(accountId))
|
||||
{
|
||||
return ValueTask.FromResult<AuthorityServiceAccountDocument?>(null);
|
||||
}
|
||||
|
||||
accounts.TryGetValue(NormalizeKey(accountId), out var document);
|
||||
return ValueTask.FromResult(document);
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<AuthorityServiceAccountDocument>> ListByTenantAsync(string tenant, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
return ValueTask.FromResult<IReadOnlyList<AuthorityServiceAccountDocument>>(Array.Empty<AuthorityServiceAccountDocument>());
|
||||
}
|
||||
|
||||
var normalizedTenant = tenant.Trim().ToLowerInvariant();
|
||||
var results = accounts.Values
|
||||
.Where(account => string.Equals(account.Tenant, normalizedTenant, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
return ValueTask.FromResult<IReadOnlyList<AuthorityServiceAccountDocument>>(results);
|
||||
}
|
||||
|
||||
public ValueTask UpsertAsync(AuthorityServiceAccountDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
accounts[NormalizeKey(document.AccountId)] = document;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask<bool> DeleteAsync(string accountId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(accountId))
|
||||
{
|
||||
return ValueTask.FromResult(false);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(accounts.Remove(NormalizeKey(accountId)));
|
||||
}
|
||||
|
||||
private static string NormalizeKey(string value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
internal sealed class TestTokenStore : IAuthorityTokenStore
|
||||
{
|
||||
public AuthorityTokenDocument? Inserted { get; set; }
|
||||
@@ -3001,6 +3379,47 @@ internal sealed class TestTokenStore : IAuthorityTokenStore
|
||||
return ValueTask.FromResult<IReadOnlyList<AuthorityTokenDocument>>(Array.Empty<AuthorityTokenDocument>());
|
||||
}
|
||||
|
||||
public ValueTask<long> CountActiveDelegationTokensAsync(string tenant, string? serviceAccountId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
if (Inserted is null)
|
||||
{
|
||||
return ValueTask.FromResult(0L);
|
||||
}
|
||||
|
||||
var tenantMatches = string.Equals(Inserted.Tenant, tenant, StringComparison.OrdinalIgnoreCase);
|
||||
var accountMatches = string.IsNullOrWhiteSpace(serviceAccountId) ||
|
||||
string.Equals(Inserted.ServiceAccountId, serviceAccountId, StringComparison.OrdinalIgnoreCase);
|
||||
var active = string.Equals(Inserted.Status, "valid", StringComparison.OrdinalIgnoreCase) &&
|
||||
(!Inserted.ExpiresAt.HasValue || Inserted.ExpiresAt.Value > DateTimeOffset.UtcNow) &&
|
||||
!string.IsNullOrWhiteSpace(Inserted.ServiceAccountId) &&
|
||||
string.Equals(Inserted.TokenKind, AuthorityTokenKinds.ServiceAccount, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
return ValueTask.FromResult(tenantMatches && accountMatches && active ? 1L : 0L);
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListActiveDelegationTokensAsync(string tenant, string? serviceAccountId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
if (Inserted is null)
|
||||
{
|
||||
return ValueTask.FromResult<IReadOnlyList<AuthorityTokenDocument>>(Array.Empty<AuthorityTokenDocument>());
|
||||
}
|
||||
|
||||
var tenantMatches = string.Equals(Inserted.Tenant, tenant, StringComparison.OrdinalIgnoreCase);
|
||||
var accountMatches = string.IsNullOrWhiteSpace(serviceAccountId) ||
|
||||
string.Equals(Inserted.ServiceAccountId, serviceAccountId, StringComparison.OrdinalIgnoreCase);
|
||||
var active = string.Equals(Inserted.Status, "valid", StringComparison.OrdinalIgnoreCase) &&
|
||||
(!Inserted.ExpiresAt.HasValue || Inserted.ExpiresAt.Value > DateTimeOffset.UtcNow) &&
|
||||
!string.IsNullOrWhiteSpace(Inserted.ServiceAccountId) &&
|
||||
string.Equals(Inserted.TokenKind, AuthorityTokenKinds.ServiceAccount, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (tenantMatches && accountMatches && active)
|
||||
{
|
||||
return ValueTask.FromResult<IReadOnlyList<AuthorityTokenDocument>>(new[] { Inserted });
|
||||
}
|
||||
|
||||
return ValueTask.FromResult<IReadOnlyList<AuthorityTokenDocument>>(Array.Empty<AuthorityTokenDocument>());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
internal sealed class TestClaimsEnricher : IClaimsEnricher
|
||||
|
||||
@@ -47,8 +47,9 @@ public sealed class TokenPersistenceIntegrationTests
|
||||
|
||||
await using var provider = await BuildMongoProviderAsync(clock);
|
||||
|
||||
var clientStore = provider.GetRequiredService<IAuthorityClientStore>();
|
||||
var tokenStore = provider.GetRequiredService<IAuthorityTokenStore>();
|
||||
var clientStore = provider.GetRequiredService<IAuthorityClientStore>();
|
||||
var tokenStore = provider.GetRequiredService<IAuthorityTokenStore>();
|
||||
var serviceAccountStore = provider.GetRequiredService<IAuthorityServiceAccountStore>();
|
||||
|
||||
var clientDocument = TestHelpers.CreateClient(
|
||||
secret: "s3cr3t!",
|
||||
@@ -67,7 +68,19 @@ public sealed class TokenPersistenceIntegrationTests
|
||||
await using var scope = provider.CreateAsyncScope();
|
||||
var sessionAccessor = scope.ServiceProvider.GetRequiredService<IAuthorityMongoSessionAccessor>();
|
||||
var options = TestHelpers.CreateAuthorityOptions();
|
||||
var validateHandler = new ValidateClientCredentialsHandler(clientStore, registry, TestActivitySource, authSink, metadataAccessor, clock, new NoopCertificateValidator(), new HttpContextAccessor(), options, NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
var validateHandler = new ValidateClientCredentialsHandler(
|
||||
clientStore,
|
||||
registry,
|
||||
TestActivitySource,
|
||||
authSink,
|
||||
metadataAccessor,
|
||||
serviceAccountStore,
|
||||
tokenStore,
|
||||
clock,
|
||||
new NoopCertificateValidator(),
|
||||
new HttpContextAccessor(),
|
||||
options,
|
||||
NullLogger<ValidateClientCredentialsHandler>.Instance);
|
||||
var handleHandler = new HandleClientCredentialsHandler(registry, tokenStore, sessionAccessor, metadataAccessor, clock, TestActivitySource, NullLogger<HandleClientCredentialsHandler>.Instance);
|
||||
var persistHandler = new PersistTokensHandler(tokenStore, sessionAccessor, clock, TestActivitySource, NullLogger<PersistTokensHandler>.Instance);
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
namespace StellaOps.Authority.OpenIddict;
|
||||
|
||||
internal static class AuthorityOpenIddictConstants
|
||||
{
|
||||
internal const string ProviderParameterName = "authority_provider";
|
||||
internal const string ProviderTransactionProperty = "authority:identity_provider";
|
||||
internal const string ClientTransactionProperty = "authority:client";
|
||||
internal const string ClientProviderTransactionProperty = "authority:client_provider";
|
||||
internal const string ClientGrantedScopesProperty = "authority:client_granted_scopes";
|
||||
internal const string TokenTransactionProperty = "authority:token";
|
||||
internal const string AuditCorrelationProperty = "authority:audit_correlation_id";
|
||||
namespace StellaOps.Authority.OpenIddict;
|
||||
|
||||
internal static class AuthorityOpenIddictConstants
|
||||
{
|
||||
internal const string ProviderParameterName = "authority_provider";
|
||||
internal const string ProviderTransactionProperty = "authority:identity_provider";
|
||||
internal const string ClientTransactionProperty = "authority:client";
|
||||
internal const string ClientProviderTransactionProperty = "authority:client_provider";
|
||||
internal const string ClientGrantedScopesProperty = "authority:client_granted_scopes";
|
||||
internal const string TokenTransactionProperty = "authority:token";
|
||||
internal const string AuditCorrelationProperty = "authority:audit_correlation_id";
|
||||
internal const string AuditClientIdProperty = "authority:audit_client_id";
|
||||
internal const string AuditProviderProperty = "authority:audit_provider";
|
||||
internal const string AuditConfidentialProperty = "authority:audit_confidential";
|
||||
@@ -46,14 +46,9 @@ internal static class AuthorityOpenIddictConstants
|
||||
internal const string BackfillTicketProperty = "authority:backfill_ticket";
|
||||
internal const string BackfillReasonParameterName = "backfill_reason";
|
||||
internal const string BackfillTicketParameterName = "backfill_ticket";
|
||||
internal const string PolicyReasonProperty = "authority:policy_reason";
|
||||
internal const string PolicyTicketProperty = "authority:policy_ticket";
|
||||
internal const string PolicyDigestProperty = "authority:policy_digest";
|
||||
internal const string PolicyOperationProperty = "authority:policy_operation";
|
||||
internal const string PolicyAuditPropertiesProperty = "authority:policy_audit_properties";
|
||||
internal const string PolicyReasonParameterName = "policy_reason";
|
||||
internal const string PolicyTicketParameterName = "policy_ticket";
|
||||
internal const string PolicyDigestParameterName = "policy_digest";
|
||||
internal const string PolicyOperationPublishValue = "publish";
|
||||
internal const string PolicyOperationPromoteValue = "promote";
|
||||
internal const string ServiceAccountParameterName = "service_account";
|
||||
internal const string DelegationActorParameterName = "delegation_actor";
|
||||
internal const string ServiceAccountProperty = "authority:service_account";
|
||||
internal const string TokenKindProperty = "authority:token_kind";
|
||||
internal const string ActorChainProperty = "authority:actor_chain";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.Authority.OpenIddict;
|
||||
|
||||
internal static class AuthorityTokenKinds
|
||||
{
|
||||
internal const string ServiceAccount = "service_account";
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,9 +31,10 @@ using StellaOps.Authority.Notifications.Ack;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Plugins;
|
||||
using StellaOps.Authority.Bootstrap;
|
||||
using StellaOps.Authority.Storage.Mongo.Extensions;
|
||||
using StellaOps.Authority.Storage.Mongo.Initialization;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.Storage.Mongo.Extensions;
|
||||
using StellaOps.Authority.Storage.Mongo.Initialization;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.Storage.Mongo.Sessions;
|
||||
using StellaOps.Authority.RateLimiting;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Plugin.DependencyInjection;
|
||||
@@ -51,7 +52,6 @@ using StellaOps.Authority.Signing;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.Kms;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.Security;
|
||||
using StellaOps.Authority.OpenApi;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
@@ -1201,11 +1201,11 @@ if (authorityOptions.Bootstrap.Enabled)
|
||||
}
|
||||
});
|
||||
|
||||
bootstrapGroup.MapGet("/revocations/export", async (
|
||||
AuthorityRevocationExportService exportService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var package = await exportService.ExportAsync(cancellationToken).ConfigureAwait(false);
|
||||
bootstrapGroup.MapGet("/revocations/export", async (
|
||||
AuthorityRevocationExportService exportService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var package = await exportService.ExportAsync(cancellationToken).ConfigureAwait(false);
|
||||
var build = package.Bundle;
|
||||
|
||||
var response = new RevocationExportResponse
|
||||
@@ -1232,14 +1232,272 @@ if (authorityOptions.Bootstrap.Enabled)
|
||||
}
|
||||
};
|
||||
|
||||
return Results.Ok(response);
|
||||
});
|
||||
|
||||
bootstrapGroup.MapPost("/signing/rotate", (
|
||||
SigningRotationRequest? request,
|
||||
AuthoritySigningKeyManager signingManager,
|
||||
ILogger<AuthoritySigningKeyManager> signingLogger) =>
|
||||
{
|
||||
return Results.Ok(response);
|
||||
});
|
||||
|
||||
bootstrapGroup.MapGet("/service-accounts", async (
|
||||
string? tenant,
|
||||
IAuthorityServiceAccountStore accountStore,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
return Results.BadRequest(new { error = "invalid_request", message = "Query parameter 'tenant' is required." });
|
||||
}
|
||||
|
||||
var documents = await accountStore.ListByTenantAsync(tenant, cancellationToken).ConfigureAwait(false);
|
||||
if (documents.Count == 0)
|
||||
{
|
||||
return Results.Ok(Array.Empty<ServiceAccountResponse>());
|
||||
}
|
||||
|
||||
var response = documents
|
||||
.OrderBy(account => account.AccountId, StringComparer.Ordinal)
|
||||
.Select(MapServiceAccount)
|
||||
.ToArray();
|
||||
|
||||
return Results.Ok(response);
|
||||
});
|
||||
|
||||
bootstrapGroup.MapGet("/service-accounts/{accountId}/tokens", async (
|
||||
string accountId,
|
||||
IAuthorityServiceAccountStore accountStore,
|
||||
IAuthorityTokenStore tokenStore,
|
||||
IAuthorityMongoSessionAccessor sessionAccessor,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(accountId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "invalid_request", message = "Account identifier is required." });
|
||||
}
|
||||
|
||||
var document = await accountStore.FindByAccountIdAsync(accountId, cancellationToken).ConfigureAwait(false);
|
||||
if (document is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var session = await sessionAccessor.GetSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
var tokens = await tokenStore.ListActiveDelegationTokensAsync(document.Tenant, document.AccountId, cancellationToken, session).ConfigureAwait(false);
|
||||
|
||||
var response = tokens
|
||||
.Select(MapDelegatedToken)
|
||||
.ToArray();
|
||||
|
||||
return Results.Ok(response);
|
||||
});
|
||||
|
||||
bootstrapGroup.MapPost("/service-accounts/{accountId}/revocations", async (
|
||||
string accountId,
|
||||
ServiceAccountRevokeRequest? request,
|
||||
HttpContext httpContext,
|
||||
IAuthorityServiceAccountStore accountStore,
|
||||
IAuthorityTokenStore tokenStore,
|
||||
IAuthorityMongoSessionAccessor sessionAccessor,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "invalid_request", message = "Request payload is required." });
|
||||
}
|
||||
|
||||
var document = await accountStore.FindByAccountIdAsync(accountId, cancellationToken).ConfigureAwait(false);
|
||||
if (document is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var session = await sessionAccessor.GetSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var targetTokens = new List<AuthorityTokenDocument>();
|
||||
if (!string.IsNullOrWhiteSpace(request.TokenId))
|
||||
{
|
||||
var token = await tokenStore.FindByTokenIdAsync(request.TokenId.Trim(), cancellationToken, session).ConfigureAwait(false);
|
||||
if (token is not null &&
|
||||
string.Equals(token.ServiceAccountId, document.AccountId, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(token.TokenKind, "service_account", StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(token.Tenant, document.Tenant, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
targetTokens.Add(token);
|
||||
}
|
||||
else
|
||||
{
|
||||
return Results.NotFound(new { error = "not_found", message = "Delegated token not found for service account." });
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var active = await tokenStore.ListActiveDelegationTokensAsync(document.Tenant, document.AccountId, cancellationToken, session).ConfigureAwait(false);
|
||||
targetTokens.AddRange(active);
|
||||
}
|
||||
|
||||
if (targetTokens.Count == 0)
|
||||
{
|
||||
await auditSink.WriteAsync(new AuthEventRecord
|
||||
{
|
||||
EventType = "authority.delegation.revoked",
|
||||
OccurredAt = now,
|
||||
Outcome = AuthEventOutcome.Failure,
|
||||
Reason = request.Reason ?? "no_active_tokens",
|
||||
CorrelationId = Activity.Current?.TraceId.ToString() ?? httpContext.TraceIdentifier ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture),
|
||||
Subject = new AuthEventSubject
|
||||
{
|
||||
SubjectId = ClassifiedString.Public(document.AccountId),
|
||||
Realm = ClassifiedString.Public(document.Tenant)
|
||||
},
|
||||
Tenant = ClassifiedString.Public(document.Tenant),
|
||||
Properties = new[]
|
||||
{
|
||||
new AuthEventProperty { Name = "delegation.service_account", Value = ClassifiedString.Public(document.AccountId) },
|
||||
new AuthEventProperty { Name = "delegation.revoked_count", Value = ClassifiedString.Public("0") }
|
||||
}
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new ServiceAccountRevokeResponse(0, Array.Empty<string>()));
|
||||
}
|
||||
|
||||
var revokedTokens = new List<string>(targetTokens.Count);
|
||||
foreach (var token in targetTokens)
|
||||
{
|
||||
if (string.Equals(token.Status, "revoked", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["delegation.service_account"] = document.AccountId
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Reason))
|
||||
{
|
||||
metadata["delegation.reason"] = request.Reason;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.TokenId))
|
||||
{
|
||||
metadata["delegation.token"] = request.TokenId;
|
||||
}
|
||||
|
||||
await tokenStore.UpdateStatusAsync(
|
||||
token.TokenId,
|
||||
"revoked",
|
||||
now,
|
||||
string.IsNullOrWhiteSpace(request.Reason) ? "delegation_revoked" : request.Reason,
|
||||
request.ReasonDescription,
|
||||
metadata,
|
||||
cancellationToken,
|
||||
session).ConfigureAwait(false);
|
||||
|
||||
revokedTokens.Add(token.TokenId);
|
||||
}
|
||||
|
||||
var orderedRevokedTokens = revokedTokens
|
||||
.OrderBy(tokenId => tokenId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var properties = new List<AuthEventProperty>
|
||||
{
|
||||
new() { Name = "delegation.service_account", Value = ClassifiedString.Public(document.AccountId) },
|
||||
new() { Name = "delegation.revoked_count", Value = ClassifiedString.Public(orderedRevokedTokens.Length.ToString(CultureInfo.InvariantCulture)) }
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Reason))
|
||||
{
|
||||
properties.Add(new AuthEventProperty { Name = "delegation.reason", Value = ClassifiedString.Public(request.Reason) });
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.ReasonDescription))
|
||||
{
|
||||
properties.Add(new AuthEventProperty { Name = "delegation.reason_description", Value = ClassifiedString.Public(request.ReasonDescription) });
|
||||
}
|
||||
|
||||
for (var index = 0; index < orderedRevokedTokens.Length; index++)
|
||||
{
|
||||
properties.Add(new AuthEventProperty
|
||||
{
|
||||
Name = $"delegation.revoked_token[{index}]",
|
||||
Value = ClassifiedString.Public(orderedRevokedTokens[index])
|
||||
});
|
||||
}
|
||||
|
||||
await auditSink.WriteAsync(new AuthEventRecord
|
||||
{
|
||||
EventType = "authority.delegation.revoked",
|
||||
OccurredAt = now,
|
||||
Outcome = orderedRevokedTokens.Length > 0 ? AuthEventOutcome.Success : AuthEventOutcome.Failure,
|
||||
Reason = request.Reason,
|
||||
CorrelationId = Activity.Current?.TraceId.ToString() ?? httpContext.TraceIdentifier ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture),
|
||||
Subject = new AuthEventSubject
|
||||
{
|
||||
SubjectId = ClassifiedString.Public(document.AccountId),
|
||||
Realm = ClassifiedString.Public(document.Tenant)
|
||||
},
|
||||
Tenant = ClassifiedString.Public(document.Tenant),
|
||||
Properties = properties
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new ServiceAccountRevokeResponse(orderedRevokedTokens.Length, orderedRevokedTokens));
|
||||
});
|
||||
|
||||
static ServiceAccountResponse MapServiceAccount(AuthorityServiceAccountDocument document)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
var scopes = document.AllowedScopes is { Count: > 0 }
|
||||
? document.AllowedScopes.OrderBy(scope => scope, StringComparer.Ordinal).ToArray()
|
||||
: Array.Empty<string>();
|
||||
|
||||
var clients = document.AuthorizedClients is { Count: > 0 }
|
||||
? document.AuthorizedClients.OrderBy(client => client, StringComparer.Ordinal).ToArray()
|
||||
: Array.Empty<string>();
|
||||
|
||||
return new ServiceAccountResponse(
|
||||
document.AccountId,
|
||||
document.Tenant,
|
||||
document.DisplayName,
|
||||
document.Description,
|
||||
document.Enabled,
|
||||
scopes,
|
||||
clients);
|
||||
}
|
||||
|
||||
static ServiceAccountTokenResponse MapDelegatedToken(AuthorityTokenDocument document)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
var scopes = document.Scope is { Count: > 0 }
|
||||
? document.Scope.OrderBy(scope => scope, StringComparer.Ordinal).ToArray()
|
||||
: Array.Empty<string>();
|
||||
|
||||
var actors = document.ActorChain is { Count: > 0 }
|
||||
? document.ActorChain
|
||||
.Where(actor => !string.IsNullOrWhiteSpace(actor))
|
||||
.Select(actor => actor.Trim())
|
||||
.Where(actor => actor.Length > 0)
|
||||
.OrderBy(actor => actor, StringComparer.Ordinal)
|
||||
.ToArray()
|
||||
: Array.Empty<string>();
|
||||
|
||||
return new ServiceAccountTokenResponse(
|
||||
document.TokenId,
|
||||
document.ClientId,
|
||||
document.Status,
|
||||
document.CreatedAt,
|
||||
document.ExpiresAt,
|
||||
document.SenderConstraint,
|
||||
scopes,
|
||||
actors);
|
||||
}
|
||||
|
||||
bootstrapGroup.MapPost("/signing/rotate", (
|
||||
SigningRotationRequest? request,
|
||||
AuthoritySigningKeyManager signingManager,
|
||||
ILogger<AuthoritySigningKeyManager> signingLogger) =>
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
signingLogger.LogWarning("Signing rotation request payload missing.");
|
||||
@@ -2457,16 +2715,16 @@ app.MapGet("/jwks", (AuthorityJwksService jwksService, HttpContext context) =>
|
||||
.WithName("JsonWebKeySet");
|
||||
|
||||
// Ensure signing key manager initialises key material on startup.
|
||||
app.Services.GetRequiredService<AuthorityAckTokenKeyManager>();
|
||||
app.Services.GetRequiredService<AuthoritySigningKeyManager>();
|
||||
|
||||
app.Run();
|
||||
|
||||
static PluginHostOptions BuildPluginHostOptions(StellaOpsAuthorityOptions options, string basePath)
|
||||
{
|
||||
var pluginDirectory = options.PluginDirectories.FirstOrDefault();
|
||||
var hostOptions = new PluginHostOptions
|
||||
{
|
||||
app.Services.GetRequiredService<AuthorityAckTokenKeyManager>();
|
||||
app.Services.GetRequiredService<AuthoritySigningKeyManager>();
|
||||
|
||||
app.Run();
|
||||
|
||||
static PluginHostOptions BuildPluginHostOptions(StellaOpsAuthorityOptions options, string basePath)
|
||||
{
|
||||
var pluginDirectory = options.PluginDirectories.FirstOrDefault();
|
||||
var hostOptions = new PluginHostOptions
|
||||
{
|
||||
BaseDirectory = basePath,
|
||||
PluginsDirectory = string.IsNullOrWhiteSpace(pluginDirectory)
|
||||
? "StellaOps.Authority.PluginBinaries"
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Authority;
|
||||
|
||||
internal sealed record ServiceAccountResponse(
|
||||
string AccountId,
|
||||
string Tenant,
|
||||
string? DisplayName,
|
||||
string? Description,
|
||||
bool Enabled,
|
||||
IReadOnlyList<string> AllowedScopes,
|
||||
IReadOnlyList<string> AuthorizedClients);
|
||||
|
||||
internal sealed record ServiceAccountTokenResponse(
|
||||
string TokenId,
|
||||
string? ClientId,
|
||||
string Status,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
string? SenderConstraint,
|
||||
IReadOnlyList<string> Scopes,
|
||||
IReadOnlyList<string> Actors);
|
||||
|
||||
internal sealed record ServiceAccountRevokeRequest(
|
||||
string? TokenId,
|
||||
string? Reason,
|
||||
string? ReasonDescription);
|
||||
|
||||
internal sealed record ServiceAccountRevokeResponse(int RevokedCount, IReadOnlyList<string> TokenIds);
|
||||
@@ -130,6 +130,7 @@
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
> 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 | DOING (2025-11-02) | 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: 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.
|
||||
|
||||
## Observability & Forensics (Epic 15)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user