// 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); } }