Skip to content

Commit 04b4e78

Browse files
committed
fix(customization): enforce approved content type quota
Serialize tenant-wide count and registration before the existing concept lock. Apply the approved distinct-key accounting without changing default unlimited entitlements or lifecycle authority. Keep hard-limit refusals consistent across HTTP, audit and job classification. Register both CI Python tools in the exact source inventory; unknown formats still fail closed. Retain the failed CI and planted-lock evidence. ADR: 0021, 0040, 0045 Module: Customization I18n: lockey_limit_exceeded
1 parent 466d88e commit 04b4e78

24 files changed

Lines changed: 543 additions & 26 deletions

File tree

‎backend/src/LearnStack.Api/Common/HttpStatusMap.cs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ public static int For(Error error)
2727
"forbidden" => (int)HttpStatusCode.Forbidden,
2828
"resource_scope_violation" => (int)HttpStatusCode.Forbidden,
2929
"feature_disabled" => (int)HttpStatusCode.Forbidden,
30+
"limit_exceeded" => (int)HttpStatusCode.Forbidden,
3031
"not_found" => (int)HttpStatusCode.NotFound,
3132
"method_not_allowed" => (int)HttpStatusCode.MethodNotAllowed,
3233
"payload_too_large" => (int)HttpStatusCode.RequestEntityTooLarge,

‎backend/src/LearnStack.Application/Audit/AuditExecutionCoordinator.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,7 @@ private async Task DeclareAsync(
381381
/// </remarks>
382382
private static AuditOutcome OutcomeOf(Error error) => error.Code switch
383383
{
384-
"forbidden" or "resource_scope_violation" or "feature_disabled" => AuditOutcome.Denied,
384+
"forbidden" or "resource_scope_violation" or "feature_disabled" or "limit_exceeded" => AuditOutcome.Denied,
385385
_ => AuditOutcome.Failed,
386386
};
387387

‎backend/src/LearnStack.Infrastructure/Jobs/BackgroundJobFailurePolicy.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ internal static TimeSpan GetRetryDelay(int committedNormalAttempts) => committed
7373
{
7474
"concurrency_conflict" or "rate_limited" or "dependency_unavailable" or "audit_unavailable" => Retry(code),
7575
"validation_failed" or "unauthorized" or "forbidden" or "tenant_mismatch" or "resource_scope_violation"
76-
or "not_found" or "business_rule_violation" or "feature_disabled" or "unsupported_locale"
76+
or "not_found" or "business_rule_violation" or "feature_disabled" or "limit_exceeded" or "unsupported_locale"
7777
or "audit_unclassified_operation" => Permanent(),
7878
_ => Retry("unexpected_fault"),
7979
};

‎backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Abstractions/CustomizationStores.cs‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44

55
namespace LearnStack.Modules.Customization.Application.Abstractions;
66

7-
/// <summary>The <c>TenantContentType</c> aggregate's writes, and the two reads a
8-
/// publish needs.</summary>
7+
/// <summary>The <c>TenantContentType</c> aggregate's writes, coordination and reads
8+
/// for registration and publication.</summary>
99
/// <remarks>
1010
/// <para>
1111
/// Declared here and implemented across the boundary, for the reason
@@ -34,6 +34,12 @@ namespace LearnStack.Modules.Customization.Application.Abstractions;
3434
public interface ITenantContentTypeStore
3535
: IAggregateWriteStore<TenantContentType, TenantContentTypeId>
3636
{
37+
/// <summary>Serializes custom-key registrations for the tenant until transaction end, before taking a concept lock.</summary>
38+
Task LockRegistrationsAsync(CancellationToken cancellationToken = default);
39+
40+
/// <summary>Distinct nondeleted custom keys and whether this key already has a nondeleted revision; excludes the built-in card.</summary>
41+
Task<ContentTypeQuotaUsage> ReadQuotaUsageAsync(string key, CancellationToken cancellationToken = default);
42+
3743
/// <summary>Serializes registration and initialization for this tenant's concept until transaction end.</summary>
3844
Task LockConceptAsync(string key, CancellationToken cancellationToken = default);
3945

@@ -56,6 +62,9 @@ public interface ITenantContentTypeStore
5662
string key, CancellationToken cancellationToken = default);
5763
}
5864

65+
/// <summary>The tenant's authoring usage; revisions share one custom-key slot.</summary>
66+
public sealed record ContentTypeQuotaUsage(long CustomKeyCount, bool HasNondeletedRevision);
67+
5968
/// <summary>The <c>TenantLevelTaxonomy</c> aggregate's writes, and the two reads a
6069
/// publish needs.</summary>
6170
/// <remarks>

‎backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Customization/CustomizationFailures.cs‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using LearnStack.SharedKernel.Localization;
2+
using LearnStack.SharedKernel.Entitlements;
23
using LearnStack.SharedKernel.Results;
34
using LearnStack.SharedKernel.Validation;
45

@@ -9,8 +10,8 @@ namespace LearnStack.Modules.Customization.Application.Customization;
910
/// </summary>
1011
/// <remarks>
1112
/// The top-level code stays a cross-cutting one — <c>validation_failed</c>,
12-
/// <c>not_found</c>, <c>business_rule_violation</c>, <c>concurrency_conflict</c>
13-
/// and <c>tenant_mismatch</c> are the five this module emits — because
13+
/// <c>not_found</c>, <c>business_rule_violation</c>, <c>concurrency_conflict</c>,
14+
/// <c>limit_exceeded</c> and <c>tenant_mismatch</c> are the six this module emits — because
1415
/// <c>HttpStatusMap</c> is a closed table and a module-specific key there falls
1516
/// through to <c>500</c>: the one answer a refusal must never give. What is
1617
/// module-specific goes in the field entry, where a client reads it.
@@ -118,6 +119,21 @@ internal static Result<T> Stale<T>() =>
118119
internal static Result<T> BusinessRule<T>(string field, string reason) =>
119120
Field<T>(BusinessRuleViolation, field, reason);
120121

122+
internal static Result<T> ContentTypeLimit<T>(long limit)
123+
{
124+
var message = new LocalizedMessage("lockey_limit_exceeded", new Dictionary<string, string>(StringComparer.Ordinal)
125+
{
126+
["limitKey"] = LimitKeys.MaxCustomContentTypes.Value,
127+
["limit"] = limit.ToString(System.Globalization.CultureInfo.InvariantCulture),
128+
});
129+
return Result<T>.Fail(new Error(message, new Dictionary<string, IReadOnlyList<LocalizedMessage>>(StringComparer.Ordinal)
130+
{
131+
// Problem Details projects parameters from field messages, so the
132+
// refusal names the ceiling on the actual wire as well as the Result.
133+
["Key"] = [message],
134+
}));
135+
}
136+
121137
internal static Result<T> Field<T>(string code, string field, string reason) =>
122138
Result.FailFor<Result<T>>(new Error(
123139
new LocalizedMessage(code),

‎backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Customization/RegisterTenantContentTypeCommandHandler.cs‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using LearnStack.Modules.Customization.Application.Abstractions;
22
using LearnStack.Modules.Customization.Application.Contracts.Customization;
33
using LearnStack.Modules.Customization.Domain;
4+
using LearnStack.SharedKernel.Entitlements;
45
using LearnStack.SharedKernel.Identifiers;
56
using LearnStack.SharedKernel.Localization;
67
using LearnStack.SharedKernel.Persistence;
@@ -37,6 +38,7 @@ internal sealed class RegisterTenantContentTypeCommandHandler(
3738
ICustomizationGenerationStore generations,
3839
ITenantLevelTaxonomyCatalog taxonomies,
3940
IJsonSchemaValidator schemas,
41+
IFeatureFlags features,
4042
ITenantContext tenantContext,
4143
IClock clock)
4244
: IRequestHandler<RegisterTenantContentTypeCommand, Result<TenantContentTypeDto>>
@@ -96,7 +98,22 @@ public async Task<Result<TenantContentTypeDto>> Handle(
9698

9799
try
98100
{
101+
// All custom-key registrars take the tenant lock before the concept
102+
// lock. Different keys otherwise race the same tenant-wide ceiling.
103+
// Card does not consume a slot; initialization keeps its history lock.
104+
var custom = request.Key != BuiltInCustomizations.Card.Key;
105+
if (custom) await contentTypes.LockRegistrationsAsync(cancellationToken);
99106
await contentTypes.LockConceptAsync(request.Key, cancellationToken);
107+
if (custom && !await features.IsEnabledAsync(FeatureKeys.UnlimitedContentTypes, cancellationToken))
108+
{
109+
var limit = await features.GetLimitAsync(LimitKeys.MaxCustomContentTypes, cancellationToken);
110+
if (limit != LimitKeys.Unlimited)
111+
{
112+
var usage = await contentTypes.ReadQuotaUsageAsync(request.Key, cancellationToken);
113+
if (!usage.HasNondeletedRevision && usage.CustomKeyCount >= limit)
114+
return CustomizationFailures.ContentTypeLimit<TenantContentTypeDto>(limit);
115+
}
116+
}
100117
await contentTypes.AddAsync(contentType, cancellationToken);
101118
}
102119
catch (AggregateConflictException conflict)

‎backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Persistence/CustomizationConceptHistory.cs‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System.Security.Cryptography;
33
using System.Text;
44
using LearnStack.Modules.Customization.Domain;
5+
using LearnStack.Modules.Customization.Application.Contracts.Customization;
56
using LearnStack.SharedKernel.Identifiers;
67
using Microsoft.EntityFrameworkCore;
78
using Microsoft.EntityFrameworkCore.Storage;
@@ -19,6 +20,18 @@ internal static async Task LockAsync(
1920
// A stable namespace includes tenant and kind, but deliberately no revision.
2021
// A hash collision only serializes unrelated writers; it cannot grant access.
2122
var identity = $"customization:{tenant.Value:D}:{(taxonomy ? "taxonomy" : "content-type")}:{key}";
23+
await LockIdentityAsync(identity, connection, transaction, cancellationToken);
24+
}
25+
26+
internal static async Task LockRegistrationsAsync(CustomizationDbContext db, CancellationToken cancellationToken)
27+
{
28+
var (tenant, connection, transaction) = RequireContext(db, BuiltInCustomizations.Card.Key);
29+
await LockIdentityAsync($"customization:{tenant.Value:D}:content-type-quota", connection, transaction, cancellationToken);
30+
}
31+
32+
private static async Task LockIdentityAsync(string identity, NpgsqlConnection connection,
33+
NpgsqlTransaction transaction, CancellationToken cancellationToken)
34+
{
2235
var digest = SHA256.HashData(Encoding.UTF8.GetBytes(identity));
2336
var lockKey = BinaryPrimitives.ReadInt64BigEndian(digest);
2437
await using var command = new NpgsqlCommand(

‎backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Persistence/CustomizationWriteStores.cs‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using LearnStack.Infrastructure.Persistence;
22
using LearnStack.Modules.Customization.Application.Abstractions;
3+
using LearnStack.Modules.Customization.Application.Contracts.Customization;
34
using LearnStack.Modules.Customization.Domain;
45
using LearnStack.Modules.Customization.Infrastructure.Projections;
56
using Microsoft.EntityFrameworkCore;
@@ -41,6 +42,19 @@ namespace LearnStack.Modules.Customization.Infrastructure.Persistence;
4142
/// </remarks>
4243
public sealed class TenantContentTypeStore(CustomizationDbContext db, CustomizationReadState reads) : ITenantContentTypeStore
4344
{
45+
public Task LockRegistrationsAsync(CancellationToken cancellationToken = default) =>
46+
CustomizationConceptHistory.LockRegistrationsAsync(db, cancellationToken);
47+
48+
public async Task<ContentTypeQuotaUsage> ReadQuotaUsageAsync(string key, CancellationToken cancellationToken = default)
49+
{
50+
CustomizationKey.EnsureValid(key, nameof(key));
51+
var customKeys = db.TenantContentTypes
52+
.Where(row => row.DeletedAt == null && row.Key != BuiltInCustomizations.Card.Key)
53+
.Select(row => row.Key).Distinct();
54+
return new(await customKeys.LongCountAsync(cancellationToken),
55+
await customKeys.AnyAsync(existing => existing == key, cancellationToken));
56+
}
57+
4458
public Task LockConceptAsync(string key, CancellationToken cancellationToken = default) =>
4559
CustomizationConceptHistory.LockAsync(db, taxonomy: false, key, cancellationToken);
4660

0 commit comments

Comments
 (0)