feat: add tenant provisioning and key lifecycle (#5)
quality-gate / quality (push) Successful in 50s
quality-gate / quality (push) Successful in 50s
Closes #5
This commit is contained in:
@@ -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()]));
|
||||
}
|
||||
Reference in New Issue
Block a user