feat: add tenant provisioning and key lifecycle (#5)
quality-gate / quality (push) Successful in 50s

Closes #5
This commit is contained in:
KyuubiYoru
2026-07-16 05:13:35 +02:00
parent 69c8b2d2bc
commit 47382ddadc
21 changed files with 2294 additions and 6 deletions
@@ -0,0 +1,83 @@
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Provisioning;
namespace FinalFactory.Rendezvous.Tests.Provisioning;
public sealed class GamePolicyTests
{
[Fact]
public void RegistryFailsClosedForUnknownAndDisabledScopes()
{
GamePolicyRegistry registry = GamePolicyRegistry.Create(
[
ProvisioningTestData.CreatePolicy(),
ProvisioningTestData.CreatePolicy("unscouted", "staging", enabled: false),
]);
Assert.True(registry.TryGet(
new GameId("space-game"),
new EnvironmentId("production"),
out _));
Assert.False(registry.TryGet(
new GameId("space-game"),
new EnvironmentId("staging"),
out _));
Assert.False(registry.TryGet(
new GameId("unscouted"),
new EnvironmentId("staging"),
out _));
}
[Fact]
public void PerGamePolicyConstrainsProtocolMetadataQuotasAndFeatures()
{
GamePolicyRegistry registry = GamePolicyRegistry.Create(
[ProvisioningTestData.CreatePolicy()]);
Assert.True(registry.TryGet(
new GameId("space-game"),
new EnvironmentId("production"),
out GamePolicy? policy));
Assert.NotNull(policy);
Assert.True(policy.AllowsProtocol(7));
Assert.False(policy.AllowsProtocol(8));
Assert.True(policy.AllowsRegion(new RegionId("eu-central")));
Assert.False(policy.AllowsRegion(new RegionId("us-east")));
Assert.True(policy.AllowsVisibility(ListingVisibility.Public));
Assert.True(policy.AllowsPublisherTrust(PublisherTrustMode.PlayerGrant));
Assert.Equal(FallbackPolicyMode.DedicatedEndpointAllowed, policy.FallbackPolicy);
Assert.Equal(10, policy.MaxListingsPerPrincipal);
Assert.Equal(1, policy.MaxAnonymousListingsPerAddress);
Assert.Equal(100, policy.MaxActiveJoinAttempts);
Assert.True(policy.AllowsMetadata(new Dictionary<string, string>
{
["mode"] = "co-op",
["map"] = "europa",
}));
Assert.False(policy.AllowsMetadata(new Dictionary<string, string>
{
["map"] = "europa",
}));
Assert.False(policy.AllowsMetadata(new Dictionary<string, string>
{
["mode"] = "co-op",
["unknown"] = "value",
}));
}
[Fact]
public void InvalidOrDuplicatePolicyConfigurationFailsAtStartup()
{
GamePolicyOptions invalid = ProvisioningTestData.CreatePolicy();
invalid.ProtocolVersions = [];
Assert.Throws<ProvisioningConfigurationException>(() =>
GamePolicyRegistry.Create([invalid]));
Assert.Throws<ProvisioningConfigurationException>(() =>
GamePolicyRegistry.Create(
[
ProvisioningTestData.CreatePolicy(),
ProvisioningTestData.CreatePolicy(),
]));
}
}
@@ -0,0 +1,225 @@
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Provisioning;
namespace FinalFactory.Rendezvous.Tests.Provisioning;
public sealed class PrincipalCredentialTests
{
[Fact]
public void DedicatedAndPlayerGrantCredentialsRoundtripToDistinctPrincipals()
{
using DictionarySecretProvider secrets = ProvisioningTestData.CreateSecrets("secret-1");
using SigningKeyRing keys = SigningKeyRing.Create(
[ProvisioningTestData.CreateKey()],
secrets);
PrincipalCredentialService service = CreateService(keys);
DedicatedPublisherPrincipal dedicated = ProvisioningTestData.CreateDedicatedPublisher();
PlayerHostGrantPrincipal playerGrant = new(
"host-grant-7",
ProvisioningTestData.Now.AddMinutes(5),
dedicated.GameId,
dedicated.EnvironmentId,
dedicated.AllowedRegions);
CredentialValidationResult dedicatedResult = service.Validate(
service.Issue(dedicated, ProvisioningTestData.Now),
ProvisioningTestData.Now);
CredentialValidationResult grantResult = service.Validate(
service.Issue(playerGrant, ProvisioningTestData.Now),
ProvisioningTestData.Now);
Assert.IsType<DedicatedPublisherPrincipal>(dedicatedResult.Principal);
Assert.IsType<PlayerHostGrantPrincipal>(grantResult.Principal);
}
[Fact]
public void WrongIssuerAndAudienceAreRejectedAfterSignatureValidation()
{
using DictionarySecretProvider secrets = ProvisioningTestData.CreateSecrets("secret-1");
using SigningKeyRing keys = SigningKeyRing.Create(
[ProvisioningTestData.CreateKey()],
secrets);
PrincipalCredentialService issuer = CreateService(keys);
string token = issuer.Issue(
ProvisioningTestData.CreateDedicatedPublisher(),
ProvisioningTestData.Now);
PrincipalCredentialService wrongIssuer = new(
"other-issuer",
"rendezvous-service",
TimeSpan.FromSeconds(30),
keys);
PrincipalCredentialService wrongAudience = new(
"final-factory-rendezvous",
"other-audience",
TimeSpan.FromSeconds(30),
keys);
Assert.Equal(
CredentialValidationError.IssuerMismatch,
wrongIssuer.Validate(token, ProvisioningTestData.Now).Error);
Assert.Equal(
CredentialValidationError.AudienceMismatch,
wrongAudience.Validate(token, ProvisioningTestData.Now).Error);
}
[Fact]
public void ExpiryTamperingAndRevocationAreRejected()
{
using DictionarySecretProvider secrets = ProvisioningTestData.CreateSecrets("secret-1");
using SigningKeyRing keys = SigningKeyRing.Create(
[ProvisioningTestData.CreateKey()],
secrets);
PrincipalCredentialService service = CreateService(keys);
string token = service.Issue(
ProvisioningTestData.CreateDedicatedPublisher(
ProvisioningTestData.Now.AddMinutes(1)),
ProvisioningTestData.Now);
char replacement = token[^1] == 'A' ? 'B' : 'A';
string tampered = token[..^1] + replacement;
Assert.Equal(
CredentialValidationError.Expired,
service.Validate(token, ProvisioningTestData.Now.AddSeconds(91)).Error);
Assert.Equal(
CredentialValidationError.SignatureInvalid,
service.Validate(tampered, ProvisioningTestData.Now).Error);
Assert.True(keys.Revoke("key-1"));
Assert.Equal(
CredentialValidationError.KeyRevoked,
service.Validate(token, ProvisioningTestData.Now).Error);
}
[Fact]
public void KeyRotationHonorsOverlapAndRejectsRetiredKeys()
{
SigningKeyOptions oldKey = ProvisioningTestData.CreateKey(
"old-key",
"old-secret",
ProvisioningTestData.Now.AddHours(-1),
ProvisioningTestData.Now.AddMinutes(10),
ProvisioningTestData.Now.AddMinutes(60));
SigningKeyOptions newKey = ProvisioningTestData.CreateKey(
"new-key",
"new-secret",
ProvisioningTestData.Now.AddMinutes(10),
ProvisioningTestData.Now.AddHours(2),
ProvisioningTestData.Now.AddHours(3));
using DictionarySecretProvider secrets = ProvisioningTestData.CreateSecrets(
"old-secret",
"new-secret");
using SigningKeyRing keys = SigningKeyRing.Create([oldKey, newKey], secrets);
PrincipalCredentialService service = CreateService(keys);
string oldToken = service.Issue(
ProvisioningTestData.CreateDedicatedPublisher(
ProvisioningTestData.Now.AddMinutes(50)),
ProvisioningTestData.Now);
Assert.True(service.Validate(oldToken, ProvisioningTestData.Now.AddMinutes(20)).IsValid);
Assert.Equal(
CredentialValidationError.KeyRetired,
service.Validate(oldToken, ProvisioningTestData.Now.AddMinutes(61)).Error);
string newToken = service.Issue(
ProvisioningTestData.CreateDedicatedPublisher(
ProvisioningTestData.Now.AddMinutes(90)),
ProvisioningTestData.Now.AddMinutes(20));
Assert.True(service.Validate(newToken, ProvisioningTestData.Now.AddMinutes(20)).IsValid);
}
[Fact]
public void OperatorCredentialNeverBecomesAPublisherPrincipal()
{
SigningKeyOptions operatorKey = ProvisioningTestData.CreateKey(
credentialKinds: [PrincipalCredentialKind.Operator],
gameId: null,
environmentId: null);
using DictionarySecretProvider secrets = ProvisioningTestData.CreateSecrets("secret-1");
using SigningKeyRing keys = SigningKeyRing.Create(
[operatorKey],
secrets);
PrincipalCredentialService service = CreateService(keys);
OperatorPrincipal operatorPrincipal = new(
"operator-1",
ProvisioningTestData.Now.AddMinutes(5),
new HashSet<OperatorPermission> { OperatorPermission.RotateKeys });
CredentialValidationResult result = service.Validate(
service.Issue(operatorPrincipal, ProvisioningTestData.Now),
ProvisioningTestData.Now);
Assert.IsType<OperatorPrincipal>(result.Principal);
Assert.IsNotAssignableFrom<IPublisherPrincipal>(result.Principal);
}
[Fact]
public void ConfiguredRevocationDoesNotRequireRetiredSecretMaterial()
{
SigningKeyOptions revoked = ProvisioningTestData.CreateKey(
"revoked-key",
"removed-secret",
revoked: true);
SigningKeyOptions active = ProvisioningTestData.CreateKey(
"active-key",
"active-secret");
using DictionarySecretProvider secrets = ProvisioningTestData.CreateSecrets("active-secret");
using SigningKeyRing keys = SigningKeyRing.Create([revoked, active], secrets);
Assert.Equal(
VerificationKeyLookup.Revoked,
keys.FindVerificationKey("revoked-key", ProvisioningTestData.Now, out _));
Assert.True(keys.TryGetSigningKey(
ProvisioningTestData.Now,
PrincipalCredentialKind.DedicatedPublisher,
"space-game",
"production",
out SigningKey? signingKey));
Assert.Equal("active-key", signingKey?.KeyId);
}
[Fact]
public void SigningKeyAuthorityRejectsCrossGameClaimsEvenWithAValidSignature()
{
using DictionarySecretProvider secrets = ProvisioningTestData.CreateSecrets("secret-1");
using SigningKeyRing keys = SigningKeyRing.Create(
[ProvisioningTestData.CreateKey()],
secrets);
Assert.Equal(
VerificationKeyLookup.Available,
keys.FindVerificationKey("key-1", ProvisioningTestData.Now, out SigningKey? signingKey));
Assert.NotNull(signingKey);
CredentialPayload payload = new()
{
Version = ContractLimits.ContractVersion,
Issuer = "final-factory-rendezvous",
Audience = "rendezvous-service",
Subject = "malicious-grant-issuer",
Kind = PrincipalCredentialKind.PlayerHostGrant,
GameId = "unscouted",
EnvironmentId = "production",
Regions = ["eu-central"],
IssuedAtUnixSeconds = ProvisioningTestData.Now.ToUnixTimeSeconds(),
NotBeforeUnixSeconds = ProvisioningTestData.Now.ToUnixTimeSeconds(),
ExpiresAtUnixSeconds = ProvisioningTestData.Now.AddMinutes(5).ToUnixTimeSeconds(),
Nonce = Guid.NewGuid().ToString("N"),
};
string encodedPayload = Base64Url.Encode(
System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(payload, ContractJson.Options));
string signedContent = $"rv1.key-1.{encodedPayload}";
string token = $"{signedContent}.{Base64Url.Encode(signingKey.Sign(signedContent))}";
CredentialValidationResult result = CreateService(keys).Validate(
token,
ProvisioningTestData.Now);
Assert.Equal(CredentialValidationError.KeyScopeMismatch, result.Error);
Assert.Null(result.Principal);
}
private static PrincipalCredentialService CreateService(SigningKeyRing keys) => new(
"final-factory-rendezvous",
"rendezvous-service",
TimeSpan.FromSeconds(30),
keys);
}
@@ -0,0 +1,97 @@
using System.Reflection;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Provisioning;
namespace FinalFactory.Rendezvous.Tests.Provisioning;
public sealed class ProvisioningSecurityTests
{
[Fact]
public void StartupFailsClearlyWhenKeyMaterialIsAbsent()
{
ProvisioningOptions options = ProvisioningTestData.CreateOptions(
ProvisioningTestData.CreateKey(secretReference: "missing-production-secret"));
using DictionarySecretProvider empty = ProvisioningTestData.CreateSecrets();
ProvisioningConfigurationException exception = Assert.Throws<ProvisioningConfigurationException>(
() => ProvisioningRuntime.Create(options, empty, ProvisioningTestData.Now));
Assert.Contains("key-1", exception.Message, StringComparison.Ordinal);
Assert.DoesNotContain("missing-production-secret", exception.Message, StringComparison.Ordinal);
}
[Fact]
public void StartupRequiresAnActivePublisherKeyForEveryEnabledPolicy()
{
ProvisioningOptions options = ProvisioningTestData.CreateOptions();
options.Games.Add(ProvisioningTestData.CreatePolicy("unscouted", "production"));
using DictionarySecretProvider secrets = ProvisioningTestData.CreateSecrets("secret-1");
ProvisioningConfigurationException exception = Assert.Throws<ProvisioningConfigurationException>(
() => ProvisioningRuntime.Create(options, secrets, ProvisioningTestData.Now));
Assert.Contains("unscouted/production", exception.Message, StringComparison.Ordinal);
Assert.Contains("key", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void SecretMaterialCredentialsAndKeysAreRedactedFromDiagnostics()
{
byte[] knownSecret = Enumerable.Range(1, 32).Select(static value => (byte)value).ToArray();
string encodedSecret = Convert.ToBase64String(knownSecret);
using SecretMaterial material = new(knownSecret);
using DictionarySecretProvider secrets = new(new Dictionary<string, byte[]>
{
["secret-1"] = knownSecret,
});
using SigningKeyRing keys = SigningKeyRing.Create(
[ProvisioningTestData.CreateKey()],
secrets);
PrincipalCredentialService service = new(
"final-factory-rendezvous",
"rendezvous-service",
TimeSpan.FromSeconds(30),
keys);
string token = service.Issue(
ProvisioningTestData.CreateDedicatedPublisher(),
ProvisioningTestData.Now);
CredentialValidationResult result = service.Validate(token, ProvisioningTestData.Now);
string diagnostics = string.Join(
'|',
material,
secrets,
keys,
service,
result);
Assert.DoesNotContain(encodedSecret, diagnostics, StringComparison.Ordinal);
Assert.DoesNotContain(token, diagnostics, StringComparison.Ordinal);
Assert.Contains("redacted", diagnostics, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void PublicClientAndContractSurfacesContainNoProvisioningSecrets()
{
Type[] publicTypes = typeof(GameId).Assembly.GetExportedTypes()
.Concat(Assembly.Load("FinalFactory.Rendezvous.Client").GetExportedTypes())
.ToArray();
string[] forbiddenTerms =
[
"GameSecret",
"SigningKey",
"KeyMaterial",
"PublisherCredential",
"SecretProvider",
];
foreach (Type type in publicTypes)
{
IEnumerable<string> names = type
.GetMembers(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static)
.Select(static member => member.Name)
.Append(type.Name);
Assert.DoesNotContain(names, name => forbiddenTerms.Any(term =>
name.Contains(term, StringComparison.OrdinalIgnoreCase)));
}
}
}
@@ -0,0 +1,98 @@
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Provisioning;
namespace FinalFactory.Rendezvous.Tests.Provisioning;
internal static class ProvisioningTestData
{
public static readonly DateTimeOffset Now = new(2026, 7, 16, 12, 0, 0, TimeSpan.Zero);
public static GamePolicyOptions CreatePolicy(
string gameId = "space-game",
string environmentId = "production",
bool enabled = true) => new()
{
GameId = gameId,
EnvironmentId = environmentId,
Enabled = enabled,
ProtocolVersions = [7],
Regions = ["eu-central"],
VisibilityModes = [ListingVisibility.Public, ListingVisibility.Unlisted],
PublisherTrustModes =
[
PublisherTrustMode.ManagedDedicated,
PublisherTrustMode.PlayerGrant,
PublisherTrustMode.AnonymousUnlisted,
],
MetadataValueMaxBytes = new Dictionary<string, int>(StringComparer.Ordinal)
{
["map"] = 32,
["mode"] = 16,
},
RequiredMetadataKeys = ["mode"],
MetadataMaxBytes = 256,
MetadataMaxKeys = 2,
MaxListingsPerPrincipal = 10,
MaxAnonymousListingsPerAddress = 1,
MaxActiveJoinAttempts = 100,
FallbackPolicy = FallbackPolicyMode.DedicatedEndpointAllowed,
};
public static SigningKeyOptions CreateKey(
string keyId = "key-1",
string secretReference = "secret-1",
DateTimeOffset? notBefore = null,
DateTimeOffset? signUntil = null,
DateTimeOffset? verifyUntil = null,
bool revoked = false,
IEnumerable<PrincipalCredentialKind>? credentialKinds = null,
string? gameId = "space-game",
string? environmentId = "production") => new()
{
KeyId = keyId,
SecretReference = secretReference,
CredentialKinds = credentialKinds?.ToList()
?? [
PrincipalCredentialKind.DedicatedPublisher,
PrincipalCredentialKind.PlayerHostGrant,
],
GameId = gameId,
EnvironmentId = environmentId,
NotBefore = notBefore ?? Now.AddHours(-1),
SignUntil = signUntil ?? Now.AddHours(1),
VerifyUntil = verifyUntil ?? Now.AddHours(2),
Revoked = revoked,
};
public static DictionarySecretProvider CreateSecrets(params string[] references)
{
Dictionary<string, byte[]> secrets = new(StringComparer.Ordinal);
for (int index = 0; index < references.Length; index++)
{
secrets.Add(
references[index],
Enumerable.Range(1 + index, 32).Select(static value => (byte)value).ToArray());
}
return new DictionarySecretProvider(secrets);
}
public static DedicatedPublisherPrincipal CreateDedicatedPublisher(
DateTimeOffset? expiresAt = null,
string gameId = "space-game",
string environmentId = "production") => new(
"workload-42",
expiresAt ?? Now.AddMinutes(10),
new GameId(gameId),
new EnvironmentId(environmentId),
new HashSet<RegionId> { new("eu-central") });
public static ProvisioningOptions CreateOptions(SigningKeyOptions? key = null) => new()
{
Issuer = "final-factory-rendezvous",
Audience = "rendezvous-service",
ClockSkewSeconds = 30,
SigningKeys = [key ?? CreateKey()],
Games = [CreatePolicy()],
};
}
@@ -0,0 +1,124 @@
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Provisioning;
namespace FinalFactory.Rendezvous.Tests.Provisioning;
public sealed class PublisherAuthorizationTests
{
private static readonly IReadOnlyDictionary<string, string> ValidMetadata =
new Dictionary<string, string>(StringComparer.Ordinal)
{
["mode"] = "co-op",
["map"] = "europa",
};
[Fact]
public void AuthoritativeScopeComesFromThePublisherPrincipal()
{
PublisherAuthorizationService service = CreateService();
DedicatedPublisherPrincipal principal = ProvisioningTestData.CreateDedicatedPublisher();
PublisherAuthorizationResult result = service.Authorize(
principal,
new GameId("space-game"),
new EnvironmentId("production"),
new RegionId("eu-central"),
7,
ListingVisibility.Public,
ValidMetadata,
ProvisioningTestData.Now);
Assert.True(result.IsAllowed);
Assert.NotNull(result.Context);
Assert.Equal(principal.GameId, result.Context.GameId);
Assert.Equal(principal.EnvironmentId, result.Context.EnvironmentId);
}
[Theory]
[InlineData("unscouted", "production")]
[InlineData("space-game", "staging")]
public void CrossGameAndEnvironmentScopeEscalationIsDenied(
string requestedGame,
string requestedEnvironment)
{
PublisherAuthorizationResult result = CreateService().Authorize(
ProvisioningTestData.CreateDedicatedPublisher(),
new GameId(requestedGame),
new EnvironmentId(requestedEnvironment),
new RegionId("eu-central"),
7,
ListingVisibility.Public,
ValidMetadata,
ProvisioningTestData.Now);
Assert.False(result.IsAllowed);
Assert.Equal(PublisherAuthorizationError.ScopeMismatch, result.Error);
Assert.Null(result.Context);
}
[Fact]
public void OperatorCannotBeUsedAsAGamePublisher()
{
OperatorPrincipal principal = new(
"operator-1",
ProvisioningTestData.Now.AddMinutes(10),
new HashSet<OperatorPermission> { OperatorPermission.ReadPolicy });
PublisherAuthorizationResult result = CreateService().Authorize(
principal,
new GameId("space-game"),
new EnvironmentId("production"),
new RegionId("eu-central"),
7,
ListingVisibility.Public,
ValidMetadata,
ProvisioningTestData.Now);
Assert.Equal(PublisherAuthorizationError.NotPublisher, result.Error);
}
[Fact]
public void AnonymousPublisherCanNeverEscalateToPublicVisibility()
{
AnonymousUnlistedPrincipal principal = new(
"anonymous-source-1",
ProvisioningTestData.Now.AddMinutes(2),
new GameId("space-game"),
new EnvironmentId("production"),
new HashSet<RegionId> { new("eu-central") });
PublisherAuthorizationResult result = CreateService().Authorize(
principal,
principal.GameId,
principal.EnvironmentId,
new RegionId("eu-central"),
7,
ListingVisibility.Public,
ValidMetadata,
ProvisioningTestData.Now);
Assert.Equal(PublisherAuthorizationError.AnonymousMustBeUnlisted, result.Error);
}
[Fact]
public void ExpiredPrincipalIsRecheckedAtAuthorizationTime()
{
DedicatedPublisherPrincipal principal = ProvisioningTestData.CreateDedicatedPublisher(
ProvisioningTestData.Now.AddSeconds(-1));
PublisherAuthorizationResult result = CreateService().Authorize(
principal,
principal.GameId,
principal.EnvironmentId,
new RegionId("eu-central"),
7,
ListingVisibility.Public,
ValidMetadata,
ProvisioningTestData.Now);
Assert.Equal(PublisherAuthorizationError.PrincipalExpired, result.Error);
}
private static PublisherAuthorizationService CreateService() => new(
GamePolicyRegistry.Create([ProvisioningTestData.CreatePolicy()]));
}