diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..deb6651 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,197 @@ +# SMAD-X — Roadmap: Domain Evolution & AD Delegations + +> Last updated: 2025-06-01 + +--- + +## Part 1 — Domain Timeline / Evolution + +### Step 1 — Model `DomainSnapshot` +**File:** `SMAD-X\Models\DomainSnapshot.cs` +- `FilePath` (string) — path to the `.smad-x.json` source file +- `DomainName` (string) — root domain name +- `SnapshotDate` (DateTime) — extracted from the `ModifiedDate` field of the root object +- `Root` (ADObject) — fully deserialized tree + +--- + +### Step 2 — Diff Model `ADChangeItem` +**File:** `SMAD-X\Models\ADChangeItem.cs` +- `ChangeType` : enum `Added | Removed | Modified` +- `ObjectName` (string) +- `DistinguishedName` (string) — stable key for comparison +- `ObjectType` (ADObjectType) +- `ChangedFields` : `List<(string Field, string OldValue, string NewValue)>` + - Tracked fields: `Tier`, `Description`, `MemberOf`, `LinkedGPOs` + +--- + +### Step 3 — Service `ADDiffService` +**File:** `SMAD-X\Services\ADDiffService.cs` + +Main method: +```csharp +public List Compare(ADObject before, ADObject after) +``` +- Recursive diff indexed by `DistinguishedName` +- Detects: **additions**, **removals**, **field modifications** + +--- + +### Step 4 — `DomainTimelineViewModel` +**File:** `SMAD-X\ViewModels\DomainTimelineViewModel.cs` +- `ObservableCollection Snapshots` +- `DomainSnapshot? SnapshotBefore` / `SnapshotAfter` (the two snapshots to compare) +- `ObservableCollection Changes` (diff result) +- `FilterType` : All / Added / Removed / Modified +- `FilterText` (string) + +Commands: +- `AddSnapshotCommand` — `OpenFilePicker` multi-select `*.smad-x.json` +- `RemoveSnapshotCommand` +- `CompareCommand` — calls `ADDiffService.Compare` + +--- + +### Step 5 — View `DomainTimelineWindow` +**Files:** `SMAD-X\Views\DomainTimelineWindow.axaml` / `.axaml.cs` + +Layout: +- **Left panel**: list of loaded snapshots (date + domain name), Add / Remove buttons +- **Selectors**: "Before" / "After" ComboBoxes fed by `Snapshots` +- **Right panel**: `DataGrid` with columns: + - Change type (colored icon: green = Added, red = Removed, orange = Modified) + - Object, DN, AD Type, Modified fields + +--- + +### Step 6 — Main menu integration +**Files:** `MainWindow.axaml` + `MainWindowViewModel.cs` +- Menu: `Analyse > Domain Evolution` — shortcut `Ctrl+T` +- Command: `ShowTimelineCommand` + +--- + +## Part 2 — Active Directory Delegations + +Delegations in Active Directory express **who** (trustee: user or group) can perform **what action** (right) +on **which scope** (OU or Container). This includes: + +- **Password reset**: a group (e.g. `Helpdesk_L1`) can reset passwords for all user accounts in a given OU. +- **Computer account creation**: a group can create/delete computer objects in a specific OU. +- **Account unlock**: a group can unlock user accounts. +- **Attribute write**: a group can modify specific attributes (e.g. `telephoneNumber`, `member`). +- **Generic control**: full control over objects in an OU (e.g. `GenericAll`, `GenericWrite`). + +These delegations are stored in the ACL (`Get-Acl`) of each OU/Container and must be captured +during the PowerShell AD analysis, then visualized in a dedicated view. + +--- + +### Step 7 — Model `ADDelegation` +**File:** `SMAD-X\Models\ADDelegation.cs` +- `TrusteeName` (string) — SAMAccountName of the trustee (user or group) +- `TrusteeType` : enum `User | Group | Computer` +- `TargetDN` (string) — DN of the OU/Container where the delegation is defined +- `Right` (string) — e.g. `ResetPassword`, `CreateChild:computer`, `GenericAll`, `WriteProperty:member` +- `RightCategory` : enum `PasswordReset | ComputerManagement | AccountUnlock | AttributeWrite | FullControl | Other` +- `IsInherited` (bool) — whether the ACE is inherited from a parent OU +- `Tier` (string?) — inherited from the target object context + +--- + +### Step 8 — Add `Delegations` to `ADObject` +**File:** `SMAD-X\Models\ADObject.cs` + +Add the property: +```csharp +public ObservableCollection Delegations { get; set; } = new(); +``` + +--- + +### Step 9 — PowerShell export of delegations +**File:** `SMAD-X\Services\ADImportPowerShellService.cs` + +In the `Build-NodeJson` function of the generated script: +- For OUs and Containers, call `Get-Acl` on the AD object +- Extract `ActiveDirectoryAccessRule` entries (non-inherited by default, with an option for inherited) +- Filter out default/system ACEs (e.g. `NT AUTHORITY\SYSTEM`, `BUILTIN\Administrators`) +- Categorize each ACE into a `RightCategory`: + - `ResetPassword` extended right → `PasswordReset` + - `CreateChild` for computer objects → `ComputerManagement` + - `WriteProperty:lockoutTime` → `AccountUnlock` + - `WriteProperty:` → `AttributeWrite` + - `GenericAll` / `GenericWrite` → `FullControl` +- Serialize under the `"Delegations"` key in the JSON output + +Expected JSON format per delegation: +```json +{ + "TrusteeName": "Helpdesk_L1", + "TrusteeType": "Group", + "TargetDN": "OU=Users,DC=contoso,DC=com", + "Right": "ResetPassword", + "RightCategory": "PasswordReset", + "IsInherited": false, + "Tier": "Tier 1" +} +``` + +--- + +### Step 10 — `DelegationsViewModel` +**File:** `SMAD-X\ViewModels\DelegationsViewModel.cs` + +- Recursively walks the entire `ADObject` tree and flattens all `Delegations` from every node +- `ObservableCollection AllDelegations` +- `ObservableCollection FilteredDelegations` + +Filters: +- `FilterTrustee` (string) — filter by trustee SAMAccountName +- `FilterRight` (string) — filter by right/category +- `FilterTier` (string) — filter by Tier +- `FilterInherited` (bool?) — include / exclude inherited ACEs +- `FilterTargetOU` (string) — filter by target OU DN + +Commands: +- `ApplyFiltersCommand` +- `ExportToCsvCommand` — exports `FilteredDelegations` to CSV + +--- + +### Step 11 — View `DelegationsWindow` +**Files:** `SMAD-X\Views\DelegationsWindow.axaml` / `.axaml.cs` + +Layout: +- **Filter bar** at the top: Trustee, Right/Category, Tier, Target OU, "Include inherited" checkbox +- **DataGrid** with columns: Trustee, Type, Right, Category, Target OU, Inherited yes/no, Tier +- **Export CSV** button + +--- + +### Step 12 — Main menu integration +**Files:** `MainWindow.axaml` + `MainWindowViewModel.cs` +- Menu: `Analyse > Delegations` — shortcut `Ctrl+D` +- Command: `ShowDelegationsCommand` + +--- + +## Implementation order + +| # | Step | Dependencies | +|---|------|--------------| +| 1 | Model DomainSnapshot | — | +| 2 | Model ADChangeItem | — | +| 3 | Service ADDiffService | Steps 1, 2 | +| 4 | DomainTimelineViewModel | Step 3 | +| 5 | View DomainTimelineWindow | Step 4 | +| 6 | Menu integration (Timeline) | Step 5 | +| 7 | Model ADDelegation | — | +| 8 | Delegations property in ADObject | Step 7 | +| 9 | PowerShell export of delegations | Step 8 | +| 10 | DelegationsViewModel | Steps 7, 8 | +| 11 | View DelegationsWindow | Step 10 | +| 12 | Menu integration (Delegations) | Step 11 | + +> **Note:** Part 1 (Timeline) and Part 2 (Delegations) are independent and can be developed in parallel. diff --git a/SMAD-X/App.axaml b/SMAD-X/App.axaml index 23283f0..d45aee7 100644 --- a/SMAD-X/App.axaml +++ b/SMAD-X/App.axaml @@ -18,6 +18,7 @@ + diff --git a/SMAD-X/Graph/GraphBuilder.cs b/SMAD-X/Graph/GraphBuilder.cs index 2b5b8c0..27e785f 100644 --- a/SMAD-X/Graph/GraphBuilder.cs +++ b/SMAD-X/Graph/GraphBuilder.cs @@ -169,6 +169,28 @@ o.Type is ADObjectType.User or ADObjectType.Computer or ADObjectType.GMSA } } + // ── Delegations ACL : trustee → OU/Container cible ── + if (filter.ShowDelegations) + { + foreach (var obj in allObjects.Where(o => o.Delegations.Count > 0)) + { + if (!_nodeMap.TryGetValue(obj.Name, out var targetNode)) continue; + + foreach (var del in obj.Delegations) + { + if (!_nodeMap.TryGetValue(del.TrusteeName, out var trusteeNode)) continue; + + bool alreadyEdge = Edges.Any(e => + e.Type == EdgeType.Delegation && + e.Source == trusteeNode && + e.Target == targetNode && + e.GpoName == del.Right); + if (!alreadyEdge) + Edges.Add(new GraphEdge(trusteeNode, targetNode, EdgeType.Delegation, del.Right)); + } + } + } + // Supprimer les nœuds isolés si le filtre le demande if (!filter.ShowIsolated) { diff --git a/SMAD-X/Graph/GraphCanvas.cs b/SMAD-X/Graph/GraphCanvas.cs index 6105ab8..b0f4c5b 100644 --- a/SMAD-X/Graph/GraphCanvas.cs +++ b/SMAD-X/Graph/GraphCanvas.cs @@ -48,6 +48,7 @@ public class GraphCanvas : Control { EdgeType.GpoInheritance, Color.FromRgb(0xD0, 0x80, 0x00) }, { EdgeType.PsoSubject, Color.FromRgb(0xC5, 0x07, 0x1F) }, { EdgeType.ParentChild, Color.FromRgb(0x60, 0x60, 0x60) }, + { EdgeType.Delegation, Color.FromRgb(0xFF, 0xB3, 0x00) }, // amber }; private static readonly Dictionary NodeIcons = new() @@ -403,6 +404,7 @@ private void DrawLegend(DrawingContext ctx) (EdgeColors[EdgeType.MemberOf], false, "MemberOf"), (EdgeColors[EdgeType.GroupNesting], true, "Group in Group"), (EdgeColors[EdgeType.PsoSubject], false, "PSO Subject"), + (EdgeColors[EdgeType.Delegation], false, "Délégation"), }; const double lx = 10; diff --git a/SMAD-X/Graph/GraphEdge.cs b/SMAD-X/Graph/GraphEdge.cs index 18304cd..2795cd6 100644 --- a/SMAD-X/Graph/GraphEdge.cs +++ b/SMAD-X/Graph/GraphEdge.cs @@ -7,7 +7,8 @@ public enum EdgeType GpoLink, GpoInheritance, // GPO héritée d'un parent (Domain → OU ou OU parente → OU enfant) PsoSubject, - ParentChild + ParentChild, + Delegation // Délégation ACL : trustee → OU/Container cible } /// @@ -30,6 +31,7 @@ public class GraphEdge EdgeType.GpoInheritance => $"⬇ Héritage GPO", EdgeType.PsoSubject => "PSO Subject", EdgeType.ParentChild => "Contains", + EdgeType.Delegation => GpoName ?? "Delegation", _ => string.Empty }; diff --git a/SMAD-X/Graph/GraphFilter.cs b/SMAD-X/Graph/GraphFilter.cs index 861c487..6ceeafe 100644 --- a/SMAD-X/Graph/GraphFilter.cs +++ b/SMAD-X/Graph/GraphFilter.cs @@ -11,6 +11,7 @@ public class GraphFilter public bool ShowGpoLinks { get; set; } = true; public bool ShowGpoInheritance { get; set; } = true; public bool ShowPsoLinks { get; set; } = true; + public bool ShowDelegations { get; set; } = true; public bool ShowIsolated { get; set; } = false; } } diff --git a/SMAD-X/Helpers/LocalizationProxy.cs b/SMAD-X/Helpers/LocalizationProxy.cs index d1971f4..b72c1aa 100644 --- a/SMAD-X/Helpers/LocalizationProxy.cs +++ b/SMAD-X/Helpers/LocalizationProxy.cs @@ -200,6 +200,7 @@ public LocalizationProxy() public string GraphFilterGpoLinks => _localizationService["Graph.FilterGpoLinks"]; public string GraphFilterGpoInheritance => _localizationService["Graph.FilterGpoInheritance"]; public string GraphFilterPso => _localizationService["Graph.FilterPso"]; + public string GraphFilterDelegations => _localizationService["Graph.FilterDelegations"]; public string GraphFilterHierarchy => _localizationService["Graph.FilterHierarchy"]; public string GraphFilterIsolated => _localizationService["Graph.FilterIsolated"]; public string GraphFitViewTooltip => _localizationService["Graph.FitViewTooltip"]; diff --git a/SMAD-X/Models/ADChangeItem.cs b/SMAD-X/Models/ADChangeItem.cs new file mode 100644 index 0000000..a879ee1 --- /dev/null +++ b/SMAD-X/Models/ADChangeItem.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; + +namespace SMADX.Models +{ + public enum ChangeType + { + Added, + Removed, + Modified + } + + /// + /// Broad category for the kind of change — used as a filter axis in the Timeline UI. + /// + public enum ChangeCategory + { + Structure, // object added / removed / renamed / moved + MemberOf, // group membership changes + GPO, // GPO link added / removed + PSO, // PSO assignment or settings changed + Delegation // delegation (ACE) added / removed / changed + } + + /// + /// Represents a single change detected between two AD snapshots. + /// + public class ADChangeItem + { + public ChangeType ChangeType { get; set; } + public ChangeCategory ChangeCategory { get; set; } = ChangeCategory.Structure; + public string ObjectName { get; set; } = string.Empty; + public string DistinguishedName { get; set; } = string.Empty; + public ADObjectType ObjectType { get; set; } + + /// + /// For Modified items: list of (Field, OldValue, NewValue) tuples. + /// + public List<(string Field, string OldValue, string NewValue)> ChangedFields { get; set; } = new(); + + public string ChangedFieldsSummary => + ChangedFields.Count == 0 + ? string.Empty + : string.Join("; ", ChangedFields.ConvertAll(f => + string.IsNullOrEmpty(f.OldValue) ? $"+{f.Field}: {f.NewValue}" + : string.IsNullOrEmpty(f.NewValue) ? $"-{f.Field}: {f.OldValue}" + : $"{f.Field}: {f.OldValue} → {f.NewValue}")); + } +} diff --git a/SMAD-X/Models/ADDelegation.cs b/SMAD-X/Models/ADDelegation.cs new file mode 100644 index 0000000..8d35672 --- /dev/null +++ b/SMAD-X/Models/ADDelegation.cs @@ -0,0 +1,44 @@ +namespace SMADX.Models +{ + public enum TrusteeType + { + User, + Group, + Computer + } + + public enum RightCategory + { + PasswordReset, + ComputerManagement, + AccountUnlock, + AttributeWrite, + FullControl, + Other + } + + /// + /// Represents an AD delegation (ACE) found on an OU or Container. + /// + public class ADDelegation + { + /// SAMAccountName of the trustee (user or group). + public string TrusteeName { get; set; } = string.Empty; + + public TrusteeType TrusteeType { get; set; } + + /// Distinguished name of the OU/Container on which the ACE is set. + public string TargetDN { get; set; } = string.Empty; + + /// Human-readable right, e.g. ResetPassword, CreateChild:computer. + public string Right { get; set; } = string.Empty; + + public RightCategory RightCategory { get; set; } + + /// True when the ACE is inherited from a parent OU. + public bool IsInherited { get; set; } + + /// Tier context of the target object, if known. + public string? Tier { get; set; } + } +} diff --git a/SMAD-X/Models/ADObject.cs b/SMAD-X/Models/ADObject.cs index 58a20e4..adcd85f 100644 --- a/SMAD-X/Models/ADObject.cs +++ b/SMAD-X/Models/ADObject.cs @@ -62,6 +62,11 @@ public string TierColor /// public ObservableCollection PSOAppliesTo { get; set; } = new(); + /// + /// ACL-based delegations found on this OU/Container. + /// + public ObservableCollection Delegations { get; set; } = new(); + // --- Propriétés spécifiques PSO (Fine-Grained Password Policy) --- public int? PSOPrecedence { get; set; } diff --git a/SMAD-X/Models/ADTreeNode.cs b/SMAD-X/Models/ADTreeNode.cs index b5d1e96..fe42674 100644 --- a/SMAD-X/Models/ADTreeNode.cs +++ b/SMAD-X/Models/ADTreeNode.cs @@ -1,5 +1,6 @@ using System.Collections.ObjectModel; using System.Collections.Specialized; +using System.Linq; using CommunityToolkit.Mvvm.ComponentModel; namespace SMADX.Models @@ -46,6 +47,9 @@ public ADTreeNode(ADObject data) // S'abonner aux changements de la collection LinkedGPOs _data.LinkedGPOs.CollectionChanged += OnLinkedGPOsChanged; + // S'abonner aux changements de la collection Delegations + _data.Delegations.CollectionChanged += OnDelegationsChanged; + foreach (var child in data.Children) { var childNode = new ADTreeNode(child) @@ -62,6 +66,12 @@ private void OnLinkedGPOsChanged(object? sender, NotifyCollectionChangedEventArg OnPropertyChanged(nameof(LinkedGPOsSummary)); } + private void OnDelegationsChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + OnPropertyChanged(nameof(HasDelegations)); + OnPropertyChanged(nameof(DelegationsSummary)); + } + /// /// Vrai si cet objet (OU ou Domain) a des GPOs liées /// @@ -75,6 +85,25 @@ private void OnLinkedGPOsChanged(object? sender, NotifyCollectionChangedEventArg ? string.Empty : "🔗 " + string.Join(", ", Data.LinkedGPOs); + /// + /// Vrai si cet objet a des délégations définies + /// + public bool HasDelegations => Data.Delegations.Count > 0; + + /// + /// Résumé des délégations, affiché en tooltip dans le TreeView + /// + public string DelegationsSummary + { + get + { + if (Data.Delegations.Count == 0) return string.Empty; + var lines = Data.Delegations.Select(d => + $"👤 {d.TrusteeName} → {d.Right} ({d.RightCategory})"); + return string.Join("\n", lines); + } + } + /// /// Icône basée sur le type d'objet /// diff --git a/SMAD-X/Models/DomainSnapshot.cs b/SMAD-X/Models/DomainSnapshot.cs new file mode 100644 index 0000000..01bb174 --- /dev/null +++ b/SMAD-X/Models/DomainSnapshot.cs @@ -0,0 +1,24 @@ +using System; + +namespace SMADX.Models +{ + /// + /// Represents a point-in-time snapshot of an AD domain loaded from a .smad-x.json file. + /// + public class DomainSnapshot + { + /// Full path to the source .smad-x.json file. + public string FilePath { get; set; } = string.Empty; + + /// Root domain name (e.g. contoso.com). + public string DomainName { get; set; } = string.Empty; + + /// Snapshot date — taken from the root ADObject.ModifiedDate. + public DateTime SnapshotDate { get; set; } + + /// Fully deserialized AD object tree. + public ADObject Root { get; set; } = null!; + + public override string ToString() => $"{DomainName} — {SnapshotDate:yyyy-MM-dd HH:mm}"; + } +} diff --git a/SMAD-X/SMAD-X.csproj b/SMAD-X/SMAD-X.csproj index 265d505..a05f660 100644 --- a/SMAD-X/SMAD-X.csproj +++ b/SMAD-X/SMAD-X.csproj @@ -27,6 +27,7 @@ + diff --git a/SMAD-X/Services/ADDataService.cs b/SMAD-X/Services/ADDataService.cs index 5bede04..e486127 100644 --- a/SMAD-X/Services/ADDataService.cs +++ b/SMAD-X/Services/ADDataService.cs @@ -430,6 +430,85 @@ public ADObject CreateFreshDomainStructure(string domainName, bool enableTiering domain.LinkedGPOs.Add("Default Domain Policy"); domainControllersOU.LinkedGPOs.Add("Default Domain Controllers Policy"); + // ── Groupes de délégation ──────────────────────────────────────── + var ggHelpDeskFresh = new ADObject("GG-HelpDesk", ADObjectType.Group) + { + Description = loc["Desc.Delegation.GGHelpDesk"], + Tier = GetTier("Tier 2"), + Parent = usersContainer + }; + usersContainer.Children.Add(ggHelpDeskFresh); + + var ggITWorkstationsFresh = new ADObject("GG-IT-Workstations", ADObjectType.Group) + { + Description = loc["Desc.Delegation.GGITWorkstations"], + Tier = GetTier("Tier 2"), + Parent = usersContainer + }; + usersContainer.Children.Add(ggITWorkstationsFresh); + + var ggITServersFresh = new ADObject("GG-IT-Servers", ADObjectType.Group) + { + Description = loc["Desc.Delegation.GGITServers"], + Tier = GetTier("Tier 1"), + Parent = usersContainer + }; + usersContainer.Children.Add(ggITServersFresh); + + var ggTier1OperatorsFresh = new ADObject("GG-Tier1-Operators", ADObjectType.Group) + { + Description = loc["Desc.Delegation.GGTier1Operators"], + Tier = GetTier("Tier 0"), + Parent = usersContainer + }; + usersContainer.Children.Add(ggTier1OperatorsFresh); + + // Mettre à jour tous les DN avant d'assigner les TargetDN des délégations + UpdateDistinguishedNamesRecursive(domain); + + // ── Délégations ────────────────────────────────────────────────── + // Delegation de réinitialisation de mot de passe sur CN=Users + usersContainer.Delegations.Add(new ADDelegation + { + TrusteeName = "GG-HelpDesk", + TrusteeType = TrusteeType.Group, + TargetDN = usersContainer.DistinguishedName, + Right = "ResetPassword", + RightCategory = RightCategory.PasswordReset, + Tier = "Tier 2" + }); + usersContainer.Delegations.Add(new ADDelegation + { + TrusteeName = "GG-HelpDesk", + TrusteeType = TrusteeType.Group, + TargetDN = usersContainer.DistinguishedName, + Right = "UnlockAccount", + RightCategory = RightCategory.AccountUnlock, + Tier = "Tier 2" + }); + + // IT-Workstations → CN=Computers + computersContainer.Delegations.Add(new ADDelegation + { + TrusteeName = "GG-IT-Workstations", + TrusteeType = TrusteeType.Group, + TargetDN = computersContainer.DistinguishedName, + Right = "CreateChild:computer", + RightCategory = RightCategory.ComputerManagement, + Tier = "Tier 2" + }); + + // Tier1-Operators → OU Domain Controllers + domainControllersOU.Delegations.Add(new ADDelegation + { + TrusteeName = "GG-Tier1-Operators", + TrusteeType = TrusteeType.Group, + TargetDN = domainControllersOU.DistinguishedName, + Right = "WriteAttribute", + RightCategory = RightCategory.AttributeWrite, + Tier = "Tier 0" + }); + // 6. Container ForeignSecurityPrincipals var fspContainer = new ADObject("ForeignSecurityPrincipals", ADObjectType.Container) { @@ -438,8 +517,8 @@ public ADObject CreateFreshDomainStructure(string domainName, bool enableTiering }; domain.Children.Add(fspContainer); - // Mettre à jour tous les DN - UpdateDistinguishedNamesRecursive(domain); + // Mettre à jour les DN du nouveau container (ajouté après le premier appel) + UpdateDistinguishedNamesRecursive(fspContainer); return domain; } @@ -853,9 +932,105 @@ public ADObject CreateSampleStructure() user1.MemberOf.Add("Domain Users"); user2.MemberOf.Add("Domain Users"); - // Mettre à jour tous les DN + // ── Groupes de délégation ──────────────────────────────────────── + var ggHelpDesk = new ADObject("GG-HelpDesk", ADObjectType.Group) + { + Description = loc["Desc.Delegation.GGHelpDesk"], + Tier = "Tier 2", + Parent = adminOU + }; + adminOU.Children.Add(ggHelpDesk); + + var ggITWorkstations = new ADObject("GG-IT-Workstations", ADObjectType.Group) + { + Description = loc["Desc.Delegation.GGITWorkstations"], + Tier = "Tier 2", + Parent = adminOU + }; + adminOU.Children.Add(ggITWorkstations); + + var ggITServers = new ADObject("GG-IT-Servers", ADObjectType.Group) + { + Description = loc["Desc.Delegation.GGITServers"], + Tier = "Tier 1", + Parent = adminOU + }; + adminOU.Children.Add(ggITServers); + + var ggTier1Operators = new ADObject("GG-Tier1-Operators", ADObjectType.Group) + { + Description = loc["Desc.Delegation.GGTier1Operators"], + Tier = "Tier 1", + Parent = adminOU + }; + adminOU.Children.Add(ggTier1Operators); + + // Mettre à jour tous les DN avant d'assigner les TargetDN des délégations UpdateDistinguishedNamesRecursive(domain); + // ── Délégations ────────────────────────────────────────────────── + // HelpDesk → OU Users : Reset password + Unlock account + usersOU.Delegations.Add(new ADDelegation + { + TrusteeName = "GG-HelpDesk", + TrusteeType = TrusteeType.Group, + TargetDN = usersOU.DistinguishedName, + Right = "ResetPassword", + RightCategory = RightCategory.PasswordReset, + Tier = "Tier 2" + }); + usersOU.Delegations.Add(new ADDelegation + { + TrusteeName = "GG-HelpDesk", + TrusteeType = TrusteeType.Group, + TargetDN = usersOU.DistinguishedName, + Right = "UnlockAccount", + RightCategory = RightCategory.AccountUnlock, + Tier = "Tier 2" + }); + + // IT-Workstations → OU Workstations : CreateChild:computer + workstationsOU.Delegations.Add(new ADDelegation + { + TrusteeName = "GG-IT-Workstations", + TrusteeType = TrusteeType.Group, + TargetDN = workstationsOU.DistinguishedName, + Right = "CreateChild:computer", + RightCategory = RightCategory.ComputerManagement, + Tier = "Tier 2" + }); + + // IT-Servers → OU Servers : CreateChild:computer + FullControl + serversOU.Delegations.Add(new ADDelegation + { + TrusteeName = "GG-IT-Servers", + TrusteeType = TrusteeType.Group, + TargetDN = serversOU.DistinguishedName, + Right = "CreateChild:computer", + RightCategory = RightCategory.ComputerManagement, + Tier = "Tier 1" + }); + serversOU.Delegations.Add(new ADDelegation + { + TrusteeName = "GG-IT-Servers", + TrusteeType = TrusteeType.Group, + TargetDN = serversOU.DistinguishedName, + Right = "FullControl", + RightCategory = RightCategory.FullControl, + Tier = "Tier 1" + }); + + // Tier1-Operators → OU Domain Controllers : WriteAttribute + domainControllersOU.Delegations.Add(new ADDelegation + { + TrusteeName = "GG-Tier1-Operators", + TrusteeType = TrusteeType.Group, + TargetDN = domainControllersOU.DistinguishedName, + Right = "WriteAttribute", + RightCategory = RightCategory.AttributeWrite, + Tier = "Tier 0" + }); + return domain; } diff --git a/SMAD-X/Services/ADDiffService.cs b/SMAD-X/Services/ADDiffService.cs new file mode 100644 index 0000000..f2e7a7f --- /dev/null +++ b/SMAD-X/Services/ADDiffService.cs @@ -0,0 +1,204 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using SMADX.Models; + +namespace SMADX.Services +{ + /// + /// Compares two DomainSnapshot trees and produces a flat list of ADChangeItem. + /// Each item is tagged with a ChangeCategory for UI filtering. + /// + public static class ADDiffService + { + public static List Compare(DomainSnapshot fileA, DomainSnapshot fileB) + { + var result = new List(); + + var flatA = Flatten(fileA.Root).ToDictionary(o => o.DistinguishedName, StringComparer.OrdinalIgnoreCase); + var flatB = Flatten(fileB.Root).ToDictionary(o => o.DistinguishedName, StringComparer.OrdinalIgnoreCase); + + // ── Structural: Added objects ────────────────────────────────── + foreach (var dn in flatB.Keys.Except(flatA.Keys, StringComparer.OrdinalIgnoreCase)) + { + var obj = flatB[dn]; + result.Add(new ADChangeItem + { + ChangeType = ChangeType.Added, + ChangeCategory = ChangeCategory.Structure, + ObjectName = obj.Name, + DistinguishedName = obj.DistinguishedName, + ObjectType = obj.Type + }); + } + + // ── Structural: Removed objects ──────────────────────────────── + foreach (var dn in flatA.Keys.Except(flatB.Keys, StringComparer.OrdinalIgnoreCase)) + { + var obj = flatA[dn]; + result.Add(new ADChangeItem + { + ChangeType = ChangeType.Removed, + ChangeCategory = ChangeCategory.Structure, + ObjectName = obj.Name, + DistinguishedName = obj.DistinguishedName, + ObjectType = obj.Type + }); + } + + // ── Per-object deltas for objects present in both files ──────── + foreach (var dn in flatA.Keys.Intersect(flatB.Keys, StringComparer.OrdinalIgnoreCase)) + { + var before = flatA[dn]; + var after = flatB[dn]; + + // Structural field changes + var structFields = DetectStructureChanges(before, after); + if (structFields.Count > 0) + result.Add(Item(ChangeType.Modified, ChangeCategory.Structure, after, structFields)); + + // MemberOf delta + var moItems = DeltaStrings(before.MemberOf, after.MemberOf, "Member"); + if (moItems.Count > 0) + result.Add(Item(ChangeType.Modified, ChangeCategory.MemberOf, after, moItems)); + + // LinkedGPOs delta + var gpoItems = DeltaStrings(before.LinkedGPOs, after.LinkedGPOs, "GPO"); + if (gpoItems.Count > 0) + result.Add(Item(ChangeType.Modified, ChangeCategory.GPO, after, gpoItems)); + + // PSO delta (applies-to list + settings) + var psoItems = DetectPsoChanges(before, after); + if (psoItems.Count > 0) + result.Add(Item(ChangeType.Modified, ChangeCategory.PSO, after, psoItems)); + + // Delegation delta + var delItems = DetectDelegationChanges(before, after); + if (delItems.Count > 0) + result.Add(Item(ChangeType.Modified, ChangeCategory.Delegation, after, delItems)); + } + + // ── Delegations on added objects ─────────────────────────────── + foreach (var dn in flatB.Keys.Except(flatA.Keys, StringComparer.OrdinalIgnoreCase)) + { + var obj = flatB[dn]; + foreach (var del in obj.Delegations) + result.Add(new ADChangeItem + { + ChangeType = ChangeType.Added, + ChangeCategory = ChangeCategory.Delegation, + ObjectName = del.TrusteeName, + DistinguishedName = del.TargetDN, + ObjectType = obj.Type, + ChangedFields = new List<(string, string, string)> + { + ("Right", string.Empty, del.Right), + ("Target", string.Empty, del.TargetDN) + } + }); + } + + return result; + } + + // ── Helpers ──────────────────────────────────────────────────────── + + private static IEnumerable Flatten(ADObject root) + { + yield return root; + foreach (var child in root.Children) + foreach (var desc in Flatten(child)) + yield return desc; + } + + private static ADChangeItem Item( + ChangeType type, ChangeCategory cat, ADObject obj, + List<(string, string, string)> fields) => + new() + { + ChangeType = type, + ChangeCategory = cat, + ObjectName = obj.Name, + DistinguishedName = obj.DistinguishedName, + ObjectType = obj.Type, + ChangedFields = fields + }; + + private static List<(string, string, string)> DetectStructureChanges(ADObject before, ADObject after) + { + var c = new List<(string, string, string)>(); + Check(c, "Name", before.Name, after.Name); + Check(c, "Description", before.Description ?? "", after.Description ?? ""); + Check(c, "Tier", before.Tier ?? "", after.Tier ?? ""); + Check(c, "Type", before.Type.ToString(), after.Type.ToString()); + return c; + } + + private static List<(string, string, string)> DeltaStrings( + IEnumerable oldSet, IEnumerable newSet, string label) + { + var c = new List<(string, string, string)>(); + var old = oldSet.ToHashSet(StringComparer.OrdinalIgnoreCase); + var nw = newSet.ToHashSet(StringComparer.OrdinalIgnoreCase); + var added = nw.Except(old).ToList(); + var removed = old.Except(nw).ToList(); + if (added.Count > 0) c.Add(($"{label} +", string.Empty, string.Join(", ", added))); + if (removed.Count > 0) c.Add(($"{label} -", string.Join(", ", removed), string.Empty)); + return c; + } + + private static List<(string, string, string)> DetectPsoChanges(ADObject before, ADObject after) + { + var c = new List<(string, string, string)>(); + CheckNullable(c, "PSO.Precedence", before.PSOPrecedence, after.PSOPrecedence); + CheckNullable(c, "PSO.MinLength", before.PSOMinPasswordLength, after.PSOMinPasswordLength); + CheckNullable(c, "PSO.HistoryCount", before.PSOPasswordHistoryCount, after.PSOPasswordHistoryCount); + CheckNullable(c, "PSO.Complexity", before.PSOComplexityEnabled, after.PSOComplexityEnabled); + CheckNullable(c, "PSO.MaxAgeDays", before.PSOMaxPasswordAgeDays, after.PSOMaxPasswordAgeDays); + CheckNullable(c, "PSO.LockoutThreshold", before.PSOLockoutThreshold, after.PSOLockoutThreshold); + CheckNullable(c, "PSO.LockoutDurationMin", before.PSOLockoutDurationMinutes, after.PSOLockoutDurationMinutes); + var psoAdded = after.PSOAppliesTo.Except(before.PSOAppliesTo, StringComparer.OrdinalIgnoreCase).ToList(); + var psoRemoved = before.PSOAppliesTo.Except(after.PSOAppliesTo, StringComparer.OrdinalIgnoreCase).ToList(); + if (psoAdded.Count > 0) c.Add(("PSO.AppliesTo +", string.Empty, string.Join(", ", psoAdded))); + if (psoRemoved.Count > 0) c.Add(("PSO.AppliesTo -", string.Join(", ", psoRemoved), string.Empty)); + return c; + } + + private static List<(string, string, string)> DetectDelegationChanges(ADObject before, ADObject after) + { + var c = new List<(string, string, string)>(); + + // Use "Trustee|Right|Target" as a stable key + static string Key(ADDelegation d) => + $"{d.TrusteeName}|{d.Right}|{d.TargetDN}".ToLowerInvariant(); + + var oldKeys = before.Delegations.ToDictionary(Key); + var newKeys = after.Delegations.ToDictionary(Key); + + foreach (var k in newKeys.Keys.Except(oldKeys.Keys)) + { + var d = newKeys[k]; + c.Add(($"Delegation +", string.Empty, $"{d.TrusteeName} → {d.Right} on {d.TargetDN}")); + } + foreach (var k in oldKeys.Keys.Except(newKeys.Keys)) + { + var d = oldKeys[k]; + c.Add(($"Delegation -", $"{d.TrusteeName} → {d.Right} on {d.TargetDN}", string.Empty)); + } + return c; + } + + private static void Check(List<(string, string, string)> list, string f, string a, string b) + { + if (!string.Equals(a, b, StringComparison.Ordinal)) list.Add((f, a, b)); + } + + private static void CheckNullable(List<(string, string, string)> list, string f, T? a, T? b) + where T : struct + { + var sa = a?.ToString() ?? string.Empty; + var sb = b?.ToString() ?? string.Empty; + if (sa != sb) list.Add((f, sa, sb)); + } + } +} diff --git a/SMAD-X/Services/ADImportPowerShellService.cs b/SMAD-X/Services/ADImportPowerShellService.cs index 07a3111..5580690 100644 --- a/SMAD-X/Services/ADImportPowerShellService.cs +++ b/SMAD-X/Services/ADImportPowerShellService.cs @@ -196,6 +196,54 @@ private static string BuildScript() L(s, " }"); L(s, " }"); L(s, ""); + L(s, " # Delegations (ACL) — OUs and Containers only"); + L(s, " $delegationsJson = '[]'"); + L(s, " if ($simType -in @('OrganizationalUnit','Container','Domain')) {"); + L(s, " try {"); + L(s, " $acl = Get-Acl -Path \"AD:$dn\" -ErrorAction Stop"); + L(s, " $skipTrustees = @('NT AUTHORITY\\SYSTEM','BUILTIN\\Administrators','CREATOR OWNER',"); + L(s, " 'NT AUTHORITY\\SELF','NT AUTHORITY\\ENTERPRISE DOMAIN CONTROLLERS',"); + L(s, " 'Everyone','BUILTIN\\Pre-Windows 2000 Compatible Access')"); + L(s, " $guidResetPwd = [guid]'00299570-246d-11d0-a768-00aa006e0529'"); + L(s, " $guidUnlockAcct = [guid]'280f369c-67c7-438e-ae98-1d46f3c6f541'"); + L(s, " $guidComputer = [guid]'bf967a86-0de6-11d0-a285-00aa003049e2'"); + L(s, " $delegParts = [System.Collections.Generic.List[string]]::new()"); + L(s, " foreach ($ace in $acl.Access) {"); + L(s, " if ($ace -isnot [System.DirectoryServices.ActiveDirectoryAccessRule]) { continue }"); + L(s, " $trustee = $ace.IdentityReference.Value"); + L(s, " if ($skipTrustees -contains $trustee) { continue }"); + L(s, " $trusteeType = 'Group'"); + L(s, " try {"); + L(s, " $tObj = Get-ADObject -Filter { SAMAccountName -eq $trustee.Split('\\\\')[-1] } -Properties objectClass -ErrorAction SilentlyContinue"); + L(s, " if ($tObj.objectClass -eq 'user') { $trusteeType = 'User' }"); + L(s, " elseif ($tObj.objectClass -eq 'computer') { $trusteeType = 'Computer' }"); + L(s, " } catch {}"); + L(s, " $trusteeSam = $trustee.Split('\\\\')[-1]"); + L(s, " $ar = $ace.ActiveDirectoryRights"); + L(s, " $objType = $ace.ObjectType"); + L(s, " $right = 'Other'"); + L(s, " $rightCategory = 'Other'"); + L(s, " if ($objType -eq $guidResetPwd) {"); + L(s, " $right = 'ResetPassword'; $rightCategory = 'PasswordReset'"); + L(s, " } elseif ($objType -eq $guidUnlockAcct) {"); + L(s, " $right = 'WriteProperty:lockoutTime'; $rightCategory = 'AccountUnlock'"); + L(s, " } elseif (($ar -band [System.DirectoryServices.ActiveDirectoryRights]::CreateChild) -and $objType -eq $guidComputer) {"); + L(s, " $right = 'CreateChild:computer'; $rightCategory = 'ComputerManagement'"); + L(s, " } elseif ($ar -band ([System.DirectoryServices.ActiveDirectoryRights]::GenericAll)) {"); + L(s, " $right = $ar.ToString(); $rightCategory = 'FullControl'"); + L(s, " } elseif ($ar -band [System.DirectoryServices.ActiveDirectoryRights]::WriteProperty) {"); + L(s, " $right = 'WriteProperty:' + $objType.ToString(); $rightCategory = 'AttributeWrite'"); + L(s, " } else {"); + L(s, " $right = $ar.ToString()"); + L(s, " }"); + L(s, " $isInherited = ($ace.IsInherited).ToString().ToLower()"); + L(s, " $tierVal = if ($tier) { $tier } else { '' }"); + L(s, " $delegParts.Add(\"{`\"TrusteeName`\":`\"$(ConvertTo-JsonString $trusteeSam)`\",`\"TrusteeType`\":`\"$trusteeType`\",`\"TargetDN`\":`\"$(ConvertTo-JsonString $dn)`\",`\"Right`\":`\"$(ConvertTo-JsonString $right)`\",`\"RightCategory`\":`\"$rightCategory`\",`\"IsInherited`\":$isInherited,`\"Tier`\":`\"$(ConvertTo-JsonString $tierVal)`\"}\")"); + L(s, " }"); + L(s, " if ($delegParts.Count -gt 0) { $delegationsJson = '[' + ($delegParts -join ',') + ']' }"); + L(s, " } catch {}"); + L(s, " }"); + L(s, ""); L(s, " # PSO fields"); L(s, " $psoAppliesToJson = '[]'"); L(s, " $psoPrec = $psoMinLen = $psoHistory = $psoComplex = $psoMaxAge = $psoMinAge = $psoLockThr = $psoLockDur = $psoLockObs = 'null'"); @@ -252,7 +300,7 @@ private static string BuildScript() L(s, ""); L(s, " $childrenJson = if ($childParts.Count -gt 0) { '[' + ($childParts -join ',') + ']' } else { '[]' }"); L(s, ""); - L(s, " return \"{`\"Id`\":`\"$id`\",`\"Name`\":`\"$(ConvertTo-JsonString $name)`\",`\"Type`\":`\"$simType`\",`\"Description`\":`\"$description`\",`\"DistinguishedName`\":`\"$(ConvertTo-JsonString $dn)`\",`\"Children`\":$childrenJson,`\"MemberOf`\":$memberOfJson,`\"LinkedGPOs`\":$linkedGPOJson,`\"PSOAppliesTo`\":$psoAppliesToJson,`\"PSOPrecedence`\":$psoPrec,`\"PSOMinPasswordLength`\":$psoMinLen,`\"PSOPasswordHistoryCount`\":$psoHistory,`\"PSOComplexityEnabled`\":$psoComplex,`\"PSOMaxPasswordAgeDays`\":$psoMaxAge,`\"PSOMinPasswordAgeDays`\":$psoMinAge,`\"PSOLockoutThreshold`\":$psoLockThr,`\"PSOLockoutDurationMinutes`\":$psoLockDur,`\"PSOLockoutObservationWindowMinutes`\":$psoLockObs,`\"CreatedDate`\":`\"$created`\",`\"ModifiedDate`\":`\"$modified`\"}\""); + L(s, " return \"{`\"Id`\":`\"$id`\",`\"Name`\":`\"$(ConvertTo-JsonString $name)`\",`\"Type`\":`\"$simType`\",`\"Description`\":`\"$description`\",`\"DistinguishedName`\":`\"$(ConvertTo-JsonString $dn)`\",`\"Children`\":$childrenJson,`\"MemberOf`\":$memberOfJson,`\"LinkedGPOs`\":$linkedGPOJson,`\"Delegations`\":$delegationsJson,`\"PSOAppliesTo`\":$psoAppliesToJson,`\"PSOPrecedence`\":$psoPrec,`\"PSOMinPasswordLength`\":$psoMinLen,`\"PSOPasswordHistoryCount`\":$psoHistory,`\"PSOComplexityEnabled`\":$psoComplex,`\"PSOMaxPasswordAgeDays`\":$psoMaxAge,`\"PSOMinPasswordAgeDays`\":$psoMinAge,`\"PSOLockoutThreshold`\":$psoLockThr,`\"PSOLockoutDurationMinutes`\":$psoLockDur,`\"PSOLockoutObservationWindowMinutes`\":$psoLockObs,`\"CreatedDate`\":`\"$created`\",`\"ModifiedDate`\":`\"$modified`\"}\""); L(s, "}"); L(s, ""); L(s, "# ── 9. Domain root ─────────────────────────────────────────────────────────"); diff --git a/SMAD-X/Services/LocalizationService.cs b/SMAD-X/Services/LocalizationService.cs index 655c104..549679a 100644 --- a/SMAD-X/Services/LocalizationService.cs +++ b/SMAD-X/Services/LocalizationService.cs @@ -862,6 +862,7 @@ Cette GPO est **créée automatiquement** avec le domaine et s'applique uniqueme ["Graph.FilterGpoLinks"] = "📋 GPO Links", ["Graph.FilterGpoInheritance"] = "⬇ Héritage GPO", ["Graph.FilterPso"] = "🔑 PSO", + ["Graph.FilterDelegations"] = "🔐 Délégations", ["Graph.FilterHierarchy"] = "📂 Hiérarchie", ["Graph.FilterIsolated"] = "Isolés", ["Graph.FitViewTooltip"] = "Zoom pour tout voir", @@ -1252,6 +1253,12 @@ Password Settings Object appliqué aux **comptes utilisateurs standards Tier 2** - Préférence : **50** (priorité inférieure à PSO-Tier0-Admins) - Activer le **self-service de réinitialisation** (SSPR) pour réduire la charge du helpdesk - Envisager une intégration avec **Microsoft Entra ID Password Protection** pour bloquer les mots de passe courants/compromis (HaveIBeenPwned)", + + // Groupes de délégation + ["Desc.Delegation.GGHelpDesk"] = "Groupe helpdesk — réinitialise les mots de passe et déverrouille les comptes utilisateurs (Tier 2)", + ["Desc.Delegation.GGITWorkstations"] = "Groupe IT Workstations — gère les objets ordinateurs dans l'OU Workstations (Tier 2)", + ["Desc.Delegation.GGITServers"] = "Groupe IT Servers — gère les objets ordinateurs et serveurs dans l'OU Servers (Tier 1)", + ["Desc.Delegation.GGTier1Operators"] = "Groupe opérateurs Tier 1 — peut modifier les attributs des contrôleurs de domaine (Tier 1)", }; // Anglais @@ -2039,6 +2046,7 @@ This GPO is **automatically created** with the domain and applies only to the ** ["Graph.FilterGpoLinks"] = "📋 GPO Links", ["Graph.FilterGpoInheritance"] = "⬇ GPO Inheritance", ["Graph.FilterPso"] = "🔑 PSO", + ["Graph.FilterDelegations"] = "🔐 Delegations", ["Graph.FilterHierarchy"] = "📂 Hierarchy", ["Graph.FilterIsolated"] = "Isolated", ["Graph.FitViewTooltip"] = "Zoom to fit all", @@ -2429,6 +2437,12 @@ Password Settings Object applied to **Tier 2 standard user accounts**. - Precedence: **50** (lower priority than PSO-Tier0-Admins) - Enable **self-service password reset (SSPR)** to reduce helpdesk load - Consider integrating **Microsoft Entra ID Password Protection** to block common/compromised passwords (HaveIBeenPwned)", + + // Delegation groups + ["Desc.Delegation.GGHelpDesk"] = "Helpdesk group — resets passwords and unlocks user accounts (Tier 2)", + ["Desc.Delegation.GGITWorkstations"] = "IT Workstations group — manages computer objects in the Workstations OU (Tier 2)", + ["Desc.Delegation.GGITServers"] = "IT Servers group — manages computer and server objects in the Servers OU (Tier 1)", + ["Desc.Delegation.GGTier1Operators"] = "Tier 1 Operators group — can modify attributes of domain controllers (Tier 1)", }; } diff --git a/SMAD-X/ViewModels/DelegationsViewModel.cs b/SMAD-X/ViewModels/DelegationsViewModel.cs new file mode 100644 index 0000000..877c6e9 --- /dev/null +++ b/SMAD-X/ViewModels/DelegationsViewModel.cs @@ -0,0 +1,135 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using System.IO; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using SMADX.Models; + +namespace SMADX.ViewModels +{ + public partial class DelegationsViewModel : ViewModelBase + { + // ── Source data ───────────────────────────────────────────────────── + [ObservableProperty] + private ObservableCollection _allDelegations = new(); + + [ObservableProperty] + private ObservableCollection _filteredDelegations = new(); + + [ObservableProperty] + private ADDelegation? _selectedDelegation; + + // ── Filters ───────────────────────────────────────────────────────── + [ObservableProperty] + private string _filterTrustee = string.Empty; + + [ObservableProperty] + private string _filterTargetDN = string.Empty; + + [ObservableProperty] + private string _filterCategory = string.Empty; // RightCategory name or "" + + [ObservableProperty] + private bool _hideInherited = false; + + // ── Statistics ────────────────────────────────────────────────────── + [ObservableProperty] + private int _countPasswordReset; + + [ObservableProperty] + private int _countComputerManagement; + + [ObservableProperty] + private int _countAccountUnlock; + + [ObservableProperty] + private int _countAttributeWrite; + + [ObservableProperty] + private int _countFullControl; + + [ObservableProperty] + private int _countOther; + + // ── Status ────────────────────────────────────────────────────────── + [ObservableProperty] + private string _statusMessage = string.Empty; + + // ── Commands ──────────────────────────────────────────────────────── + + /// Load delegations from the currently active ADObject tree. + public void LoadFromTree(ADObject root) + { + AllDelegations.Clear(); + foreach (var d in CollectDelegations(root)) + AllDelegations.Add(d); + + ApplyFilters(); + RefreshStats(); + StatusMessage = $"{AllDelegations.Count} delegation(s) found."; + } + + [RelayCommand] + private void ApplyFilters() + { + var query = AllDelegations.AsEnumerable(); + + if (HideInherited) + query = query.Where(d => !d.IsInherited); + + if (!string.IsNullOrWhiteSpace(FilterTrustee)) + { + var t = FilterTrustee.ToLowerInvariant(); + query = query.Where(d => d.TrusteeName.ToLowerInvariant().Contains(t)); + } + + if (!string.IsNullOrWhiteSpace(FilterTargetDN)) + { + var t = FilterTargetDN.ToLowerInvariant(); + query = query.Where(d => d.TargetDN.ToLowerInvariant().Contains(t)); + } + + if (!string.IsNullOrWhiteSpace(FilterCategory)) + query = query.Where(d => d.RightCategory.ToString() == FilterCategory); + + FilteredDelegations.Clear(); + foreach (var d in query) + FilteredDelegations.Add(d); + } + + [RelayCommand] + private async Task ExportToCsvAsync(string outputPath) + { + var lines = new List { "TrusteeName,TrusteeType,TargetDN,Right,RightCategory,IsInherited,Tier" }; + lines.AddRange(FilteredDelegations.Select(d => + $"{Q(d.TrusteeName)},{d.TrusteeType},{Q(d.TargetDN)},{Q(d.Right)},{d.RightCategory},{d.IsInherited},{Q(d.Tier ?? "")}")); + await File.WriteAllLinesAsync(outputPath, lines); + StatusMessage = $"Exported {FilteredDelegations.Count} row(s) to {outputPath}"; + } + + // ── Helpers ───────────────────────────────────────────────────────── + + private static IEnumerable CollectDelegations(ADObject node) + { + foreach (var d in node.Delegations) + yield return d; + foreach (var child in node.Children) + foreach (var d in CollectDelegations(child)) + yield return d; + } + + private void RefreshStats() + { + CountPasswordReset = AllDelegations.Count(d => d.RightCategory == RightCategory.PasswordReset); + CountComputerManagement = AllDelegations.Count(d => d.RightCategory == RightCategory.ComputerManagement); + CountAccountUnlock = AllDelegations.Count(d => d.RightCategory == RightCategory.AccountUnlock); + CountAttributeWrite = AllDelegations.Count(d => d.RightCategory == RightCategory.AttributeWrite); + CountFullControl = AllDelegations.Count(d => d.RightCategory == RightCategory.FullControl); + CountOther = AllDelegations.Count(d => d.RightCategory == RightCategory.Other); + } + + private static string Q(string v) => $"\"{v.Replace("\"", "\"\"")}\""; + } +} diff --git a/SMAD-X/ViewModels/DomainTimelineViewModel.cs b/SMAD-X/ViewModels/DomainTimelineViewModel.cs new file mode 100644 index 0000000..3173b3f --- /dev/null +++ b/SMAD-X/ViewModels/DomainTimelineViewModel.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using SMADX.Models; +using SMADX.Services; + +namespace SMADX.ViewModels +{ + public partial class DomainTimelineViewModel : ViewModelBase + { + // ── The two files being compared ──────────────────────────────────── + [ObservableProperty] private DomainSnapshot? _fileA; + [ObservableProperty] private DomainSnapshot? _fileB; + + // ── Diff results ──────────────────────────────────────────────────── + [ObservableProperty] private ObservableCollection _allChanges = new(); + [ObservableProperty] private ObservableCollection _filteredChanges = new(); + + // ── Stats ─────────────────────────────────────────────────────────── + [ObservableProperty] private int _countAdded; + [ObservableProperty] private int _countRemoved; + [ObservableProperty] private int _countModified; + + // ── Filters ───────────────────────────────────────────────────────── + [ObservableProperty] private string _filterText = string.Empty; + [ObservableProperty] private string _filterChangeType = string.Empty; + [ObservableProperty] private string _filterCategory = string.Empty; + + public IReadOnlyList ChangeTypeOptions { get; } = + new[] { "", "Added", "Removed", "Modified" }; + + public IReadOnlyList CategoryOptions { get; } = + new[] { "", "Structure", "MemberOf", "GPO", "PSO", "Delegation" }; + + // ── Status ────────────────────────────────────────────────────────── + [ObservableProperty] private string _statusMessage = "Open two .smad-x.json files to compare."; + + // ── Commands ──────────────────────────────────────────────────────── + + private static readonly ADDataService _dataService = new(); + + /// Loads a single .smad-x.json file into slot A or B. + public async Task LoadFileAsync(string path, bool isFileA) + { + try + { + StatusMessage = $"Loading {System.IO.Path.GetFileName(path)}…"; + var root = await _dataService.LoadFromFileAsync(path); + if (root is null) + { + StatusMessage = "Failed to load file — invalid format."; + return; + } + + var snap = new DomainSnapshot + { + FilePath = path, + DomainName = root.Name, + SnapshotDate = root.ModifiedDate, + Root = root + }; + + if (isFileA) FileA = snap; + else FileB = snap; + + StatusMessage = $"File {(isFileA ? "A" : "B")} loaded: {System.IO.Path.GetFileName(path)} ({snap.DomainName} {snap.SnapshotDate:yyyy-MM-dd HH:mm})"; + } + catch (Exception ex) + { + StatusMessage = $"Error loading file: {ex.Message}"; + } + } + + /// Runs the diff between FileA and FileB. + [RelayCommand] + private void Compare() + { + if (FileA is null || FileB is null) + { + StatusMessage = "Please open both File A and File B before comparing."; + return; + } + + try + { + var changes = ADDiffService.Compare(FileA, FileB); + AllChanges.Clear(); + foreach (var c in changes) + AllChanges.Add(c); + + CountAdded = AllChanges.Count(c => c.ChangeType == ChangeType.Added); + CountRemoved = AllChanges.Count(c => c.ChangeType == ChangeType.Removed); + CountModified = AllChanges.Count(c => c.ChangeType == ChangeType.Modified); + + ApplyFilters(); + StatusMessage = $"Comparison complete — {AllChanges.Count} change(s) · +{CountAdded} -{CountRemoved} ~{CountModified}"; + } + catch (Exception ex) + { + StatusMessage = $"Comparison error: {ex.Message}"; + } + } + + /// Applies text / type / category filters to AllChanges → FilteredChanges. + [RelayCommand] + private void ApplyFilters() + { + var query = AllChanges.AsEnumerable(); + + if (!string.IsNullOrWhiteSpace(FilterChangeType) && + System.Enum.TryParse(FilterChangeType, out var ct)) + query = query.Where(c => c.ChangeType == ct); + + if (!string.IsNullOrWhiteSpace(FilterCategory) && + System.Enum.TryParse(FilterCategory, out var cat)) + query = query.Where(c => c.ChangeCategory == cat); + + if (!string.IsNullOrWhiteSpace(FilterText)) + { + var text = FilterText.ToLowerInvariant(); + query = query.Where(c => + c.ObjectName.ToLowerInvariant().Contains(text) || + c.DistinguishedName.ToLowerInvariant().Contains(text) || + c.ChangedFieldsSummary.ToLowerInvariant().Contains(text)); + } + + FilteredChanges.Clear(); + foreach (var c in query) + FilteredChanges.Add(c); + } + + /// Exports FilteredChanges to a CSV file. + [RelayCommand] + private async Task ExportToCsvAsync(string outputPath) + { + var lines = new List { "ChangeType,Category,ObjectName,ObjectType,DistinguishedName,Details" }; + lines.AddRange(FilteredChanges.Select(c => + $"{c.ChangeType},{c.ChangeCategory},{Escape(c.ObjectName)},{c.ObjectType},{Escape(c.DistinguishedName)},{Escape(c.ChangedFieldsSummary)}")); + await File.WriteAllLinesAsync(outputPath, lines); + StatusMessage = $"Exported {FilteredChanges.Count} row(s) to {outputPath}"; + } + + private static string Escape(string v) => $"\"{v.Replace("\"", "\"\"")}\""; + } +} diff --git a/SMAD-X/ViewModels/GraphViewModel.cs b/SMAD-X/ViewModels/GraphViewModel.cs index f670238..bcfd848 100644 --- a/SMAD-X/ViewModels/GraphViewModel.cs +++ b/SMAD-X/ViewModels/GraphViewModel.cs @@ -60,6 +60,13 @@ public bool ShowIsolated set { SetProperty(ref _showIsolated, value); RequestRefresh(); } } + private bool _showDelegations = true; + public bool ShowDelegations + { + get => _showDelegations; + set { SetProperty(ref _showDelegations, value); RequestRefresh(); } + } + // Infos nœud sélectionné private string _selectedNodeInfo = string.Empty; public string SelectedNodeInfo @@ -96,6 +103,7 @@ public GraphViewModel(ADObject root) ShowGpoLinks = ShowGpoLinks, ShowGpoInheritance = ShowGpoInheritance, ShowPsoLinks = ShowPsoLinks, + ShowDelegations = ShowDelegations, ShowHierarchy = ShowHierarchy, ShowIsolated = ShowIsolated, }; diff --git a/SMAD-X/ViewModels/MainWindowViewModel.cs b/SMAD-X/ViewModels/MainWindowViewModel.cs index ddf716b..a40ebe1 100644 --- a/SMAD-X/ViewModels/MainWindowViewModel.cs +++ b/SMAD-X/ViewModels/MainWindowViewModel.cs @@ -88,6 +88,9 @@ public string SelectedObjectDescription public string SelectedObjectDistinguishedName => SelectedNode?.Data?.DistinguishedName ?? string.Empty; + /// Root ADObject of the currently loaded domain tree (null when no domain loaded). + public ADObject? RootObject => RootNodes.Count > 0 ? RootNodes[0].Data : null; + public string SelectedObjectTier { get => string.IsNullOrWhiteSpace(SelectedNode?.Data?.Tier) ? " " : SelectedNode!.Data!.Tier!; diff --git a/SMAD-X/Views/DelegationsWindow.axaml b/SMAD-X/Views/DelegationsWindow.axaml new file mode 100644 index 0000000..9a92d4e --- /dev/null +++ b/SMAD-X/Views/DelegationsWindow.axaml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +