// Copyright Epic Games, Inc. All Rights Reserved.
using System.ComponentModel.DataAnnotations;
using System.Reflection;
using System.Security.Claims;
using System.Text.Json.Serialization;
using EpicGames.Horde.Acls;
using HordeServer.Utilities;
#pragma warning disable CA2227 // Change 'X' to be read-only by removing the property setter
namespace HordeServer.Acls
{
///
/// Parameters to update an ACL
///
public class AclConfig
{
///
/// The parent scope object
///
[JsonIgnore]
public AclConfig? Parent { get; set; }
///
/// Name of this scope
///
[JsonIgnore]
public AclScopeName ScopeName { get; set; }
///
/// Legacy aliases for this scope
///
[JsonIgnore]
public AclScopeName[]? LegacyScopeNames { get; set; }
///
/// ACL actions associated with this config (for debugging purposes)
///
[JsonIgnore]
public AclAction[]? Actions { get; set; }
///
/// ACLs which are parented to this
///
[JsonIgnore]
public List? Children { get; set; }
///
/// Entries to replace the existing ACL
///
public List? Entries { get; set; }
///
/// Defines profiles which allow grouping sets of actions into named collections
///
public List? Profiles { get; set; }
///
/// Whether to inherit permissions from the parent ACL
///
public bool? Inherit { get; set; }
///
/// List of exceptions to the inherited setting
///
public List? Exceptions { get; set; }
IReadOnlyDictionary _profileLookup = null!;
///
/// Tests whether a user is authorized to perform the given actions
///
/// Action that is being performed. This should be a single flag.
/// The principal to authorize
/// True/false if the action is allowed or denied, null if there is no specific setting for this user
public bool Authorize(AclAction action, ClaimsPrincipal user)
=> TryAuthorize(action, user) ?? false;
///
/// Tests whether a user is authorized to perform the given actions
///
/// Action that is being performed. This should be a single flag.
/// The principal to authorize
/// True/false if the action is allowed or denied, null if there is no specific setting for this user
public bool? TryAuthorize(AclAction action, ClaimsPrincipal user)
{
if (user.HasAdminClaim())
{
return true;
}
for (AclConfig? next = this; next != null; next = next.Parent)
{
bool? result = next.AuthorizeSingleScope(action, user);
if (result.HasValue)
{
return result.Value;
}
}
return null;
}
///
/// Tests whether a user is authorized to perform the given actions in this specific scope
///
/// Action that is being performed. This should be a single flag.
/// The principal to authorize
/// True/false if the action is allowed or denied, null if there is no specific setting for this user
public bool? AuthorizeSingleScope(AclAction action, ClaimsPrincipal user)
{
// Check if there's a specific entry for this action
if (Entries != null)
{
foreach (AclEntryConfig entry in Entries)
{
if (user == null)
{
throw new NullReferenceException("User is null");
}
if (entry == null)
{
throw new NullReferenceException("Entry is null");
}
if (entry.ComputedActions == null)
{
throw new NullReferenceException("ComputedActions is null");
}
if (entry.Claim.Type == null)
{
throw new NullReferenceException("Claim.Type is null");
}
if (entry.Claim.Value == null)
{
throw new NullReferenceException("Claim.Value is null");
}
if (entry.ComputedActions.Contains(action) && user.HasClaim(entry.Claim.Type, entry.Claim.Value))
{
return true;
}
}
}
// Otherwise check if we're prevented from inheriting permissions
if (Inherit ?? true)
{
if (Exceptions != null && Exceptions.Contains(action))
{
return false;
}
}
else
{
if (Exceptions == null || !Exceptions.Contains(action))
{
return false;
}
}
// Otherwise allow to propagate up the hierarchy
return null;
}
///
/// Called after the config file has been read
///
public void PostLoad(AclConfig parentScope, string scopeNameSuffix, AclAction[] actions)
{
PostLoad(parentScope, parentScope.ScopeName.Append(scopeNameSuffix), actions);
}
///
/// Called after the config file has been read
///
public void PostLoad(AclConfig? parentScope, AclScopeName scopeName, AclAction[] actions)
{
Parent = parentScope;
ScopeName = scopeName;
Children = null;
Actions = actions;
if (parentScope == null)
{
_profileLookup = new Dictionary();
}
else
{
parentScope.Children ??= new List();
parentScope.Children.Add(this);
_profileLookup = parentScope._profileLookup;
}
if (Profiles != null && Profiles.Count > 0)
{
Dictionary newProfileLookup = new Dictionary(_profileLookup);
foreach (AclProfileConfig profileConfig in Profiles)
{
newProfileLookup.Add(profileConfig.Id, profileConfig);
}
_profileLookup = newProfileLookup;
HashSet visited = new HashSet();
foreach (AclProfileConfig profileConfig in Profiles)
{
profileConfig.PostLoad(ScopeName, newProfileLookup, visited);
}
}
if (Entries != null)
{
foreach (AclEntryConfig entryConfig in Entries)
{
entryConfig.PostLoad(ScopeName, _profileLookup);
}
}
}
///
/// Find all entitlements for a user
///
public Dictionary> FindEntitlements(Predicate predicate)
{
Dictionary> scopeToActions = new Dictionary>();
FindEntitlements(predicate, scopeToActions);
return scopeToActions;
}
///
/// Find all entitlements for a user
///
public void FindEntitlements(Predicate predicate, Dictionary> scopeToActions)
{
if (Entries != null)
{
foreach (AclEntryConfig entry in Entries)
{
if (predicate(entry.Claim))
{
HashSet? actions;
if (!scopeToActions.TryGetValue(ScopeName, out actions))
{
actions = new HashSet();
scopeToActions.Add(ScopeName, actions);
}
actions.UnionWith(entry.ComputedActions);
}
}
}
if (Children != null)
{
foreach (AclConfig childAclConfig in Children)
{
childAclConfig.FindEntitlements(predicate, scopeToActions);
}
}
}
///
/// Get all AclActions declared for the types specified
///
/// Array of struct/class types
/// List of AclAction
public static AclAction[] GetActions(Type[] type)
{
return type
.SelectMany(x => x.GetProperties(BindingFlags.Static | BindingFlags.Public))
.Where(x => x.PropertyType == typeof(AclAction))
.Select(x => (AclAction)x.GetValue(null)!)
.ToArray();
}
}
///
/// Individual entry in an ACL
///
public class AclEntryConfig
{
///
/// Name of the user or group
///
[Required]
public AclClaimConfig Claim { get; set; }
///
/// Array of actions to allow
///
public List? Actions { get; set; }
///
/// List of profiles to grant
///
public List? Profiles { get; set; }
///
/// List of all actions inherited from all profiles
///
[JsonIgnore]
public HashSet ComputedActions { get; set; } = null!;
///
/// Private constructor for serialization
///
public AclEntryConfig()
{
Claim = new AclClaimConfig();
}
///
/// Constructor
///
/// The claim this entry applies to
/// List of allowed operations
/// Profiles to inherit
public AclEntryConfig(AclClaimConfig claim, IEnumerable? actions, IEnumerable? profiles = null)
{
Claim = claim;
if (actions != null)
{
Actions = new List(actions);
}
if (profiles != null)
{
Profiles = new List(profiles);
}
}
internal void PostLoad(AclScopeName scopeName, IReadOnlyDictionary profileLookup)
{
HashSet computedActions = new HashSet();
if (Actions != null)
{
computedActions.UnionWith(Actions);
}
if (Profiles != null)
{
computedActions.UnionWith(Profiles.SelectMany(profileId => GetActionsForProfile(scopeName, profileId, profileLookup)));
}
ComputedActions = computedActions;
}
static IEnumerable GetActionsForProfile(AclScopeName scopeName, AclProfileId profileId, IReadOnlyDictionary profileLookup)
{
AclProfileConfig? profileConfig;
if (!profileLookup.TryGetValue(profileId, out profileConfig))
{
throw new Exception($"Undefined profile '{profileId}' referenced from {scopeName}");
}
return profileConfig.ComputedActions;
}
}
///
/// Configuration for an ACL profile. This defines a preset group of actions which can be given to a user via an ACL entry.
///
public class AclProfileConfig
{
///
/// Identifier for this profile
///
public AclProfileId Id { get; set; }
///
/// Actions to include
///
public List? Actions { get; set; }
///
/// Actions to exclude from the inherited actions
///
public List? ExcludeActions { get; set; }
///
/// Other profiles to extend from
///
public List? Extends { get; set; }
///
/// Computed list of actions after considering base profiles etc... Fixed up by calling PostLoad().
///
[JsonIgnore]
internal HashSet ComputedActions { get; set; } = null!;
internal void PostLoad(AclScopeName scopeName, Dictionary profileLookup, HashSet visited)
{
if (ComputedActions == null)
{
if (!visited.Add(Id))
{
throw new Exception($"Recursive profile definition for '{Id}' in {scopeName}");
}
HashSet computedActions = new HashSet();
if (Extends != null)
{
computedActions.UnionWith(Extends.SelectMany(profileId => GetActionsForProfile(scopeName, profileId, profileLookup, visited)));
}
if (Actions != null)
{
computedActions.UnionWith(Actions);
}
if (ExcludeActions != null)
{
computedActions.ExceptWith(ExcludeActions);
}
ComputedActions = computedActions;
}
}
static IEnumerable GetActionsForProfile(AclScopeName scopeName, AclProfileId profileId, Dictionary profileLookup, HashSet visited)
{
AclProfileConfig? profileConfig;
if (!profileLookup.TryGetValue(profileId, out profileConfig))
{
throw new Exception($"Undefined profile '{profileId}'");
}
profileConfig.PostLoad(scopeName, profileLookup, visited);
return profileConfig.ComputedActions!;
}
}
///
/// New claim to create
///
public class AclClaimConfig : IAclClaim
{
///
/// The claim type
///
[Required]
public string Type { get; set; } = null!;
///
/// The claim value
///
[Required]
public string Value { get; set; } = null!;
///
/// Constructor
///
public AclClaimConfig()
{
Type = String.Empty;
Value = String.Empty;
}
///
/// Constructor
///
/// The claim object
public AclClaimConfig(Claim claim)
: this(claim.Type, claim.Value)
{
}
///
/// Constructor
///
/// The claim type
/// The claim value
public AclClaimConfig(string type, string value)
{
Type = type;
Value = value;
}
///
/// Converts this object to a regular object.
///
public Claim ToClaim() => new Claim(Type, Value);
}
}