SboxSuperuser/Core/Permissions/PolicyGraph.cs

PolicyGraph represents a snapshot of permission groups and users. It validates group/user collections, builds group inheritance ancestor lists, and computes applicable groups for a user (always including the implicit "player" group).

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

namespace SboxSuperuser.Core.Permissions;

internal sealed class PolicyGraph
{
	private readonly Dictionary<string, IReadOnlyList<string>> _ancestors = new( StringComparer.Ordinal );
	internal IReadOnlyDictionary<string, GroupDefinition> Groups { get; }
	internal IReadOnlyDictionary<string, UserDefinition> Users { get; }
	internal long Revision { get; }

	internal PolicyGraph( long revision, IEnumerable<GroupDefinition> groups, IEnumerable<UserDefinition> users )
	{
		if ( revision < 0 )
		{
			throw new ArgumentOutOfRangeException( nameof( revision ) );
		}

		Revision = revision;
		var groupCopy = ContractGuard.Copy( groups, nameof( groups ) );
		var userCopy = ContractGuard.Copy( users, nameof( users ) );
		if ( groupCopy.Count is < 1 or > 256 || userCopy.Count > 10000 )
		{
			throw new ArgumentException( "Policy supports 1–256 groups and up to 10000 stored users." );
		}

		Groups = new ReadOnlyDictionary<string, GroupDefinition>( groupCopy.ToDictionary( group => group.Id, StringComparer.Ordinal ) );
		Users = new ReadOnlyDictionary<string, UserDefinition>( userCopy.ToDictionary( user => user.SteamId, StringComparer.Ordinal ) );
		if ( !Groups.ContainsKey( "player" ) )
		{
			throw new ArgumentException( "The implicit player group is required.", nameof( groups ) );
		}

		foreach ( var group in Groups.Values )
		{
			ValidateReferences( group.Parents );
		}

		foreach ( var user in Users.Values )
		{
			ValidateReferences( user.Groups );
		}

		foreach ( var id in Groups.Keys )
		{
			BuildAncestors( id, new HashSet<string>( StringComparer.Ordinal ) );
		}
	}

	internal IReadOnlyList<string> ApplicableGroups( string steamId )
	{
		var result = new HashSet<string>( _ancestors[ "player" ], StringComparer.Ordinal );
		if ( Users.TryGetValue( steamId, out var user ) )
		{
			foreach ( var id in user.Groups )
			{
				result.UnionWith( _ancestors[ id ] );
			}
		}

		return Array.AsReadOnly( result.OrderBy( id => id, StringComparer.Ordinal ).ToArray() );
	}

	internal IReadOnlyList<string> AncestorsOf( string groupId ) => _ancestors[ groupId ];

	private void ValidateReferences( IEnumerable<string> references )
	{
		foreach ( var id in references )
		{
			if ( !Groups.ContainsKey( id ) )
			{
				throw new ArgumentException( $"Referenced group '{id}' does not exist." );
			}
		}
	}

	private IReadOnlyList<string> BuildAncestors( string id, HashSet<string> visiting )
	{
		if ( _ancestors.TryGetValue( id, out var cached ) )
		{
			return cached;
		}

		if ( !visiting.Add( id ) )
		{
			throw new ArgumentException( $"Group inheritance cycle includes '{id}'." );
		}

		var result = new HashSet<string>( StringComparer.Ordinal ) { id };
		foreach ( var parent in Groups[ id ].Parents )
		{
			result.UnionWith( BuildAncestors( parent, visiting ) );
		}

		visiting.Remove( id );
		var snapshot = Array.AsReadOnly( result.OrderBy( group => group, StringComparer.Ordinal ).ToArray() );
		_ancestors.Add( id, snapshot );
		return snapshot;
	}
}