Compare commits
13 Commits
Bruker-Dem
...
f3af7b74f0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3af7b74f0 | ||
|
|
45009dfacc | ||
|
|
cd133c67e1 | ||
|
|
17bcf9d4fb | ||
|
|
ae65f8e758 | ||
|
|
54be771569 | ||
|
|
d95a9c0ea9 | ||
|
|
8e6bbe4bec | ||
|
|
b055e29f9a | ||
|
|
804eee20fd | ||
|
|
246af92f5d | ||
|
|
ac747028f6 | ||
|
|
f9daba1bb6 |
@@ -373,6 +373,7 @@ namespace C4IT.LIAM
|
|||||||
public string Description { get; set; }
|
public string Description { get; set; }
|
||||||
public string UniqueId { get; set; }
|
public string UniqueId { get; set; }
|
||||||
public string DataAreaType { get; set; }
|
public string DataAreaType { get; set; }
|
||||||
|
public int DataAreaTypeId { get; set; }
|
||||||
}
|
}
|
||||||
public class LiamApiVersionInfo
|
public class LiamApiVersionInfo
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1178,6 +1178,7 @@ where ";
|
|||||||
Level = DataArea.Level.ToString(),
|
Level = DataArea.Level.ToString(),
|
||||||
ConfigurationId = ProviderEntry.ObjectID.ToString(),
|
ConfigurationId = ProviderEntry.ObjectID.ToString(),
|
||||||
DataAreaType = DataArea.DataType.ToString(),
|
DataAreaType = DataArea.DataType.ToString(),
|
||||||
|
DataAreaTypeId = (int)DataArea.DataType,
|
||||||
Owner = owner,
|
Owner = owner,
|
||||||
Write = write,
|
Write = write,
|
||||||
Read = DataAreaNtfsFolder?.ReadGroupIdentifier ?? string.Empty,
|
Read = DataAreaNtfsFolder?.ReadGroupIdentifier ?? string.Empty,
|
||||||
|
|||||||
@@ -35,19 +35,75 @@ namespace C4IT.LIAM
|
|||||||
|
|
||||||
private string lastErrorMessage = null;
|
private string lastErrorMessage = null;
|
||||||
|
|
||||||
private static readonly string[] RequiredGraphRoles = new[]
|
private sealed class GraphPermissionRequirement
|
||||||
{
|
{
|
||||||
"Application.Read.All",
|
public string Description { get; private set; }
|
||||||
"Channel.ReadBasic.All",
|
|
||||||
"Directory.Read.All",
|
public string[] AcceptedPermissions { get; private set; }
|
||||||
"Files.ReadWrite.All",
|
|
||||||
|
public GraphPermissionRequirement(string description, params string[] acceptedPermissions)
|
||||||
|
{
|
||||||
|
Description = description;
|
||||||
|
AcceptedPermissions = acceptedPermissions ?? new string[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly GraphPermissionRequirement[] RequiredGraphPermissions = new[]
|
||||||
|
{
|
||||||
|
new GraphPermissionRequirement(
|
||||||
|
"Team lesen",
|
||||||
|
"Team.ReadBasic.All",
|
||||||
|
"Group.Read.All",
|
||||||
"Group.ReadWrite.All",
|
"Group.ReadWrite.All",
|
||||||
|
"Directory.Read.All",
|
||||||
|
"Directory.ReadWrite.All"),
|
||||||
|
new GraphPermissionRequirement(
|
||||||
|
"Channels lesen",
|
||||||
|
"Channel.ReadBasic.All",
|
||||||
|
"ChannelSettings.Read.All",
|
||||||
|
"ChannelSettings.ReadWrite.All",
|
||||||
|
"Group.Read.All",
|
||||||
|
"Group.ReadWrite.All",
|
||||||
|
"Directory.Read.All",
|
||||||
|
"Directory.ReadWrite.All"),
|
||||||
|
new GraphPermissionRequirement(
|
||||||
|
"Dateien lesen und schreiben",
|
||||||
|
"Files.ReadWrite.All",
|
||||||
|
"Sites.ReadWrite.All",
|
||||||
|
"Sites.FullControl.All"),
|
||||||
|
new GraphPermissionRequirement(
|
||||||
|
"Benutzer lesen",
|
||||||
|
"User.Read.All",
|
||||||
|
"User.ReadWrite.All",
|
||||||
|
"Directory.Read.All",
|
||||||
|
"Directory.ReadWrite.All"),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly GraphPermissionRequirement[] CloneBaseGraphPermissions = new[]
|
||||||
|
{
|
||||||
|
new GraphPermissionRequirement("Teams klonen", "Team.Create"),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly GraphPermissionRequirement[] CloneAppsGraphPermissions = new[]
|
||||||
|
{
|
||||||
|
new GraphPermissionRequirement("Apps mitklonen", "Application.Read.All", "Application.ReadWrite.All"),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly GraphPermissionRequirement[] CloneSettingsGraphPermissions = new[]
|
||||||
|
{
|
||||||
|
new GraphPermissionRequirement("Team-Einstellungen mitklonen", "TeamSettings.Read.All", "TeamSettings.ReadWrite.All"),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly GraphPermissionRequirement[] CloneMemberGraphPermissions = new[]
|
||||||
|
{
|
||||||
|
new GraphPermissionRequirement(
|
||||||
|
"Mitglieder mitklonen",
|
||||||
"GroupMember.Read.All",
|
"GroupMember.Read.All",
|
||||||
"GroupMember.ReadWrite.All",
|
"GroupMember.ReadWrite.All",
|
||||||
"Team.Create",
|
"Group.Read.All",
|
||||||
"Team.ReadBasic.All",
|
"Group.ReadWrite.All",
|
||||||
"TeamSettings.Read.All",
|
"Directory.Read.All",
|
||||||
"User.Read.All",
|
"Directory.ReadWrite.All"),
|
||||||
};
|
};
|
||||||
|
|
||||||
private void SetLastError(string message)
|
private void SetLastError(string message)
|
||||||
@@ -182,9 +238,57 @@ namespace C4IT.LIAM
|
|||||||
|
|
||||||
private bool EnsureGraphPermissions(string accessToken)
|
private bool EnsureGraphPermissions(string accessToken)
|
||||||
{
|
{
|
||||||
|
return EnsureGraphPermissions(accessToken, RequiredGraphPermissions, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool EnsureClonePermissions(string accessToken, int partsToClone)
|
||||||
|
{
|
||||||
|
var requirements = new List<GraphPermissionRequirement>(CloneBaseGraphPermissions);
|
||||||
|
var cloneParts = (CloneTeamRequest.ClonableTeamParts)partsToClone;
|
||||||
|
|
||||||
|
if (cloneParts.HasFlag(CloneTeamRequest.ClonableTeamParts.Apps))
|
||||||
|
requirements.AddRange(CloneAppsGraphPermissions);
|
||||||
|
if (cloneParts.HasFlag(CloneTeamRequest.ClonableTeamParts.Settings))
|
||||||
|
requirements.AddRange(CloneSettingsGraphPermissions);
|
||||||
|
if (cloneParts.HasFlag(CloneTeamRequest.ClonableTeamParts.Members))
|
||||||
|
requirements.AddRange(CloneMemberGraphPermissions);
|
||||||
|
|
||||||
|
return EnsureGraphPermissions(accessToken, requirements, "Team-Klonen");
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool EnsureGraphPermissions(string accessToken, IEnumerable<GraphPermissionRequirement> requirements, string operationName)
|
||||||
|
{
|
||||||
|
if (!TryGetGrantedGraphPermissions(accessToken, out var granted, out var errorMessage))
|
||||||
|
{
|
||||||
|
SetLastError(errorMessage);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var missing = requirements
|
||||||
|
.Where(requirement => requirement.AcceptedPermissions == null || !requirement.AcceptedPermissions.Any(granted.Contains))
|
||||||
|
.Select(requirement => $"{requirement.Description} ({string.Join(" / ", requirement.AcceptedPermissions)})")
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (missing.Count > 0)
|
||||||
|
{
|
||||||
|
var prefix = string.IsNullOrWhiteSpace(operationName)
|
||||||
|
? "Fehlende Graph-Berechtigungen: "
|
||||||
|
: $"Fehlende Graph-Berechtigungen für {operationName}: ";
|
||||||
|
SetLastError(prefix + string.Join(", ", missing));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryGetGrantedGraphPermissions(string accessToken, out HashSet<string> granted, out string errorMessage)
|
||||||
|
{
|
||||||
|
granted = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
errorMessage = null;
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(accessToken))
|
if (string.IsNullOrWhiteSpace(accessToken))
|
||||||
{
|
{
|
||||||
SetLastError("Kein Access Token für Berechtigungsprüfung verfügbar");
|
errorMessage = "Kein Access Token für Berechtigungsprüfung verfügbar";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,7 +297,7 @@ namespace C4IT.LIAM
|
|||||||
var parts = accessToken.Split('.');
|
var parts = accessToken.Split('.');
|
||||||
if (parts.Length < 2)
|
if (parts.Length < 2)
|
||||||
{
|
{
|
||||||
SetLastError("Ungültiges Access Token");
|
errorMessage = "Ungültiges Access Token";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,12 +313,10 @@ namespace C4IT.LIAM
|
|||||||
var payloadObj = JsonConvert.DeserializeObject<JObject>(payloadJson);
|
var payloadObj = JsonConvert.DeserializeObject<JObject>(payloadJson);
|
||||||
if (payloadObj == null)
|
if (payloadObj == null)
|
||||||
{
|
{
|
||||||
SetLastError("Token-Payload konnte nicht gelesen werden");
|
errorMessage = "Token-Payload konnte nicht gelesen werden";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var granted = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
if (payloadObj.TryGetValue("roles", out var rolesToken) && rolesToken is JArray roleArray)
|
if (payloadObj.TryGetValue("roles", out var rolesToken) && rolesToken is JArray roleArray)
|
||||||
{
|
{
|
||||||
foreach (var role in roleArray.Values<string>())
|
foreach (var role in roleArray.Values<string>())
|
||||||
@@ -231,10 +333,9 @@ namespace C4IT.LIAM
|
|||||||
granted.Add(scope);
|
granted.Add(scope);
|
||||||
}
|
}
|
||||||
|
|
||||||
var missing = RequiredGraphRoles.Where(required => !granted.Contains(required)).ToList();
|
if (!granted.Any())
|
||||||
if (missing.Count > 0)
|
|
||||||
{
|
{
|
||||||
SetLastError("Fehlende Graph-Berechtigungen: " + string.Join(", ", missing));
|
errorMessage = "Keine Graph-Berechtigungen im Access Token gefunden";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,7 +343,7 @@ namespace C4IT.LIAM
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
SetLastError("Berechtigungsprüfung fehlgeschlagen: " + ex.Message);
|
errorMessage = "Berechtigungsprüfung fehlgeschlagen: " + ex.Message;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -349,6 +450,11 @@ namespace C4IT.LIAM
|
|||||||
|
|
||||||
public async Task<cMsGraphResultBase> cloneTeam(string teamId, string name, string description, int visibility, int partsToClone, string additionalMembers, string additionalOwners)
|
public async Task<cMsGraphResultBase> cloneTeam(string teamId, string name, string description, int visibility, int partsToClone, string additionalMembers, string additionalOwners)
|
||||||
{
|
{
|
||||||
|
if (!await LogonAsync())
|
||||||
|
return null;
|
||||||
|
if (!EnsureClonePermissions(MsSharepoint.Base?.AccessToken, partsToClone))
|
||||||
|
return null;
|
||||||
|
|
||||||
var request = new CloneTeamRequest()
|
var request = new CloneTeamRequest()
|
||||||
{
|
{
|
||||||
DisplayName = name,
|
DisplayName = name,
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ namespace C4IT.LIAM
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static Guid nftsModuleId = new Guid("77e213a1-6517-ea11-4881-000c2980fd94");
|
public static Guid nftsModuleId = new Guid("77e213a1-6517-ea11-4881-000c2980fd94");
|
||||||
|
private const string AdditionalConfigurationExcludePathsKey = "NtfsExcludePaths";
|
||||||
|
private const string AdditionalConfigurationIncludePathsKey = "NtfsIncludePaths";
|
||||||
public readonly cNtfsBase ntfsBase = new cNtfsBase();
|
public readonly cNtfsBase ntfsBase = new cNtfsBase();
|
||||||
public readonly cActiveDirectoryBase activeDirectoryBase = new cActiveDirectoryBase();
|
public readonly cActiveDirectoryBase activeDirectoryBase = new cActiveDirectoryBase();
|
||||||
private readonly Dictionary<string, HashSet<string>> publishedShareCache = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, HashSet<string>> publishedShareCache = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
|
||||||
@@ -208,6 +210,9 @@ namespace C4IT.LIAM
|
|||||||
if (!await LogonAsync())
|
if (!await LogonAsync())
|
||||||
return null;
|
return null;
|
||||||
var classification = ClassifyPath(UID);
|
var classification = ClassifyPath(UID);
|
||||||
|
if (!PathsEqual(classification?.NormalizedPath, this.RootPath) && !ShouldIncludeDataArea(classification))
|
||||||
|
return null;
|
||||||
|
|
||||||
return await BuildDataAreaAsync(classification);
|
return await BuildDataAreaAsync(classification);
|
||||||
}
|
}
|
||||||
catch (Exception E)
|
catch (Exception E)
|
||||||
@@ -371,14 +376,16 @@ namespace C4IT.LIAM
|
|||||||
foreach (var childPath in GetServerRootChildPaths(parentClassification.NormalizedPath))
|
foreach (var childPath in GetServerRootChildPaths(parentClassification.NormalizedPath))
|
||||||
{
|
{
|
||||||
var childClassification = ClassifyPath(childPath);
|
var childClassification = ClassifyPath(childPath);
|
||||||
if (!ShouldIncludeDataArea(childClassification.DisplayName))
|
if (!ShouldTraverseDataArea(childClassification))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
|
if (ShouldIncludeDataArea(childClassification))
|
||||||
|
{
|
||||||
var childDataArea = await BuildDataAreaAsync(childClassification);
|
var childDataArea = await BuildDataAreaAsync(childClassification);
|
||||||
if (childDataArea == null)
|
if (childDataArea != null)
|
||||||
continue;
|
|
||||||
|
|
||||||
children.Add(childDataArea);
|
children.Add(childDataArea);
|
||||||
|
}
|
||||||
|
|
||||||
if (depth > 1)
|
if (depth > 1)
|
||||||
children.AddRange(await GetChildDataAreasAsync(childClassification, depth - 1));
|
children.AddRange(await GetChildDataAreasAsync(childClassification, depth - 1));
|
||||||
}
|
}
|
||||||
@@ -393,14 +400,16 @@ namespace C4IT.LIAM
|
|||||||
foreach (var entry in folderEntries.Values.OfType<cNtfsResultFolder>())
|
foreach (var entry in folderEntries.Values.OfType<cNtfsResultFolder>())
|
||||||
{
|
{
|
||||||
var childClassification = ClassifyPath(entry.Path);
|
var childClassification = ClassifyPath(entry.Path);
|
||||||
if (!ShouldIncludeDataArea(childClassification.DisplayName))
|
if (!ShouldTraverseDataArea(childClassification))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
|
if (ShouldIncludeDataArea(childClassification))
|
||||||
|
{
|
||||||
var childDataArea = await BuildDataAreaAsync(childClassification, entry);
|
var childDataArea = await BuildDataAreaAsync(childClassification, entry);
|
||||||
if (childDataArea == null)
|
if (childDataArea != null)
|
||||||
continue;
|
|
||||||
|
|
||||||
children.Add(childDataArea);
|
children.Add(childDataArea);
|
||||||
|
}
|
||||||
|
|
||||||
if (depth > 1)
|
if (depth > 1)
|
||||||
children.AddRange(await GetChildDataAreasAsync(childClassification, depth - 1));
|
children.AddRange(await GetChildDataAreasAsync(childClassification, depth - 1));
|
||||||
}
|
}
|
||||||
@@ -420,7 +429,55 @@ namespace C4IT.LIAM
|
|||||||
.Select(shareName => BuildUncPath(new[] { serverName, shareName }, 2));
|
.Select(shareName => BuildUncPath(new[] { serverName, shareName }, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool ShouldIncludeDataArea(string displayName)
|
private bool ShouldIncludeDataArea(cNtfsPathClassification classification)
|
||||||
|
{
|
||||||
|
if (classification == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (!MatchesDataAreaRegEx(classification.DisplayName))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
string matchingConfigurationKey;
|
||||||
|
string matchingRule;
|
||||||
|
if (IsPathBlacklisted(classification, out matchingConfigurationKey, out matchingRule))
|
||||||
|
{
|
||||||
|
LogEntry($"Skip NTFS path '{classification.NormalizedPath}' due to AdditionalConfiguration rule '{matchingConfigurationKey}={matchingRule}'", LogLevels.Debug);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsPathWhitelisted(classification, true, out matchingConfigurationKey, out matchingRule))
|
||||||
|
{
|
||||||
|
LogEntry($"Skip NTFS path '{classification.NormalizedPath}' because no AdditionalConfiguration whitelist matched", LogLevels.Debug);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool ShouldTraverseDataArea(cNtfsPathClassification classification)
|
||||||
|
{
|
||||||
|
if (classification == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
string matchingConfigurationKey;
|
||||||
|
string matchingRule;
|
||||||
|
if (IsPathBlacklisted(classification, out matchingConfigurationKey, out matchingRule))
|
||||||
|
{
|
||||||
|
LogEntry($"Skip NTFS subtree '{classification.NormalizedPath}' due to AdditionalConfiguration rule '{matchingConfigurationKey}={matchingRule}'", LogLevels.Debug);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!HasAdditionalConfigurationValues(AdditionalConfigurationIncludePathsKey))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (IsPathWhitelisted(classification, true, out matchingConfigurationKey, out matchingRule))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
LogEntry($"Skip NTFS subtree '{classification.NormalizedPath}' because it is outside AdditionalConfiguration whitelist '{AdditionalConfigurationIncludePathsKey}'", LogLevels.Debug);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool MatchesDataAreaRegEx(string displayName)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(this.DataAreaRegEx))
|
if (string.IsNullOrEmpty(this.DataAreaRegEx))
|
||||||
return true;
|
return true;
|
||||||
@@ -428,6 +485,197 @@ namespace C4IT.LIAM
|
|||||||
return Regex.Match(displayName ?? string.Empty, this.DataAreaRegEx).Success;
|
return Regex.Match(displayName ?? string.Empty, this.DataAreaRegEx).Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool IsPathBlacklisted(cNtfsPathClassification classification, out string matchingConfigurationKey, out string matchingRule)
|
||||||
|
{
|
||||||
|
return TryMatchPathPolicy(classification, AdditionalConfigurationExcludePathsKey, false, out matchingConfigurationKey, out matchingRule);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsPathWhitelisted(cNtfsPathClassification classification, bool allowPathAncestorMatches, out string matchingConfigurationKey, out string matchingRule)
|
||||||
|
{
|
||||||
|
matchingConfigurationKey = null;
|
||||||
|
matchingRule = null;
|
||||||
|
|
||||||
|
if (!HasAdditionalConfigurationValues(AdditionalConfigurationIncludePathsKey))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return TryMatchPathPolicy(classification, AdditionalConfigurationIncludePathsKey, allowPathAncestorMatches, out matchingConfigurationKey, out matchingRule);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryMatchPathPolicy(cNtfsPathClassification classification, string key, bool allowPathAncestorMatches, out string matchingConfigurationKey, out string matchingRule)
|
||||||
|
{
|
||||||
|
matchingConfigurationKey = null;
|
||||||
|
matchingRule = null;
|
||||||
|
|
||||||
|
if (classification == null || string.IsNullOrWhiteSpace(key))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var patterns = GetAdditionalConfigurationValues(key).ToList();
|
||||||
|
if (patterns.Count == 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
foreach (var pattern in patterns)
|
||||||
|
{
|
||||||
|
if (!MatchesPathPolicy(classification, pattern))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
matchingConfigurationKey = key;
|
||||||
|
matchingRule = pattern;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowPathAncestorMatches)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
foreach (var pattern in patterns)
|
||||||
|
{
|
||||||
|
if (!CanPathLeadToPattern(classification, pattern))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
matchingConfigurationKey = key;
|
||||||
|
matchingRule = pattern;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool HasAdditionalConfigurationValues(string key)
|
||||||
|
{
|
||||||
|
return GetAdditionalConfigurationValues(key).Any();
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<string> GetAdditionalConfigurationValues(string key)
|
||||||
|
{
|
||||||
|
if (AdditionalConfiguration == null || string.IsNullOrWhiteSpace(key))
|
||||||
|
return Enumerable.Empty<string>();
|
||||||
|
|
||||||
|
string rawValue;
|
||||||
|
if (!AdditionalConfiguration.TryGetValue(key, out rawValue) || string.IsNullOrWhiteSpace(rawValue))
|
||||||
|
return Enumerable.Empty<string>();
|
||||||
|
|
||||||
|
return rawValue
|
||||||
|
.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Select(i => i.Trim())
|
||||||
|
.Where(i => !string.IsNullOrWhiteSpace(i))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetRelativePathFromRoot(string path)
|
||||||
|
{
|
||||||
|
var normalizedRoot = NormalizeUncPath(this.RootPath);
|
||||||
|
var normalizedPath = NormalizeUncPath(path);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(normalizedRoot) || string.IsNullOrWhiteSpace(normalizedPath))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
if (PathsEqual(normalizedRoot, normalizedPath))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
var rootWithSeparator = normalizedRoot + "\\";
|
||||||
|
if (!normalizedPath.StartsWith(rootWithSeparator, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return normalizedPath;
|
||||||
|
|
||||||
|
return normalizedPath.Substring(rootWithSeparator.Length)
|
||||||
|
.Trim()
|
||||||
|
.TrimStart('\\')
|
||||||
|
.Replace('/', '\\');
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool MatchesPathPolicy(cNtfsPathClassification classification, string pattern)
|
||||||
|
{
|
||||||
|
if (classification == null || string.IsNullOrWhiteSpace(pattern))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
foreach (var candidate in GetPathPolicyCandidates(classification))
|
||||||
|
{
|
||||||
|
if (MatchesAdditionalConfigurationPattern(candidate, pattern))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<string> GetPathPolicyCandidates(cNtfsPathClassification classification)
|
||||||
|
{
|
||||||
|
if (classification == null)
|
||||||
|
return Enumerable.Empty<string>();
|
||||||
|
|
||||||
|
var candidates = new List<string>();
|
||||||
|
var relativePath = GetRelativePathFromRoot(classification.NormalizedPath);
|
||||||
|
if (!string.IsNullOrWhiteSpace(relativePath))
|
||||||
|
candidates.Add(relativePath);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(classification.NormalizedPath))
|
||||||
|
candidates.Add(classification.NormalizedPath);
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
.Where(i => !string.IsNullOrWhiteSpace(i))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool MatchesAdditionalConfigurationPattern(string value, string pattern)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value) || string.IsNullOrWhiteSpace(pattern))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var normalizedValue = value.Trim().Replace('/', '\\').Trim('\\');
|
||||||
|
var normalizedPattern = pattern.Trim().Replace('/', '\\').Trim('\\');
|
||||||
|
if (string.IsNullOrWhiteSpace(normalizedPattern))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var regexPattern = "^" + Regex.Escape(normalizedPattern).Replace("\\*", ".*") + "$";
|
||||||
|
return Regex.IsMatch(normalizedValue, regexPattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanPathLeadToPattern(cNtfsPathClassification classification, string pattern)
|
||||||
|
{
|
||||||
|
if (classification == null || string.IsNullOrWhiteSpace(pattern))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
foreach (var candidate in GetPathPolicyCandidates(classification))
|
||||||
|
{
|
||||||
|
if (IsPathAncestorOfPattern(candidate, pattern))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsPathAncestorOfPattern(string path, string pattern)
|
||||||
|
{
|
||||||
|
var normalizedPath = (path ?? string.Empty).Trim().Replace('/', '\\').Trim('\\');
|
||||||
|
var normalizedPattern = (pattern ?? string.Empty).Trim().Replace('/', '\\').Trim('\\');
|
||||||
|
if (string.IsNullOrWhiteSpace(normalizedPath) || string.IsNullOrWhiteSpace(normalizedPattern))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var pathSegments = normalizedPath.Split(new[] { '\\' }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
var patternSegments = normalizedPattern.Split(new[] { '\\' }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (pathSegments.Length > patternSegments.Length)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
for (var segmentIndex = 0; segmentIndex < pathSegments.Length; segmentIndex++)
|
||||||
|
{
|
||||||
|
if (segmentIndex >= patternSegments.Length)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (!MatchesPatternSegment(pathSegments[segmentIndex], patternSegments[segmentIndex]))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool MatchesPatternSegment(string valueSegment, string patternSegment)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(valueSegment) || string.IsNullOrWhiteSpace(patternSegment))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var regexPattern = "^" + Regex.Escape(patternSegment).Replace("\\*", ".*") + "$";
|
||||||
|
return Regex.IsMatch(valueSegment, regexPattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||||
|
}
|
||||||
|
|
||||||
private List<string> GetDfsObjectPrefixes(string path)
|
private List<string> GetDfsObjectPrefixes(string path)
|
||||||
{
|
{
|
||||||
var normalizedPath = NormalizeUncPath(path);
|
var normalizedPath = NormalizeUncPath(path);
|
||||||
@@ -645,15 +893,44 @@ namespace C4IT.LIAM
|
|||||||
IEnumerable<string> ownerSids,
|
IEnumerable<string> ownerSids,
|
||||||
IEnumerable<string> readerSids,
|
IEnumerable<string> readerSids,
|
||||||
IEnumerable<string> writerSids,
|
IEnumerable<string> writerSids,
|
||||||
|
bool allowSharePathEnsure = false,
|
||||||
bool ensureTraverseGroups = false,
|
bool ensureTraverseGroups = false,
|
||||||
bool whatIf = false)
|
bool whatIf = false)
|
||||||
{
|
{
|
||||||
if (!IsPermissionManagedFolderPath(folderPath))
|
var classification = ClassifyPath(folderPath);
|
||||||
|
var allowShareKinds = allowSharePathEnsure;
|
||||||
|
if (!IsSupportedPermissionManagedPathKind(
|
||||||
|
classification,
|
||||||
|
allowShareKinds
|
||||||
|
? new[] { eNtfsPathKind.Folder, eNtfsPathKind.ClassicShare, eNtfsPathKind.DfsLink }
|
||||||
|
: new[] { eNtfsPathKind.Folder }))
|
||||||
{
|
{
|
||||||
return Task.FromResult(new ResultToken(System.Reflection.MethodBase.GetCurrentMethod().ToString())
|
return Task.FromResult(new ResultToken(System.Reflection.MethodBase.GetCurrentMethod().ToString())
|
||||||
{
|
{
|
||||||
resultErrorId = 30008,
|
resultErrorId = 30008,
|
||||||
resultMessage = $"NTFS permission ensure is only supported for folder paths. Shares, DFS namespaces and server roots are skipped: {folderPath}"
|
resultMessage = allowShareKinds
|
||||||
|
? $"NTFS permission ensure is only supported for folder and share paths. DFS namespaces and server roots are skipped: {folderPath}"
|
||||||
|
: $"NTFS permission ensure is only supported for folder paths unless share support is explicitly enabled. Shares, DFS namespaces and server roots are skipped: {folderPath}"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
string matchingConfigurationKey;
|
||||||
|
string matchingRule;
|
||||||
|
if (IsPathBlacklisted(classification, out matchingConfigurationKey, out matchingRule))
|
||||||
|
{
|
||||||
|
return Task.FromResult(new ResultToken(System.Reflection.MethodBase.GetCurrentMethod().ToString())
|
||||||
|
{
|
||||||
|
resultErrorId = 30008,
|
||||||
|
resultMessage = $"NTFS permission ensure skipped for '{folderPath}' due to AdditionalConfiguration rule '{matchingConfigurationKey}={matchingRule}'."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsPathWhitelisted(classification, false, out matchingConfigurationKey, out matchingRule))
|
||||||
|
{
|
||||||
|
return Task.FromResult(new ResultToken(System.Reflection.MethodBase.GetCurrentMethod().ToString())
|
||||||
|
{
|
||||||
|
resultErrorId = 30008,
|
||||||
|
resultMessage = $"NTFS permission ensure skipped for '{folderPath}' because no AdditionalConfiguration whitelist matched."
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -667,7 +944,12 @@ namespace C4IT.LIAM
|
|||||||
writerSids);
|
writerSids);
|
||||||
engine.WhatIf = whatIf;
|
engine.WhatIf = whatIf;
|
||||||
|
|
||||||
return Task.FromResult(engine.ensureDataAreaPermissions(ensureTraverseGroups));
|
var allowTraverseGroups = classification.Kind == eNtfsPathKind.Folder && ensureTraverseGroups;
|
||||||
|
var resultToken = engine.ensureDataAreaPermissions(allowTraverseGroups);
|
||||||
|
if (!allowTraverseGroups && ensureTraverseGroups)
|
||||||
|
resultToken.warnings.Add($"Traverse groups are currently only ensured for folder paths. Traverse processing was skipped for '{folderPath}'.");
|
||||||
|
|
||||||
|
return Task.FromResult(resultToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private DataArea_FileSystem CreateFilesystemEngine(
|
private DataArea_FileSystem CreateFilesystemEngine(
|
||||||
@@ -737,9 +1019,35 @@ namespace C4IT.LIAM
|
|||||||
}
|
}
|
||||||
|
|
||||||
public bool IsPermissionManagedFolderPath(string path)
|
public bool IsPermissionManagedFolderPath(string path)
|
||||||
|
{
|
||||||
|
return IsPermissionManagedPath(path, eNtfsPathKind.Folder);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsPermissionManagedSharePath(string path)
|
||||||
|
{
|
||||||
|
return IsPermissionManagedPath(path, eNtfsPathKind.ClassicShare, eNtfsPathKind.DfsLink);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsPermissionManagedPath(string path, params eNtfsPathKind[] supportedKinds)
|
||||||
{
|
{
|
||||||
var classification = ClassifyPath(path);
|
var classification = ClassifyPath(path);
|
||||||
return classification != null && classification.Kind == eNtfsPathKind.Folder;
|
if (!IsSupportedPermissionManagedPathKind(classification, supportedKinds))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
string matchingConfigurationKey;
|
||||||
|
string matchingRule;
|
||||||
|
if (IsPathBlacklisted(classification, out matchingConfigurationKey, out matchingRule))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return IsPathWhitelisted(classification, false, out matchingConfigurationKey, out matchingRule);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsSupportedPermissionManagedPathKind(cNtfsPathClassification classification, params eNtfsPathKind[] supportedKinds)
|
||||||
|
{
|
||||||
|
if (classification == null || supportedKinds == null || supportedKinds.Length == 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return supportedKinds.Contains(classification.Kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
private IEnumerable<IAM_SecurityGroupTemplate> BuildSecurityGroupTemplates()
|
private IEnumerable<IAM_SecurityGroupTemplate> BuildSecurityGroupTemplates()
|
||||||
|
|||||||
@@ -539,11 +539,24 @@ namespace C4IT_IAM_SET
|
|||||||
null,
|
null,
|
||||||
Helper.MaxAdGroupNameLength,
|
Helper.MaxAdGroupNameLength,
|
||||||
$"Traverse fuer '{parent.FullName}'");
|
$"Traverse fuer '{parent.FullName}'");
|
||||||
|
var boundedTraverseDescriptionContext = Helper.GetBoundedAdGroupTemplateContext(
|
||||||
|
traverseGroupTemplate.DescriptionTemplate,
|
||||||
|
true,
|
||||||
|
relativePath,
|
||||||
|
sanitizedSegments,
|
||||||
|
folderName,
|
||||||
|
null,
|
||||||
|
Helper.MaxAdGroupDescriptionLength,
|
||||||
|
$"Traverse fuer '{parent.FullName}'",
|
||||||
|
"AD-Gruppenbeschreibung");
|
||||||
var adjustedTraverseSegments = boundedTraverseContext.SanitizedSegments ?? Array.Empty<string>();
|
var adjustedTraverseSegments = boundedTraverseContext.SanitizedSegments ?? Array.Empty<string>();
|
||||||
var adjustedTraverseRelativePath = adjustedTraverseSegments.Length > 0 ? string.Join("_", adjustedTraverseSegments) : string.Empty;
|
var adjustedTraverseRelativePath = adjustedTraverseSegments.Length > 0 ? string.Join("_", adjustedTraverseSegments) : string.Empty;
|
||||||
var adjustedTraverseFolderName = boundedTraverseContext.FolderName;
|
var adjustedTraverseFolderName = boundedTraverseContext.FolderName;
|
||||||
|
var adjustedTraverseDescriptionSegments = boundedTraverseDescriptionContext.SanitizedSegments ?? Array.Empty<string>();
|
||||||
|
var adjustedTraverseDescriptionRelativePath = adjustedTraverseDescriptionSegments.Length > 0 ? string.Join("_", adjustedTraverseDescriptionSegments) : string.Empty;
|
||||||
|
var adjustedTraverseDescriptionFolderName = boundedTraverseDescriptionContext.FolderName;
|
||||||
var traverseNameTemplate = Helper.ApplyTemplatePlaceholders(traverseGroupTemplate.NamingTemplate, true, adjustedTraverseRelativePath, adjustedTraverseSegments, adjustedTraverseFolderName);
|
var traverseNameTemplate = Helper.ApplyTemplatePlaceholders(traverseGroupTemplate.NamingTemplate, true, adjustedTraverseRelativePath, adjustedTraverseSegments, adjustedTraverseFolderName);
|
||||||
var traverseDescriptionTemplate = Helper.ApplyTemplatePlaceholders(traverseGroupTemplate.DescriptionTemplate, true, adjustedTraverseRelativePath, adjustedTraverseSegments, adjustedTraverseFolderName);
|
var traverseDescriptionTemplate = Helper.ApplyTemplatePlaceholders(traverseGroupTemplate.DescriptionTemplate, true, adjustedTraverseDescriptionRelativePath, adjustedTraverseDescriptionSegments, adjustedTraverseDescriptionFolderName);
|
||||||
|
|
||||||
string traverseRegex = null;
|
string traverseRegex = null;
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ namespace C4IT_IAM_Engine
|
|||||||
public static class Helper
|
public static class Helper
|
||||||
{
|
{
|
||||||
public const int MaxAdGroupNameLength = 64;
|
public const int MaxAdGroupNameLength = 64;
|
||||||
|
public const int MaxAdGroupDescriptionLength = 1024;
|
||||||
public const int MaxAdGroupLoopDigits = 3;
|
public const int MaxAdGroupLoopDigits = 3;
|
||||||
private const int MinLeadingRelativePathSegmentLength = 3;
|
private const int MinLeadingRelativePathSegmentLength = 3;
|
||||||
private const int MinSingleLeadingRelativePathSegmentLength = 2;
|
private const int MinSingleLeadingRelativePathSegmentLength = 2;
|
||||||
@@ -74,7 +75,8 @@ namespace C4IT_IAM_Engine
|
|||||||
string folderName,
|
string folderName,
|
||||||
IDictionary<string, string> replacementTags,
|
IDictionary<string, string> replacementTags,
|
||||||
int maxLength,
|
int maxLength,
|
||||||
string logContext)
|
string logContext,
|
||||||
|
string valueLabel = "AD-Gruppenname")
|
||||||
{
|
{
|
||||||
var effectiveSegments = (sanitizedSegments ?? Array.Empty<string>()).Where(i => i != null).ToArray();
|
var effectiveSegments = (sanitizedSegments ?? Array.Empty<string>()).Where(i => i != null).ToArray();
|
||||||
var effectiveFolderName = folderName ?? string.Empty;
|
var effectiveFolderName = folderName ?? string.Empty;
|
||||||
@@ -132,14 +134,14 @@ namespace C4IT_IAM_Engine
|
|||||||
{
|
{
|
||||||
cLogManager.DefaultLogger.LogEntry(
|
cLogManager.DefaultLogger.LogEntry(
|
||||||
LogLevels.Warning,
|
LogLevels.Warning,
|
||||||
$"AD-Gruppenname gekuerzt ({logContext}): '{result.OriginalValue}' ({GetMeasuredTemplateLength(result.OriginalValue)}) -> '{result.FinalValue}' ({GetMeasuredTemplateLength(result.FinalValue)}), Strategie: {result.Strategy}, Limit: {maxLength}.");
|
$"{valueLabel} gekuerzt ({logContext}): '{result.OriginalValue}' ({GetMeasuredTemplateLength(result.OriginalValue)}) -> '{result.FinalValue}' ({GetMeasuredTemplateLength(result.FinalValue)}), Strategie: {result.Strategy}, Limit: {maxLength}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (measuredValue.Length > maxLength)
|
if (measuredValue.Length > maxLength)
|
||||||
{
|
{
|
||||||
cLogManager.DefaultLogger.LogEntry(
|
cLogManager.DefaultLogger.LogEntry(
|
||||||
LogLevels.Warning,
|
LogLevels.Warning,
|
||||||
$"AD-Gruppenname ueberschreitet weiterhin das sichere Limit ({logContext}): '{result.FinalValue}' ({measuredValue.Length}), Limit: {maxLength}.");
|
$"{valueLabel} ueberschreitet weiterhin das sichere Limit ({logContext}): '{result.FinalValue}' ({measuredValue.Length}), Limit: {maxLength}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -208,20 +208,34 @@ namespace C4IT_IAM_Engine
|
|||||||
Helper.MaxAdGroupNameLength,
|
Helper.MaxAdGroupNameLength,
|
||||||
$"{template.Type}/{template.Scope} fuer '{newFolderPath}'");
|
$"{template.Type}/{template.Scope} fuer '{newFolderPath}'");
|
||||||
|
|
||||||
var adjustedSegments = boundedNameContext.SanitizedSegments ?? Array.Empty<string>();
|
var boundedDescriptionContext = Helper.GetBoundedAdGroupTemplateContext(
|
||||||
var adjustedRelativePath = adjustedSegments.Length > 0 ? string.Join("_", adjustedSegments) : string.Empty;
|
template.DescriptionTemplate,
|
||||||
var adjustedFolderName = boundedNameContext.FolderName;
|
template.Type != SecurityGroupType.Traverse,
|
||||||
|
relativePath,
|
||||||
|
sanitizedSegments,
|
||||||
|
folderName,
|
||||||
|
replacementTags,
|
||||||
|
Helper.MaxAdGroupDescriptionLength,
|
||||||
|
$"{template.Type}/{template.Scope} fuer '{newFolderPath}'",
|
||||||
|
"AD-Gruppenbeschreibung");
|
||||||
|
|
||||||
template.NamingTemplate = Helper.ApplyTemplatePlaceholders(template.NamingTemplate, template.Type != SecurityGroupType.Traverse, adjustedRelativePath, adjustedSegments, adjustedFolderName)
|
var adjustedNameSegments = boundedNameContext.SanitizedSegments ?? Array.Empty<string>();
|
||||||
|
var adjustedNameRelativePath = adjustedNameSegments.Length > 0 ? string.Join("_", adjustedNameSegments) : string.Empty;
|
||||||
|
var adjustedNameFolderName = boundedNameContext.FolderName;
|
||||||
|
var adjustedDescriptionSegments = boundedDescriptionContext.SanitizedSegments ?? Array.Empty<string>();
|
||||||
|
var adjustedDescriptionRelativePath = adjustedDescriptionSegments.Length > 0 ? string.Join("_", adjustedDescriptionSegments) : string.Empty;
|
||||||
|
var adjustedDescriptionFolderName = boundedDescriptionContext.FolderName;
|
||||||
|
|
||||||
|
template.NamingTemplate = Helper.ApplyTemplatePlaceholders(template.NamingTemplate, template.Type != SecurityGroupType.Traverse, adjustedNameRelativePath, adjustedNameSegments, adjustedNameFolderName)
|
||||||
.ReplaceTags(customTags).ReplaceTags(tags)
|
.ReplaceTags(customTags).ReplaceTags(tags)
|
||||||
.ToUpper();
|
.ToUpper();
|
||||||
|
|
||||||
template.DescriptionTemplate = Helper.ApplyTemplatePlaceholders(template.DescriptionTemplate, template.Type != SecurityGroupType.Traverse, adjustedRelativePath, adjustedSegments, adjustedFolderName)
|
template.DescriptionTemplate = Helper.ApplyTemplatePlaceholders(template.DescriptionTemplate, template.Type != SecurityGroupType.Traverse, adjustedDescriptionRelativePath, adjustedDescriptionSegments, adjustedDescriptionFolderName)
|
||||||
.ReplaceTags(customTags).ReplaceTags(tags)
|
.ReplaceTags(customTags).ReplaceTags(tags)
|
||||||
.ToUpper();
|
.ToUpper();
|
||||||
|
|
||||||
|
|
||||||
template.WildcardTemplate = Helper.ApplyTemplatePlaceholders(template.WildcardTemplate, template.Type != SecurityGroupType.Traverse, adjustedRelativePath, adjustedSegments, adjustedFolderName)
|
template.WildcardTemplate = Helper.ApplyTemplatePlaceholders(template.WildcardTemplate, template.Type != SecurityGroupType.Traverse, adjustedNameRelativePath, adjustedNameSegments, adjustedNameFolderName)
|
||||||
.ReplaceTags(customTags).ReplaceTags(tags)
|
.ReplaceTags(customTags).ReplaceTags(tags)
|
||||||
.ToUpper();
|
.ToUpper();
|
||||||
|
|
||||||
|
|||||||
@@ -909,7 +909,7 @@ namespace C4IT.LIAM.Activities
|
|||||||
public InArgument<Guid> ConfigID { get; set; }
|
public InArgument<Guid> ConfigID { get; set; }
|
||||||
|
|
||||||
[Category("Input")]
|
[Category("Input")]
|
||||||
[DisplayName("Folder Path")]
|
[DisplayName("Path")]
|
||||||
[RequiredArgument]
|
[RequiredArgument]
|
||||||
public InArgument<string> FolderPath { get; set; }
|
public InArgument<string> FolderPath { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -198,12 +198,14 @@ namespace LiamWorkflowActivities
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var allowSharePathEnsure = IsAdditionalConfigurationEnabled(provider, "AllowManualNtfsPermissionEnsureForShares");
|
||||||
var token = await ntfsProvider.EnsureMissingPermissionGroupsAsync(
|
var token = await ntfsProvider.EnsureMissingPermissionGroupsAsync(
|
||||||
folderPath,
|
folderPath,
|
||||||
customTags,
|
customTags,
|
||||||
NormalizeIdentifierList(ownerSids),
|
NormalizeIdentifierList(ownerSids),
|
||||||
NormalizeIdentifierList(readerSids),
|
NormalizeIdentifierList(readerSids),
|
||||||
NormalizeIdentifierList(writerSids),
|
NormalizeIdentifierList(writerSids),
|
||||||
|
allowSharePathEnsure,
|
||||||
ensureTraverseGroups,
|
ensureTraverseGroups,
|
||||||
IsWorkflowWhatIfEnabled(provider));
|
IsWorkflowWhatIfEnabled(provider));
|
||||||
if (token == null)
|
if (token == null)
|
||||||
@@ -417,10 +419,16 @@ namespace LiamWorkflowActivities
|
|||||||
if (!(provider is cLiamProviderNtfs ntfsProvider))
|
if (!(provider is cLiamProviderNtfs ntfsProvider))
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
if (!IsAdditionalConfigurationEnabled(provider, "EnsureNtfsPermissionGroups"))
|
var allowFolderEnsure = IsAdditionalConfigurationEnabled(provider, "EnsureNtfsPermissionGroups");
|
||||||
|
var allowSharePathEnsure = IsAdditionalConfigurationEnabled(provider, "EnsureNtfsPermissionGroupsForShares");
|
||||||
|
if (!allowFolderEnsure && !allowSharePathEnsure)
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
foreach (var ntfsArea in dataAreas.OfType<cLiamNtfsFolder>())
|
foreach (var ntfsArea in dataAreas
|
||||||
|
.Where(dataArea =>
|
||||||
|
allowFolderEnsure && dataArea is cLiamNtfsFolder
|
||||||
|
|| allowSharePathEnsure && dataArea is cLiamNtfsShare)
|
||||||
|
.Cast<cLiamNtfsPermissionDataAreaBase>())
|
||||||
{
|
{
|
||||||
var folderPath = ntfsArea.TechnicalName;
|
var folderPath = ntfsArea.TechnicalName;
|
||||||
if (string.IsNullOrWhiteSpace(folderPath))
|
if (string.IsNullOrWhiteSpace(folderPath))
|
||||||
@@ -438,6 +446,7 @@ namespace LiamWorkflowActivities
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
|
allowSharePathEnsure,
|
||||||
false,
|
false,
|
||||||
simulateOnly);
|
simulateOnly);
|
||||||
if (ensureResult == null)
|
if (ensureResult == null)
|
||||||
@@ -660,7 +669,8 @@ namespace LiamWorkflowActivities
|
|||||||
ConfigurationId = configurationId ?? string.Empty,
|
ConfigurationId = configurationId ?? string.Empty,
|
||||||
BaseFolder = ntfsFolder?.Share?.TechnicalName ?? dataArea.Provider?.RootPath ?? string.Empty,
|
BaseFolder = ntfsFolder?.Share?.TechnicalName ?? dataArea.Provider?.RootPath ?? string.Empty,
|
||||||
UniqueId = dataArea.UID ?? string.Empty,
|
UniqueId = dataArea.UID ?? string.Empty,
|
||||||
DataAreaType = dataArea.DataType.ToString()
|
DataAreaType = dataArea.DataType.ToString(),
|
||||||
|
DataAreaTypeId = (int)dataArea.DataType
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
29
LiamWorkflowDiagnostics/SignSourceFiles.cmd
Normal file
29
LiamWorkflowDiagnostics/SignSourceFiles.cmd
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal EnableDelayedExpansion
|
||||||
|
|
||||||
|
set "ProductName=C4IT Light Identity Access Management"
|
||||||
|
set "SignTool=..\..\Common Code\Tools\signtool.exe"
|
||||||
|
set "TimeStamp=http://rfc3161timestamp.globalsign.com/advanced"
|
||||||
|
|
||||||
|
set "FileList="
|
||||||
|
|
||||||
|
if exist ".\bin\Release\LiamWorkflowDiagnostics.exe" (
|
||||||
|
set "FileList=!FileList! ".\bin\Release\LiamWorkflowDiagnostics.exe""
|
||||||
|
)
|
||||||
|
|
||||||
|
for %%F in (".\bin\Release\Liam*.dll") do (
|
||||||
|
if exist "%%~fF" (
|
||||||
|
set "FileList=!FileList! "%%F""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not defined FileList (
|
||||||
|
echo No matching release binaries found to sign.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo Signing all matching files at once...
|
||||||
|
call "%SignTool%" sign /a /tr %TimeStamp% /td SHA256 /fd SHA256 /d "%ProductName%" !FileList!
|
||||||
|
|
||||||
|
pause
|
||||||
BIN
Sonstiges/Icon1.ico
Normal file
BIN
Sonstiges/Icon1.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 200 KiB |
@@ -0,0 +1,307 @@
|
|||||||
|
# LIAM NTFS AdditionalConfiguration Blacklist / Whitelist - Technischer Entwurf
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Dieses Dokument beschreibt einen kleinen technischen Entwurf, um dem NTFS-Provider ueber `AdditionalConfiguration` eine Blacklist und spaeter optional auch eine Whitelist fuer NTFS-Pfade mitzugeben.
|
||||||
|
|
||||||
|
Die Policy soll klassifizierungsunabhaengig arbeiten und damit fuer Shares, DFS-Links, DFS-Namespaces und Folder dieselbe Matching-Logik verwenden.
|
||||||
|
|
||||||
|
## Ausgangslage
|
||||||
|
|
||||||
|
Der NTFS-Provider verfuegt heute bereits ueber `AdditionalConfiguration`, nutzt diese im NTFS-Code aber im Wesentlichen nur fuer boolesche Feature-Flags.
|
||||||
|
|
||||||
|
Die Ermittlung der DataAreas erfolgt aktuell rekursiv ueber:
|
||||||
|
|
||||||
|
- Root-Aufbau in `getDataAreasAsync()`
|
||||||
|
- Kind-Ermittlung in `GetChildDataAreasAsync()`
|
||||||
|
- Dateisystem-Enumeration je Ebene ueber `ntfsBase.RequestFoldersListAsync(parentPath, 1)`
|
||||||
|
|
||||||
|
Der bestehende Filter `ShouldIncludeDataArea()` wirkt nur auf den `DisplayName` und basiert auf `DataAreaRegEx`. Das ist fuer gezielte Ordnerausschluesse fachlich und technisch zu grob.
|
||||||
|
|
||||||
|
## Zielbild
|
||||||
|
|
||||||
|
Die neue Logik soll eine explizite Pfad-Policy fuer den NTFS-Provider einfuehren.
|
||||||
|
|
||||||
|
Diese Policy entscheidet fuer jeden gefundenen Pfad:
|
||||||
|
|
||||||
|
- darf als DataArea materialisiert werden
|
||||||
|
- darf weiter traversiert werden
|
||||||
|
|
||||||
|
Ein ausgeschlossener Pfad soll weder als DataArea geliefert noch weiter traversiert werden.
|
||||||
|
|
||||||
|
## Vorgeschlagene Konfigurationsschluessel
|
||||||
|
|
||||||
|
### Aktueller Stand
|
||||||
|
|
||||||
|
- `NtfsExcludePaths`
|
||||||
|
- `NtfsIncludePaths`
|
||||||
|
|
||||||
|
Beispiel:
|
||||||
|
|
||||||
|
```text
|
||||||
|
NtfsExcludePaths=Archiv;*\Temp;Abteilung\Alt;\\server\share\legacy\*
|
||||||
|
NtfsIncludePaths=Fachbereiche\*;Shares\Produktion\*;\\server\dfs\namespace\link\*
|
||||||
|
```
|
||||||
|
|
||||||
|
## Aktueller Implementierungsstand
|
||||||
|
|
||||||
|
Die nachfolgend beschriebene Path-Policy ist im NTFS-Provider inzwischen implementiert.
|
||||||
|
|
||||||
|
### 1. Materialisierung und Traversierung sind getrennt
|
||||||
|
|
||||||
|
In `GetChildDataAreasAsync()` wird pro gefundenem Pfad heute getrennt entschieden:
|
||||||
|
|
||||||
|
- darf der Pfad als DataArea geliefert werden
|
||||||
|
- darf unterhalb des Pfads weiter traversiert werden
|
||||||
|
|
||||||
|
Das ist wichtig fuer Include-Regeln. Ein Zwischenpfad darf fuer die Traversierung erlaubt sein, auch wenn erst ein tiefer liegendes Zielobjekt tatsaechlich auf der Whitelist steht.
|
||||||
|
|
||||||
|
### 2. Matching ist klassifizierungsunabhaengig
|
||||||
|
|
||||||
|
Die Path-Policy arbeitet fuer:
|
||||||
|
|
||||||
|
- `ServerRoot`
|
||||||
|
- `ClassicShare`
|
||||||
|
- `DfsNamespaceRoot`
|
||||||
|
- `DfsLink`
|
||||||
|
- `Folder`
|
||||||
|
|
||||||
|
mit derselben Matching-Logik.
|
||||||
|
|
||||||
|
Die Klassifikation bleibt fuer die fachliche Verarbeitung der DataArea relevant, nicht mehr fuer Blacklist-/Whitelist-Matching.
|
||||||
|
|
||||||
|
### 3. Relative und absolute Pfade werden parallel ausgewertet
|
||||||
|
|
||||||
|
Jeder klassifizierte Pfad wird gegen mehrere Kandidaten gematcht:
|
||||||
|
|
||||||
|
- relativer Pfad unterhalb von `RootPath`
|
||||||
|
- normalisierter absoluter UNC-Pfad
|
||||||
|
|
||||||
|
Dadurch koennen Regeln sowohl knapp relativ als auch explizit absolut formuliert werden.
|
||||||
|
|
||||||
|
### 4. Include-Regeln duerfen Traversierungs-Vorpfade freischalten
|
||||||
|
|
||||||
|
Wenn `NtfsIncludePaths` gesetzt ist, darf ein Pfad auch dann traversiert werden, wenn er selbst noch nicht final matcht, aber zu einem spaeter passenden Zielpfad fuehren kann.
|
||||||
|
|
||||||
|
Beispiel:
|
||||||
|
|
||||||
|
```text
|
||||||
|
NtfsIncludePaths=Abteilung\IT\*
|
||||||
|
```
|
||||||
|
|
||||||
|
Dann darf `Abteilung` fuer die Traversierung erhalten bleiben, damit `Abteilung\IT\TeamA` ueberhaupt erreicht werden kann.
|
||||||
|
|
||||||
|
### 5. `LoadDataArea()` respektiert die Policy
|
||||||
|
|
||||||
|
Direktes Laden eines Pfads ueber `LoadDataArea()` wird ebenfalls durch die Path-Policy eingeschraenkt.
|
||||||
|
|
||||||
|
Ausnahme:
|
||||||
|
|
||||||
|
- der konfigurierte `RootPath` selbst bleibt ladbar
|
||||||
|
|
||||||
|
Damit kann die Filterung nicht einfach durch direktes Laden einer UID umgangen werden.
|
||||||
|
|
||||||
|
### 6. Permission-Management bleibt fachlich auf Folder beschraenkt
|
||||||
|
|
||||||
|
`IsPermissionManagedFolderPath()` verwendet dieselbe generische Path-Policy, bleibt aber weiterhin nur fuer als `Folder` klassifizierte Pfade zulaessig.
|
||||||
|
|
||||||
|
Die Path-Policy ist also klassifizierungsunabhaengig, das Berechtigungs-Handling selbst aber bewusst nicht.
|
||||||
|
|
||||||
|
## Matching-Regeln
|
||||||
|
|
||||||
|
Empfohlene Semantik:
|
||||||
|
|
||||||
|
- Trennzeichen fuer Mehrfachwerte: `;`
|
||||||
|
- Auswertung case-insensitive
|
||||||
|
- Leerzeichen an Eintraegen vor dem Match trimmen
|
||||||
|
- Matching gegen relative Pfade unter `RootPath` und gegen normalisierte absolute UNC-Pfade
|
||||||
|
- Interne Normalisierung auf konsistente UNC-/Directory-Notation
|
||||||
|
- Zunaechst nur einfache Wildcards `*` unterstuetzen, keine freien Regex-Ausdruecke
|
||||||
|
- Include-Regeln duerfen fuer die Traversierung auch uebergeordnete Pfade freischalten, wenn diese zu einem spaeter passenden Zielpfad fuehren
|
||||||
|
|
||||||
|
Empfohlene Prioritaet:
|
||||||
|
|
||||||
|
1. Wenn keine Include-Regel gesetzt ist, sind alle Pfade grundsaetzlich erlaubt.
|
||||||
|
2. Wenn Include-Regeln gesetzt sind, sind nur passende Pfade erlaubt.
|
||||||
|
3. Exclude-Regeln werden danach angewendet und gewinnen bei Kollision.
|
||||||
|
|
||||||
|
Das entspricht dem aktuell implementierten Verhalten.
|
||||||
|
|
||||||
|
## Beispiele
|
||||||
|
|
||||||
|
### 1. Einzelnen Teilbaum ausschliessen
|
||||||
|
|
||||||
|
Konfiguration:
|
||||||
|
|
||||||
|
```text
|
||||||
|
NtfsExcludePaths=Abteilung\Alt\*
|
||||||
|
```
|
||||||
|
|
||||||
|
Wirkung:
|
||||||
|
|
||||||
|
- `Abteilung\Alt` und alles darunter wird nicht mehr als DataArea geliefert
|
||||||
|
- unterhalb von `Abteilung\Alt` wird auch nicht weiter traversiert
|
||||||
|
- andere Teilbaeume bleiben unveraendert sichtbar
|
||||||
|
|
||||||
|
### 2. Bestimmte Ordnernamen ueberall ausschliessen
|
||||||
|
|
||||||
|
Konfiguration:
|
||||||
|
|
||||||
|
```text
|
||||||
|
NtfsExcludePaths=*\Temp;*\Archiv
|
||||||
|
```
|
||||||
|
|
||||||
|
Wirkung:
|
||||||
|
|
||||||
|
- jeder Pfad, dessen letzter oder einer spaeteren Segmente `Temp` oder `Archiv` entspricht, wird ausgeschlossen
|
||||||
|
- das ist praktisch fuer technische oder historische Unterordner, die in vielen Shares gleich benannt sind
|
||||||
|
|
||||||
|
### 3. Nur einen Fachbereich sichtbar lassen
|
||||||
|
|
||||||
|
Konfiguration:
|
||||||
|
|
||||||
|
```text
|
||||||
|
NtfsIncludePaths=Fachbereiche\HR\*
|
||||||
|
```
|
||||||
|
|
||||||
|
Wirkung:
|
||||||
|
|
||||||
|
- nur Pfade unterhalb von `Fachbereiche\HR` werden als DataAreas geliefert
|
||||||
|
- notwendige Zwischenpfade wie `Fachbereiche` duerfen fuer die Traversierung erhalten bleiben
|
||||||
|
- alle anderen Teilbaeume unterhalb von `RootPath` fallen aus der Ergebnismenge
|
||||||
|
|
||||||
|
### 4. Whitelist und Blacklist kombinieren
|
||||||
|
|
||||||
|
Konfiguration:
|
||||||
|
|
||||||
|
```text
|
||||||
|
NtfsIncludePaths=Fachbereiche\IT\*
|
||||||
|
NtfsExcludePaths=Fachbereiche\IT\Test;Fachbereiche\IT\Alt\*
|
||||||
|
```
|
||||||
|
|
||||||
|
Wirkung:
|
||||||
|
|
||||||
|
- grundsaetzlich ist nur `Fachbereiche\IT` relevant
|
||||||
|
- innerhalb dieses Bereichs werden `Test` und der komplette Teilbaum `Alt` wieder ausgeschlossen
|
||||||
|
- Exclude gewinnt also auch innerhalb eines eingeschraenkten Include-Bereichs
|
||||||
|
|
||||||
|
### 5. Absoluten UNC-Pfad fuer DFS-Link verwenden
|
||||||
|
|
||||||
|
Konfiguration:
|
||||||
|
|
||||||
|
```text
|
||||||
|
NtfsIncludePaths=\\server\dfs\namespace\link\Produktion\*
|
||||||
|
```
|
||||||
|
|
||||||
|
Wirkung:
|
||||||
|
|
||||||
|
- die Regel greift auch dann, wenn der Root ueber DFS klassifiziert wird
|
||||||
|
- benoetigte Vorpfade wie `\\server\dfs`, `\\server\dfs\namespace` und `\\server\dfs\namespace\link` duerfen fuer die Traversierung erhalten bleiben
|
||||||
|
- dadurch kann ein bestimmter DFS-Zweig sehr gezielt freigegeben werden
|
||||||
|
|
||||||
|
### 6. Nur bestimmte Shares unter einem Server-Root zulassen
|
||||||
|
|
||||||
|
Konfiguration:
|
||||||
|
|
||||||
|
```text
|
||||||
|
NtfsIncludePaths=ShareA\*;ShareB\*
|
||||||
|
```
|
||||||
|
|
||||||
|
Voraussetzung:
|
||||||
|
|
||||||
|
- `RootPath` zeigt auf einen Server-Root wie `\\fileserver`
|
||||||
|
|
||||||
|
Wirkung:
|
||||||
|
|
||||||
|
- nur Kinder unterhalb von `ShareA` und `ShareB` werden sichtbar
|
||||||
|
- andere Shares des Servers werden nicht materialisiert und nicht weiter traversiert
|
||||||
|
|
||||||
|
### 7. Direktes Laden eines ausgeschlossenen Pfads
|
||||||
|
|
||||||
|
Konfiguration:
|
||||||
|
|
||||||
|
```text
|
||||||
|
NtfsExcludePaths=Abteilung\Alt\*
|
||||||
|
```
|
||||||
|
|
||||||
|
Wirkung:
|
||||||
|
|
||||||
|
- ein direkter `LoadDataArea()` auf einen Pfad unterhalb von `Abteilung\Alt` liefert kein Objekt mehr
|
||||||
|
- der konfigurierte `RootPath` selbst bleibt davon ausgenommen und kann weiterhin geladen werden
|
||||||
|
|
||||||
|
## Technische Einhaengepunkte
|
||||||
|
|
||||||
|
### 1. Provider-seitige Policy-Methoden
|
||||||
|
|
||||||
|
Im NTFS-Provider existiert dafuer inzwischen eine kleine Policy-Schicht, insbesondere:
|
||||||
|
|
||||||
|
- `GetAdditionalConfigurationValues(string key)`
|
||||||
|
- `ShouldIncludeDataArea(...)`
|
||||||
|
- `ShouldTraverseDataArea(...)`
|
||||||
|
- `MatchesPathPolicy(...)`
|
||||||
|
- `TryMatchPathPolicy(...)`
|
||||||
|
- `CanPathLeadToPattern(...)`
|
||||||
|
|
||||||
|
Die Methode `IsAdditionalConfigurationEnabled()` bleibt fuer boolesche Flags bestehen und wird durch Listen-/String-Helfer ergaenzt.
|
||||||
|
|
||||||
|
### 2. Anwendung in der DataArea-Traversierung
|
||||||
|
|
||||||
|
Der erste und wichtigste Einhaengepunkt ist `GetChildDataAreasAsync()`.
|
||||||
|
|
||||||
|
Dort wird heute jede gefundene Ebene verarbeitet und vor `BuildDataAreaAsync()` sowie vor dem rekursiven Abstieg durch die Path-Policy geprueft.
|
||||||
|
|
||||||
|
Vorteil:
|
||||||
|
|
||||||
|
- geringe Eingriffstiefe
|
||||||
|
- kein Umbau der allgemeinen NTFS-Basis erforderlich
|
||||||
|
- fachliche Wirkung genau dort, wo DataAreas erzeugt werden
|
||||||
|
|
||||||
|
### 3. Wiederverwendung fuer Permission-Management
|
||||||
|
|
||||||
|
`IsPermissionManagedFolderPath()` bleibt fachlich auf Folder beschraenkt, verwendet fuer Black-/Whitelist aber dieselbe generische Path-Policy.
|
||||||
|
|
||||||
|
Damit wird vermieden, dass ein Ordner zwar nicht mehr als DataArea sichtbar ist, aber weiterhin im Permission-Flow auftaucht.
|
||||||
|
|
||||||
|
### 4. Wiederverwendung fuer `LoadDataArea()`
|
||||||
|
|
||||||
|
Auch `LoadDataArea()` verwendet die Path-Policy inzwischen, damit gefilterte Pfade nicht per Direktzugriff wieder sichtbar werden.
|
||||||
|
|
||||||
|
## Offene Abgrenzung
|
||||||
|
|
||||||
|
Bewusst weiterhin nicht umgesetzt:
|
||||||
|
|
||||||
|
- Umbau von `cNtfsBase` auf generische Filter-Callbacks
|
||||||
|
- freie Regex-Konfiguration in `AdditionalConfiguration`
|
||||||
|
- unterschiedliche Regeln je Klassifikationstyp
|
||||||
|
|
||||||
|
Diese Themen koennen spaeter folgen, sind fuer den aktuellen Nutzen aber nicht noetig.
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
Fuer ausgeschlossene Pfade sollte auf `Debug` geloggt werden:
|
||||||
|
|
||||||
|
- welcher Pfad verworfen wurde
|
||||||
|
- welche Regel gegriffen hat
|
||||||
|
- ob der Pfad nur nicht materialisiert oder auch nicht traversiert wurde
|
||||||
|
|
||||||
|
Beispiel:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Skip NTFS path '\\server\share\IT\_disabled' due to AdditionalConfiguration rule 'NtfsExcludePaths=IT\_disabled'
|
||||||
|
```
|
||||||
|
|
||||||
|
Das ist wichtig, damit fehlende DataAreas spaeter im Betrieb nachvollziehbar bleiben.
|
||||||
|
|
||||||
|
## Empfohlener Umsetzungsplan
|
||||||
|
|
||||||
|
1. Listenparser fuer `AdditionalConfiguration` im NTFS-Provider einfuehren.
|
||||||
|
2. Pfadnormalisierung und Matching fuer relative sowie absolute Pfade kapseln.
|
||||||
|
3. `GetChildDataAreasAsync()` um `ShouldTraverseDataArea(...)` erweitern.
|
||||||
|
4. Debug-Logging fuer Skip-Faelle einfuehren.
|
||||||
|
5. Dieselbe Policy in `IsPermissionManagedFolderPath()` und `LoadDataArea()` wiederverwenden.
|
||||||
|
|
||||||
|
Diese Punkte sind im aktuellen Implementierungsstand umgesetzt.
|
||||||
|
|
||||||
|
## Kurzfazit
|
||||||
|
|
||||||
|
Die generische Path-Policy im NTFS-Provider ist klein genug fuer eine risikoarme Implementierung, passt in die vorhandene `AdditionalConfiguration`-Architektur und arbeitet ohne Sonderregeln pro Klassifikationstyp.
|
||||||
Reference in New Issue
Block a user