Code/SboxSuperuser/Core/Audit/AuditRecord.cs

Represents an immutable audit record. Validates inputs (IDs, timestamps, actor identity, arguments and targets), stores safe arguments and targets as readonly collections, and can produce a completed copy with an outcome and completion time.

File Access
using System.Collections.ObjectModel;
using SboxSuperuser.Core.Contracts;
using SboxSuperuser.Core.Identity;

namespace SboxSuperuser.Core.Audit;

internal sealed class AuditRecord
{
	internal long Sequence { get; }
	internal Guid Id { get; }
	internal Guid RequestId { get; }
	internal string ActorKind { get; }
	internal string ActorSubject { get; }
	internal string Origin { get; }
	internal string Command { get; }
	internal IReadOnlyDictionary<string, string> SafeArguments { get; }
	internal IReadOnlyList<string> Targets { get; }
	internal DateTimeOffset CreatedUtc { get; }
	internal DateTimeOffset? CompletedUtc { get; }
	internal string Outcome { get; }

	internal AuditRecord( long sequence, Guid id, Guid requestId, string actorKind, string actorSubject, string origin, string command,
		IReadOnlyDictionary<string, string> safeArguments, IEnumerable<string> targets, DateTimeOffset createdUtc,
		string outcome = "Unknown", DateTimeOffset? completedUtc = null )
	{
		if ( sequence < 1 || id == Guid.Empty || requestId == Guid.Empty || createdUtc.Offset != TimeSpan.Zero
			|| completedUtc.HasValue && (completedUtc.Value.Offset != TimeSpan.Zero || completedUtc.Value < createdUtc)
			|| outcome != "Unknown" && !completedUtc.HasValue )
		{
			throw new ArgumentException( "Audit identifiers, sequence and UTC timestamps must be explicit and ordered." );
		}

		ValidateActor( actorKind, actorSubject, origin );
		ContractGuard.Key( command, nameof( command ) );
		if ( command.Length > 96 || actorSubject.Length > 96 )
		{
			throw new ArgumentException( "Audit identity and command keys exceed their limits." );
		}

		if ( outcome is not ("Unknown" or "Success" or "NoChange" or "InvalidArguments" or "Denied" or "NotFound"
			or "Unsupported" or "Cancelled" or "Failed" or "Conflict" or "StorageUnavailable") )
		{
			throw new ArgumentException( "Unknown audit outcome." );
		}

		var arguments = new Dictionary<string, string>( StringComparer.Ordinal );
		foreach ( var pair in safeArguments ?? new Dictionary<string, string>() )
		{
			ContractGuard.Key( pair.Key, nameof( safeArguments ) );
			if ( pair.Key.Length > 96 || pair.Value is null || pair.Value.Length > 256 || arguments.Count >= 32 )
			{
				throw new ArgumentException( "Safe audit arguments exceed their limits." );
			}

			arguments.Add( pair.Key, pair.Value );
		}

		var targetCopy = (targets ?? Array.Empty<string>()).Take( 257 ).ToArray();
		if ( targetCopy.Length > 256 || targetCopy.Distinct( StringComparer.Ordinal ).Count() != targetCopy.Length )
		{
			throw new ArgumentException( "Audit target IDs must be unique, with at most 256 targets." );
		}

		foreach ( var target in targetCopy )
		{
			SteamIdentity.Validate( target, nameof( targets ) );
		}

		Sequence = sequence;
		Id = id;
		RequestId = requestId;
		ActorKind = actorKind;
		ActorSubject = actorSubject;
		Origin = origin;
		Command = command;
		SafeArguments = new ReadOnlyDictionary<string, string>( arguments );
		Targets = Array.AsReadOnly( targetCopy );
		CreatedUtc = createdUtc;
		Outcome = outcome;
		CompletedUtc = completedUtc;
	}

	internal AuditRecord Complete( string outcome, DateTimeOffset utc )
	{
		if ( CompletedUtc.HasValue )
		{
			return Outcome == outcome ? this : throw new InvalidOperationException( "A recorded outcome cannot be replaced." );
		}

		return new AuditRecord( Sequence, Id, RequestId, ActorKind, ActorSubject, Origin, Command, SafeArguments, Targets,
			CreatedUtc, outcome, utc < CreatedUtc ? CreatedUtc : utc );
	}

	private static void ValidateActor( string kind, string subject, string origin )
	{
		if ( kind == "Player" && origin is "Console" or "Chat" or "Interface" )
		{
			SteamIdentity.Validate( subject, nameof( subject ) );
			return;
		}

		if ( kind == "DedicatedConsole" && origin == "Console" && subject == "dedicated-console" )
		{
			return;
		}

		if ( kind == "InternalService" && origin == "InternalService" )
		{
			ContractGuard.Key( subject, nameof( subject ) );
			return;
		}

		throw new ArgumentException( "Audit actor and source do not match." );
	}
}