Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project>
<PropertyGroup>
<VersionPrefix Condition="'$(VersionPrefix)' == ''">0.1.1</VersionPrefix>
<VersionSuffix Condition="'$(VersionSuffix)' == ''">beta.12</VersionSuffix>
<VersionSuffix Condition="'$(VersionSuffix)' == ''">beta.13</VersionSuffix>

<Version Condition="'$(Version)' == '' and '$(VersionSuffix)' == ''">$(VersionPrefix)</Version>
<Version Condition="'$(Version)' == '' and '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix)</Version>
Expand Down
13 changes: 12 additions & 1 deletion docs/releases.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
# Releases

RatelDesk has one repository-owned release line. The root `Directory.Build.props` is the source of truth: maintainers update `VersionPrefix` when preparing the next release. All application projects inherit that value. `VersionSuffix` creates prereleases without changing the release line. The current planned test release is `0.1.1-beta.9`; earlier prereleases remain immutable.
RatelDesk has one repository-owned release line. The root `Directory.Build.props` is the source of truth for `VersionPrefix` and `VersionSuffix`. Applications and release outputs inherit those values. Prereleases remain immutable.

## 0.1.1-beta.13 — reciprocal link recovery

Background workers leave live attempts awaiting administrator consent untouched,
preserving the original approval's concurrency revision. Expired unused attempts
still abort and purge temporary pairing material.

Link recovery reuses short-lived verification and control tokens for the exact
approved peer, tenant, scope and credential revision. It checks current durable
authority on each use and retains the existing rate limits, finite recovery
permissions and rotation deadlines.

## 0.1.1-beta.9 — service credentials and reciprocal NetRatel linking

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public static IServiceCollection AddServiceLinkProtocol(this IServiceCollection
services.AddOptions<ServiceLinkOptions>().Bind(configuration.GetSection(ServiceLinkOptions.SectionName)).ValidateOnStart();
services.AddSingleton<IValidateOptions<ServiceLinkOptions>, ServiceLinkOptionsValidator>();
services.AddScoped<ServiceLinkCoordinator>();
services.AddScoped<ServiceLinkProtocolTokenCache>();
services.AddHttpClient<ServiceLinkTransport>(client => client.Timeout = TimeSpan.FromSeconds(25))
.ConfigurePrimaryHttpMessageHandler(provider => IntegrationSafeHttpMessageHandler.CreateServiceLink(
currentAllowPrivateHttp: () => provider.GetRequiredService<IOptionsMonitor<ServiceLinkOptions>>().CurrentValue.AllowPrivateHttp));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public sealed partial class ServiceLinkCoordinator(
HelpdeskDbContext db, IServicePrincipalRegistry registry, ICurrentUserAccessService accessService,
IIntegrationProviderSettingsService providers, ServiceLinkTransport transport, IDataProtectionProvider protection,
IOptions<ServiceLinkOptions> options, IOptionsMonitor<ServiceIdentityOptions> identityOptions, TimeProvider clock,
ServiceLinkProtocolTokenCache protocolTokens,
IOptionsMonitor<ServiceLinkOptions>? currentOptions = null)
{
private ServiceLinkOptions settings => currentOptions?.CurrentValue ?? options.Value;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Caching.Memory;
using Helpdesk.Shared.ServiceLink;

namespace Helpdesk.Infrastructure.ServiceLink;

/// <summary>Server-only immutable control-token binding; never serialize its credential into a response or log.</summary>
public sealed record ServiceLinkProtocolTokenContext(string AttemptId, string LinkId, long LinkRevision,
string GrantHash, string DescriptorHash, string DirectionId, string Mode, int? OutboundProfileRevision,
ServiceLinkMetadata Local, ServiceLinkMetadata Peer, ServiceDirectionalCredential Credential,
string? RotationId, long? AuthorityExpiresAtUnixSeconds);

/// <summary>A bounded control/verification cache. The caller must resolve current durable authority on every use.</summary>
public sealed class ServiceLinkProtocolTokenCache(ServiceLinkTransport transport, IMemoryCache cache, TimeProvider clock)
{
// Fixed stripes share acquisition across transient worker scopes without retaining a lock per link.
private static readonly SemaphoreSlim[] Stripes = Enumerable.Range(0, 64).Select(_ => new SemaphoreSlim(1, 1)).ToArray();
private sealed record CachedToken(string Token, DateTimeOffset ReuseUntilUtc);

public async Task<string> GetAsync(Func<CancellationToken, Task<ServiceLinkProtocolTokenContext>> resolveCurrent,
string scope, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
if (scope is not (ServiceLinkContract.ControlScope or ServiceLinkContract.VerifyScope))
throw new ServiceLinkProtocolException(403, "protocol-scope-required", "Only an exact control or verification scope may use this cache.");
var captured = await resolveCurrent(ct);
Validate(captured, ct);
var key = Key(captured, scope);
var stripe = Stripes[(key.GetHashCode(StringComparison.Ordinal) & int.MaxValue) % Stripes.Length];
await stripe.WaitAsync(ct);
try
{
// Waiting for another scope's token cannot preserve stale authority.
var current = await resolveCurrent(ct);
Validate(current, ct);
RequireSame(key, current, scope);
if (cache.TryGetValue<CachedToken>(key, out var cached) && cached is not null)
{
if (cached.ReuseUntilUtc > clock.GetUtcNow()) return cached.Token;
cache.Remove(key);
}
var requestStartedAt = clock.GetUtcNow();
var issued = await transport.AcquireTokenAsync(current.Credential, scope, ct);
var after = await resolveCurrent(ct);
Validate(after, ct);
RequireSame(key, after, scope);
var now = clock.GetUtcNow();
if (requestStartedAt.AddSeconds(issued.ExpiresIn) <= now)
throw new ServiceLinkProtocolException(422, "expired-token-response", "The service token lifetime elapsed before its response could be used.");
var reuseUntil = requestStartedAt.AddSeconds(Math.Min(45, issued.ExpiresIn - 5));
if (after.AuthorityExpiresAtUnixSeconds is { } deadline)
reuseUntil = DateTimeOffset.FromUnixTimeSeconds(deadline) < reuseUntil
? DateTimeOffset.FromUnixTimeSeconds(deadline) : reuseUntil;
var remaining = reuseUntil - now;
if (remaining > TimeSpan.Zero) cache.Set(key, new CachedToken(issued.AccessToken, reuseUntil), remaining);
return issued.AccessToken;
}
finally { stripe.Release(); }
}

private void Validate(ServiceLinkProtocolTokenContext context, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
if (context.AuthorityExpiresAtUnixSeconds is { } expires && expires <= clock.GetUtcNow().ToUnixTimeSeconds())
throw new ServiceLinkProtocolException(403, "protocol-authority-expired", "The retained control authority has expired.");
// A cache hit still observes an opt-in removed from current typed options.
transport.ValidateTokenEndpoint(context.Credential);
}

private static void RequireSame(string capturedKey, ServiceLinkProtocolTokenContext current, string scope)
{
if (Key(current, scope) != capturedKey)
throw new ServiceLinkProtocolException(409, "protocol-profile-conflict", "The current protocol credential or approved link changed during acquisition.");
}

private static string Key(ServiceLinkProtocolTokenContext context, string scope) => "rateldesk-protocol-token/" +
ServiceLinkCanonicalJson.HashObject(new
{
context.AttemptId, context.LinkId, context.LinkRevision, context.GrantHash, context.DescriptorHash,
context.DirectionId, context.Mode, context.OutboundProfileRevision, context.Local, context.Peer,
context.RotationId, context.AuthorityExpiresAtUnixSeconds, RequestedScope = scope,
Credential = context.Credential with
{
ClientSecret = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(context.Credential.ClientSecret))),
Scopes = ServiceLinkValidation.Set(context.Credential.Scopes)
}
});
}
100 changes: 100 additions & 0 deletions src/Helpdesk.Infrastructure/ServiceLink/ServiceLinkProtocolTokens.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
using Microsoft.EntityFrameworkCore;
using Helpdesk.Infrastructure.ServiceIdentity;
using Helpdesk.Shared.ServiceLink;
using static Helpdesk.Infrastructure.ServiceLink.ServiceLinkValidation;

namespace Helpdesk.Infrastructure.ServiceLink;

public sealed partial class ServiceLinkCoordinator
{
private Task<string> ProtocolToken(ServiceLinkAttempt captured, string scope, string route,
ServiceLinkLifecycleRequest? request, ServiceDirectionalCredential credential, CancellationToken ct, bool candidateCredential = false) =>
protocolTokens.GetAsync(token => CurrentProtocolTokenContext(captured, scope, route, request, credential, candidateCredential, token), scope, ct);

private async Task<ServiceLinkProtocolTokenContext> CurrentProtocolTokenContext(ServiceLinkAttempt captured, string scope,
string route, ServiceLinkLifecycleRequest? request, ServiceDirectionalCredential credential, bool candidateCredential, CancellationToken ct)
{
var current = await db.Set<ServiceLinkAttempt>().AsNoTracking().SingleOrDefaultAsync(x => x.AttemptId == captured.AttemptId, ct);
Require(current is not null && current.LinkId == captured.LinkId && current.LinkRevision == captured.LinkRevision &&
current.GrantHash == captured.GrantHash && current.DescriptorHash == captured.DescriptorHash &&
current.Role == captured.Role && current.LocalTenantId == captured.LocalTenantId &&
current.PeerInstanceId == captured.PeerInstanceId && current.PeerTenantId == captured.PeerTenantId &&
current.OutboundProfileRevision == captured.OutboundProfileRevision && current.ProtectedOutboundCredential is not null,
"protocol-profile-conflict", "The current approved link or outbound profile changed.", 409);
var a = current!;
var summary = Summary(a);
Require(summary.AttemptId == a.AttemptId && summary.LinkId == a.LinkId && summary.ProposedLinkRevision == a.LinkRevision &&
summary.DescriptorHash == a.DescriptorHash && ServiceLinkPayloadNormalization.SummaryHashMatches(summary, a.GrantHash!) &&
ServiceLinkAuthority.LocalIdentityMatches(summary, a.Role, issuer, settings),
"protocol-authority-unavailable", "The approved local identity is no longer current.", 403);
var grant = OutboundGrant(a);
var peer = Peer(a);
Credential(credential, grant, peer);
var principal = await db.Set<ServicePrincipalRegistration>().AsNoTracking().SingleOrDefaultAsync(x => x.Id == a.InboundPrincipalId, ct);
var secret = principal is null ? null : await db.Set<ServicePrincipalSecret>().AsNoTracking().SingleOrDefaultAsync(x =>
x.ServicePrincipalId == principal.Id && x.CredentialRevision == principal.CurrentCredentialRevision, ct);
var customers = principal is null ? Array.Empty<string>() : ServicePrincipalRegistry.ReadArray(principal.CustomerIdsJson);
Require(principal is not null && secret is not null && secret.Status != "revoked" &&
secret.ExpiresAtUtc > clock.GetUtcNow() && (secret.RetireAtUtc is null || secret.RetireAtUtc > clock.GetUtcNow()) &&
registry.PermittedScopes(principal, secret).Contains(scope, StringComparer.Ordinal) &&
await db.Organizations.AsNoTracking().AnyAsync(x => x.Id == principal.OrganizationId &&
x.State == Helpdesk.Shared.Models.EntityState.Enabled, ct) &&
await db.Customers.AsNoTracking().CountAsync(x => customers.Contains(x.Id) && x.OrganizationId == principal.OrganizationId &&
x.State == Helpdesk.Shared.Models.EntityState.Enabled, ct) == customers.Length &&
await registry.CanIssueScopesAsync(new(principal, secret), [scope], ct),
"protocol-authority-unavailable", "The exact local registration has no current retained protocol authority.", 403);

var terminal = a.Decision == "abort" || a.LifecycleState is "revocation_pending" or "revoked" or "expired" or "failed";
long? authorityDeadline = secret!.ExpiresAtUtc.ToUnixTimeSeconds();
if (secret.RetireAtUtc is { } retirement) authorityDeadline = Math.Min(authorityDeadline.Value, retirement.ToUnixTimeSeconds());
if (terminal)
{
Require(scope == ServiceLinkContract.ControlScope && a.TerminalControlExpiresAtUnixSeconds > Now &&
(a.LifecycleState is "revocation_pending" or "revoked"
? route == "revoke" || route == "ack" && request?.AckPhase == "revoked" || route == "status"
: route is "abort" or "status"),
"terminal-control-restricted", "Only the original finite terminal control recovery may continue.", 403);
authorityDeadline = Math.Min(authorityDeadline.Value, a.TerminalControlExpiresAtUnixSeconds!.Value);
}
else Require(a.LifecycleState is not ("awaiting_approval" or "failed" or "expired" or "revoked"),
"protocol-authority-unavailable", "This attempt has no prepared protocol authority.", 403);

var outbound = Read<ServiceDirectionalCredential>(Unprotect(a, "outbound-credential", a.ProtectedOutboundCredential!));
var accepted = ServiceLinkCanonicalJson.HashObject(ServiceLinkPayloadNormalization.Credential(outbound)) ==
ServiceLinkCanonicalJson.HashObject(ServiceLinkPayloadNormalization.Credential(credential));
var candidate = candidateCredential;
if (candidate)
{
Require(request?.RotationId is not null &&
(route == "verify" || route == "rotate" && request.RotationPhase is "verified" or "switched" or "abort"),
"candidate-control-restricted", "A candidate may only complete its own pending rotation.", 403);
var rotation = await db.Set<ServiceLinkRotation>().AsNoTracking().SingleOrDefaultAsync(x =>
x.LinkId == a.LinkId && x.RotationId == request!.RotationId && !x.IsIssuer, ct);
Require(!terminal && rotation is not null && rotation.DirectionId == grant.DirectionId &&
rotation.SuccessorCredentialRevision == credential.CredentialRevision &&
rotation.RotationState != "aborted", "candidate-control-restricted", "The candidate belongs to another rotation.", 403);
var durableCandidate = rotation!.ProtectedCandidate is null ? null : Read<ServiceDirectionalCredential>(
Unprotect(a, "rotation-candidate/" + rotation.RotationId, rotation.ProtectedCandidate));
// After a durable caller switch, escrow may be gone while an unfinished
// exact switched journal still needs the already installed successor.
var pendingCandidate = rotation.ActiveRotationKey is not null && rotation.RotationState != "completed" &&
durableCandidate is not null && ServiceLinkCanonicalJson.HashObject(ServiceLinkPayloadNormalization.Credential(durableCandidate)) ==
ServiceLinkCanonicalJson.HashObject(ServiceLinkPayloadNormalization.Credential(credential));
var retainedSwitch = accepted && rotation.CallerSwitchRevision is not null && rotation.ActivateDecisionId is not null &&
route == "rotate" && request!.RotationPhase == "switched" &&
await db.Set<ServiceLinkOperation>().AsNoTracking().AnyAsync(x => x.LinkId == a.LinkId &&
x.OperationId == request.OperationId && x.Outbound && !x.Completed && x.Kind == "rotate-switched/" + rotation.RotationId, ct);
Require(pendingCandidate || retainedSwitch,
"candidate-control-restricted", "The current candidate or installed successor differs from this rotation.", 403);
if (rotation.ActivateDecisionId is null && request!.RotationPhase != "abort")
{
Require(rotation.OfferExpiresAtUnixSeconds > Now, "candidate-control-expired", "The unactivated candidate offer expired.", 403);
authorityDeadline = Math.Min(authorityDeadline.Value, rotation.OfferExpiresAtUnixSeconds!.Value);
}
}
else Require(accepted, "protocol-profile-conflict", "The current outbound credential differs from the captured credential.", 409);
return new(a.AttemptId, a.LinkId!, a.LinkRevision, a.GrantHash!, a.DescriptorHash, grant.DirectionId,
a.Decision + "/" + a.LifecycleState + (candidate ? "/candidate" : "/current"), a.OutboundProfileRevision,
Local(a), peer, credential, candidate ? request!.RotationId : null, authorityDeadline);
}
}
Loading
Loading