From b8cf9fea0a0c1e431ccff30b5021c3db298cf8e0 Mon Sep 17 00:00:00 2001 From: JM2K69 Date: Tue, 2 Jun 2026 07:14:26 +0000 Subject: [PATCH 1/8] docs: add ROADMAP for timeline and delegations features --- ROADMAP.md | 197 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 ROADMAP.md 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. From b3991ed8a683f9aae9e0f2eb899d7d9b85f06c66 Mon Sep 17 00:00:00 2001 From: JM2K69 Date: Tue, 2 Jun 2026 07:36:27 +0000 Subject: [PATCH 2/8] feat: add Domain Timeline and Delegations features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Models: DomainSnapshot, ADChangeItem (ChangeType enum), ADDelegation (RightCategory/TrusteeType enums) - ADObject: add Delegations property (ObservableCollection) - Services: ADDiffService (Compare/Flatten/DetectChanges) - Services: ADImportPowerShellService — export ACL delegations in PS script (PasswordReset, AccountUnlock, ComputerManagement, AttributeWrite, FullControl) - ViewModels: DomainTimelineViewModel, DelegationsViewModel - Views: DomainTimelineWindow, DelegationsWindow (DataGrid-based) - MainWindow: Analyse menu (Ctrl+T Timeline, Ctrl+D Delegations) - MainWindowViewModel: RootObject property - csproj: add Avalonia.Controls.DataGrid 12.0.0 - App.axaml: include DataGrid Fluent theme --- SMAD-X/App.axaml | 1 + SMAD-X/Models/ADChangeItem.cs | 32 +++++ SMAD-X/Models/ADDelegation.cs | 44 ++++++ SMAD-X/Models/ADObject.cs | 5 + SMAD-X/Models/DomainSnapshot.cs | 24 ++++ SMAD-X/SMAD-X.csproj | 1 + SMAD-X/Services/ADDiffService.cs | 120 ++++++++++++++++ SMAD-X/Services/ADImportPowerShellService.cs | 50 ++++++- SMAD-X/ViewModels/DelegationsViewModel.cs | 135 ++++++++++++++++++ SMAD-X/ViewModels/DomainTimelineViewModel.cs | 142 +++++++++++++++++++ SMAD-X/ViewModels/MainWindowViewModel.cs | 3 + SMAD-X/Views/DelegationsWindow.axaml | 103 ++++++++++++++ SMAD-X/Views/DelegationsWindow.axaml.cs | 43 ++++++ SMAD-X/Views/DomainTimelineWindow.axaml | 121 ++++++++++++++++ SMAD-X/Views/DomainTimelineWindow.axaml.cs | 58 ++++++++ SMAD-X/Views/MainWindow.axaml | 4 + SMAD-X/Views/MainWindow.axaml.cs | 15 ++ 17 files changed, 900 insertions(+), 1 deletion(-) create mode 100644 SMAD-X/Models/ADChangeItem.cs create mode 100644 SMAD-X/Models/ADDelegation.cs create mode 100644 SMAD-X/Models/DomainSnapshot.cs create mode 100644 SMAD-X/Services/ADDiffService.cs create mode 100644 SMAD-X/ViewModels/DelegationsViewModel.cs create mode 100644 SMAD-X/ViewModels/DomainTimelineViewModel.cs create mode 100644 SMAD-X/Views/DelegationsWindow.axaml create mode 100644 SMAD-X/Views/DelegationsWindow.axaml.cs create mode 100644 SMAD-X/Views/DomainTimelineWindow.axaml create mode 100644 SMAD-X/Views/DomainTimelineWindow.axaml.cs 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/Models/ADChangeItem.cs b/SMAD-X/Models/ADChangeItem.cs new file mode 100644 index 0000000..376c841 --- /dev/null +++ b/SMAD-X/Models/ADChangeItem.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; + +namespace SMADX.Models +{ + public enum ChangeType + { + Added, + Removed, + Modified + } + + /// + /// Represents a single change detected between two AD snapshots. + /// + public class ADChangeItem + { + public ChangeType ChangeType { get; set; } + 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 => $"{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/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/ADDiffService.cs b/SMAD-X/Services/ADDiffService.cs new file mode 100644 index 0000000..1c9579e --- /dev/null +++ b/SMAD-X/Services/ADDiffService.cs @@ -0,0 +1,120 @@ +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. + /// + public static class ADDiffService + { + /// + /// Compare a baseline snapshot against a current snapshot. + /// + public static List Compare(DomainSnapshot baseline, DomainSnapshot current) + { + var result = new List(); + + var baselineFlat = Flatten(baseline.Root); + var currentFlat = Flatten(current.Root); + + var baselineByDn = baselineFlat.ToDictionary(o => o.DistinguishedName, StringComparer.OrdinalIgnoreCase); + var currentByDn = currentFlat .ToDictionary(o => o.DistinguishedName, StringComparer.OrdinalIgnoreCase); + + // Added + foreach (var dn in currentByDn.Keys.Except(baselineByDn.Keys, StringComparer.OrdinalIgnoreCase)) + { + var obj = currentByDn[dn]; + result.Add(new ADChangeItem + { + ChangeType = ChangeType.Added, + ObjectName = obj.Name, + DistinguishedName = obj.DistinguishedName, + ObjectType = obj.Type + }); + } + + // Removed + foreach (var dn in baselineByDn.Keys.Except(currentByDn.Keys, StringComparer.OrdinalIgnoreCase)) + { + var obj = baselineByDn[dn]; + result.Add(new ADChangeItem + { + ChangeType = ChangeType.Removed, + ObjectName = obj.Name, + DistinguishedName = obj.DistinguishedName, + ObjectType = obj.Type + }); + } + + // Modified + foreach (var dn in baselineByDn.Keys.Intersect(currentByDn.Keys, StringComparer.OrdinalIgnoreCase)) + { + var before = baselineByDn[dn]; + var after = currentByDn[dn]; + var fields = DetectChanges(before, after); + if (fields.Count > 0) + { + result.Add(new ADChangeItem + { + ChangeType = ChangeType.Modified, + ObjectName = after.Name, + DistinguishedName = after.DistinguishedName, + ObjectType = after.Type, + ChangedFields = fields + }); + } + } + + 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 List<(string Field, string OldValue, string NewValue)> DetectChanges(ADObject before, ADObject after) + { + var changes = new List<(string, string, string)>(); + + Check(changes, nameof(ADObject.Name), before.Name, after.Name); + Check(changes, nameof(ADObject.Description), before.Description, after.Description); + Check(changes, nameof(ADObject.Tier), before.Tier ?? "", after.Tier ?? ""); + Check(changes, nameof(ADObject.Type), before.Type.ToString(), after.Type.ToString()); + + // MemberOf delta + var addedMemberships = after.MemberOf.Except(before.MemberOf).ToList(); + var removedMemberships = before.MemberOf.Except(after.MemberOf).ToList(); + if (addedMemberships.Count > 0) + changes.Add(("MemberOf +", string.Empty, string.Join(", ", addedMemberships))); + if (removedMemberships.Count > 0) + changes.Add(("MemberOf -", string.Join(", ", removedMemberships), string.Empty)); + + // LinkedGPOs delta + var addedGpos = after.LinkedGPOs.Except(before.LinkedGPOs).ToList(); + var removedGpos = before.LinkedGPOs.Except(after.LinkedGPOs).ToList(); + if (addedGpos.Count > 0) + changes.Add(("LinkedGPOs +", string.Empty, string.Join(", ", addedGpos))); + if (removedGpos.Count > 0) + changes.Add(("LinkedGPOs -", string.Join(", ", removedGpos), string.Empty)); + + return changes; + } + + private static void Check( + List<(string, string, string)> list, + string field, string oldVal, string newVal) + { + if (!string.Equals(oldVal, newVal, StringComparison.Ordinal)) + list.Add((field, oldVal, newVal)); + } + } +} 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/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..e20f35e --- /dev/null +++ b/SMAD-X/ViewModels/DomainTimelineViewModel.cs @@ -0,0 +1,142 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Text.Json; +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 + { + // ── Snapshots loaded by the user ──────────────────────────────────── + [ObservableProperty] + private ObservableCollection _snapshots = new(); + + [ObservableProperty] + private DomainSnapshot? _selectedBaseline; + + [ObservableProperty] + private DomainSnapshot? _selectedCurrent; + + // ── Diff results ──────────────────────────────────────────────────── + [ObservableProperty] + private ObservableCollection _allChanges = new(); + + [ObservableProperty] + private ObservableCollection _filteredChanges = new(); + + // ── Filters ───────────────────────────────────────────────────────── + [ObservableProperty] + private string _filterText = string.Empty; + + [ObservableProperty] + private string _filterChangeType = string.Empty; // "Added" | "Removed" | "Modified" | "" + + [ObservableProperty] + private string _filterObjectType = string.Empty; + + // ── Status ────────────────────────────────────────────────────────── + [ObservableProperty] + private string _statusMessage = string.Empty; + + // ── Commands ──────────────────────────────────────────────────────── + + /// Adds one or more .smad-x.json snapshot files to the timeline. + [RelayCommand] + private async Task AddSnapshotsAsync(IEnumerable filePaths) + { + var opts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + + foreach (var path in filePaths) + { + if (!File.Exists(path)) continue; + + await using var stream = File.OpenRead(path); + var root = await JsonSerializer.DeserializeAsync(stream, opts); + if (root is null) continue; + + var snap = new DomainSnapshot + { + FilePath = path, + DomainName = root.Name, + SnapshotDate = root.ModifiedDate, + Root = root + }; + + Snapshots.Add(snap); + } + + StatusMessage = $"{Snapshots.Count} snapshot(s) loaded."; + } + + /// Removes the selected snapshot from the timeline. + [RelayCommand] + private void RemoveSnapshot(DomainSnapshot? snap) + { + if (snap is not null) + Snapshots.Remove(snap); + } + + /// Runs the diff between SelectedBaseline and SelectedCurrent. + [RelayCommand] + private void Compare() + { + if (SelectedBaseline is null || SelectedCurrent is null) + { + StatusMessage = "Please select a baseline and a current snapshot."; + return; + } + + var changes = ADDiffService.Compare(SelectedBaseline, SelectedCurrent); + AllChanges.Clear(); + foreach (var c in changes) + AllChanges.Add(c); + + ApplyFilters(); + StatusMessage = $"{AllChanges.Count} change(s) detected."; + } + + /// Applies text/type filters to AllChanges. + [RelayCommand] + private void ApplyFilters() + { + var query = AllChanges.AsEnumerable(); + + if (!string.IsNullOrWhiteSpace(FilterChangeType)) + query = query.Where(c => c.ChangeType.ToString() == FilterChangeType); + + if (!string.IsNullOrWhiteSpace(FilterObjectType)) + query = query.Where(c => c.ObjectType.ToString() == FilterObjectType); + + if (!string.IsNullOrWhiteSpace(FilterText)) + { + var text = FilterText.ToLowerInvariant(); + query = query.Where(c => + c.ObjectName.ToLowerInvariant().Contains(text) || + c.DistinguishedName.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,ObjectName,ObjectType,DistinguishedName,Details" }; + lines.AddRange(FilteredChanges.Select(c => + $"{c.ChangeType},{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/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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +