Ending Shared Drive Permission Sprawl With Scripted Access Reviews
Stop manual NTFS permission edits for good by scripting ACL baselines, drift detection, auto-remediation and self-service group access requests.

This playbook covers
Table of Contents
Table of contents
#The Old Way vs the New Way
The old way of handling file share access starts with a ticket titled “Please give me access to the Finance folder like Sarah has” and ends thirty minutes later with a technician manually browsing nested NTFS folders, right-clicking Properties, clicking Security, and copy-pasting group memberships they cannot fully audit. Six months later nobody remembers why Sarah has access to four different departmental shares, and a security audit turns into a two-week fire drill because permissions were granted individually rather than through groups. The old way treats every access request as a one-off manual action and never revisits it. The new way treats access as data: every NTFS ACL is inventoried on a schedule, drift from an approved baseline triggers an automated remediation or a flagged review, and new access requests are granted through group membership via a self-service workflow rather than a technician manually editing an ACL. The ticket does not disappear because we got faster at editing permissions—it disappears because permissions become a governed, versioned, and continuously audited dataset instead of a manual craft.
#Prerequisites and Permissions
You need a Windows Server or file server host with the NTFS module available (built into PowerShell 5.1 and PowerShell 7 with the Microsoft.PowerShell.Security module), and a service account with Read permission on the target share hierarchy for inventory jobs, and Modify/Full Control delegated only on the specific parent folders where remediation is authorised to run. Do not run remediation using a Domain Admin account. Create a dedicated gMSA (Group Managed Service Account) scoped to the file server OU. You also need write access to a SQL Server or SQLite database (or, at minimum, a version-controlled CSV/JSON baseline in Git) to store the approved permission baseline, and a channel (Teams webhook or email relay) for drift alerts. For the self-service piece, you need Azure AD group-based licensing/entitlement or an on-prem equivalent such as a PowerShell-driven distribution group with dynamic membership rules tied to an HR attribute.
#Step-by-Step Implementation
1. Inventory the current ACL state across the target share. Run the baseline capture script below and confirm it produces one row per unique (path, identity, rights) tuple. Expected result: a CSV with several hundred to several thousand rows depending on share size. Evidence to capture: row count and file hash of the output, logged with a timestamp.
1$RootPath = "\\fileserver01\Shares\Finance"
2$Output = "C:\Audit\acl_baseline_$(Get-Date -Format yyyyMMdd).csv"
3
4Get-ChildItem -Path $RootPath -Recurse -Directory -ErrorAction SilentlyContinue |
5 ForEach-Object {
6 $acl = Get-Acl -Path $_.FullName
7 foreach ($entry in $acl.Access) {
8 [PSCustomObject]@{
9 Path = $_.FullName
10 Identity = $entry.IdentityReference.Value
11 Rights = $entry.FileSystemRights
12 Inherited = $entry.IsInherited
13 AccessType = $entry.AccessControlType
14 }
15 }
16 } | Export-Csv -Path $Output -NoTypeInformation -Encoding UTF8
17
18Write-Output "Captured $((Import-Csv $Output).Count) ACL entries to $Output"Expected output example: Captured 1834 ACL entries to C:\Audit\acl_baseline_20240611.csv.
2. Convert direct user grants into group-based access. Any row where Identity is a user object (not a group) rather than a domain group is technical debt. Flag these programmatically:
1$Baseline = Import-Csv "C:\Audit\acl_baseline_20240611.csv"
2$DirectUserGrants = $Baseline | Where-Object {
3 (Get-ADObject -Filter "Name -eq '$($_.Identity.Split('\')[-1])'" -ErrorAction SilentlyContinue).ObjectClass -eq "user"
4}
5$DirectUserGrants | Export-Csv "C:\Audit\direct_user_grants.csv" -NoTypeInformation
6Write-Output "Found $($DirectUserGrants.Count) direct user grants requiring migration to group membership"Expected result: a short list, typically 5–15% of total entries. Evidence to capture: the exported CSV and the count in the ticket-deflection log.
3. Establish the approved baseline. Work with the data owner once to define which security groups should have access to which top-level folders and at what rights level (Read, Modify, Full Control). Store this as versioned JSON in your Git repository, not as a spreadsheet on someone’s desktop.
1{
2 "share": "\\fileserver01\Shares\Finance",
3 "approved": [
4 { "path": "\\fileserver01\Shares\Finance", "group": "GRP-Finance-Read", "rights": "ReadAndExecute" },
5 { "path": "\\fileserver01\Shares\Finance\Payroll", "group": "GRP-Finance-Payroll-Modify", "rights": "Modify" }
6 ]
7}Expected result: a reviewable, diffable source of truth. Evidence to capture: the Git commit hash of the approved baseline, signed off by the data owner via email or ticket approval.
4. Schedule a nightly drift-detection job comparing live ACLs against the approved baseline. Any entry present on disk but absent from baseline is drift; any approved entry missing from disk is a break. Run via Scheduled Task or an Azure Automation runbook.

1$Approved = Get-Content "C:\Baseline\finance_baseline.json" | ConvertFrom-Json
2$Live = Get-Acl -Path $Approved.share
3foreach ($rule in $Approved.approved) {
4 $match = $Live.Access | Where-Object { $_.IdentityReference -match $rule.group -and $_.FileSystemRights -match $rule.rights }
5 if (-not $match) {
6 Write-Warning "DRIFT: Missing approved grant for $($rule.group) on $($rule.path)"
7 }
8}Expected result: a clean run with zero warnings, or a list of drift lines written to the automation job log. Evidence to capture: the job’s console output archived for 90 days.
5. Route new access requests through a self-service group-join workflow instead of a technician editing ACLs. In Azure AD or on-prem AD, expose the security groups as self-service-requestable with owner approval, then remove the technician entirely from the access grant path.
1New-ADGroup -Name "GRP-Finance-Payroll-Modify" -GroupScope Global -GroupCategory Security -Path "OU=Groups,OU=Finance,DC=corp,DC=local"
2Set-ADGroup -Identity "GRP-Finance-Payroll-Modify" -Add @{ "msDS-GroupMSAMembership" = $null }
3# Enable self-service group membership via Azure AD access packages or PIM for Groups for approval workflowExpected result: users request membership via a portal, an owner approves, and access is granted without a technician touching an ACL. Evidence to capture: access package approval log entry with requester, approver, and timestamp.
6. Auto-remediate low-risk drift (unauthorised Everyone or Authenticated Users entries) immediately, and quarantine higher-risk drift for human review rather than silent auto-correction.
1$Live.Access | Where-Object { $_.IdentityReference -eq "Everyone" -or $_.IdentityReference -eq "NT AUTHORITY\Authenticated Users" } |
2 ForEach-Object {
3 $Live.RemoveAccessRule($_) | Out-Null
4 Write-Output "Removed high-risk grant: $($_.IdentityReference) on $($Approved.share)"
5 }
6Set-Acl -Path $Approved.share -AclObject $LiveExpected result: zero broad-group grants remain after remediation. Evidence to capture: before/after ACL export diff attached to the change record.
#Verification and Expected Evidence
Confirm success by re-running the baseline capture script (Step 1) and diffing the output against the previous run. A clean state shows zero direct user grants, zero Everyone/Authenticated Users entries, and 100% of approved groups present with correct rights. Capture the diff file, the automation job’s exit code (0 for success), and a screenshot or export of the self-service access package showing at least one successful group-join request processed without technician involvement.
| Check | Tool/Command | Pass Criteria |
|---|---|---|
| Direct user grants | Step 2 script | Count equals 0 |
| Broad group exposure | Step 6 script | No Everyone/Authenticated Users entries |
| Baseline drift | Step 4 nightly job | Zero unresolved warnings |
| Self-service adoption | Access package audit log | >80% of new requests routed via self-service within 30 days |
#Rollback
Before running Step 6 remediation in production, export the full ACL with Get-Acl and store it as a restorable object using Export-Clixml. If remediation removes a grant that turns out to be legitimately required (for example, a legacy service account depending on Authenticated Users), restore instantly with Import-Clixml followed by Set-Acl, and add the exception to the approved baseline JSON with a documented justification before re-running remediation. Roll back the self-service workflow by disabling the access package and reverting to manual ticket-based grants only if approval routing produces incorrect group assignments in more than one case within a week.
#Failure and Escalation Conditions
Wake a human technician immediately if the nightly drift job fails to run three consecutive nights, if remediation removes a grant on a path tagged as business-critical in the baseline metadata, or if the self-service approval workflow grants access without a recorded approver (indicating a broken conditional access or approval policy). Escalate to the security team, not just IT operations, if drift detection finds a domain group with unexpected nested membership from an untrusted OU, since this may indicate lateral movement rather than simple permission sprawl.
Rendering diagram...
#Measuring Ticket Deflection
Track four metrics monthly: the number of “please give me access like X” tickets closed by self-service versus manually by a technician, the average time-to-grant for access requests before and after the self-service rollout, the count of drift incidents caught proactively by the nightly job versus discovered reactively during an audit, and the percentage reduction in direct user ACL entries quarter over quarter. A mature deployment should show self-service handling over 80% of routine access requests within 60 days, with technician-handled access tickets dropping by a comparable margin. Feed these numbers into your existing ticketing platform’s reporting dashboard so the deflection trend is visible to both IT leadership and the security audit team.

#Change-Control Record Keeping
Every baseline amendment must be logged as a formal change record before the nightly job consumes the updated JSON. At minimum, capture the requester, the data owner who approved the exception, the Git commit hash, and a rollback reference pointing to the prior commit hash. Store this in your change management system (ServiceNow, Jira Service Management, or an internal wiki table) rather than relying on Git commit messages alone, since auditors will ask for approval evidence separate from the technical artefact.
<code class=
#Monitoring and Alerting Configuration
Wire the nightly drift job’s output into your existing monitoring stack rather than leaving warnings buried in a Scheduled Task history log that nobody checks. Configure the task to write structured output to the Windows Event Log under a custom source, then forward matching events to your SIEM or use a lightweight webhook call at the end of the script:
1if ($DriftCount -gt 0) {
2 $Body = @{ text = "ACL drift detected on Finance share: $DriftCount entries. See job log for details." } | ConvertTo-Json
3 Invoke-RestMethod -Uri $TeamsWebhookUrl -Method Post -Body $Body -ContentType "application/json"
4}
5Write-EventLog -LogName Application -Source "ACLDriftMonitor" -EventId 4100 -EntryType Warning -Message "Drift count: $DriftCount"Set an alerting threshold of zero tolerance for high-risk drift (broad group exposure) and a rolling seven-day tolerance of no more than two unresolved low-risk drift instances before a ticket is automatically raised against the file server team. Review the Scheduled Task’s last run result weekly using Get-ScheduledTaskInfo to confirm LastTaskResult equals 0, since a silently failing task produces no drift warnings at all and creates a false sense of a clean baseline.
#Realistic Failure Symptoms
The most common failure mode is not the script erroring outright but the service account losing Read permission after an unrelated group membership change, causing Get-Acl to throw access-denied exceptions on a subset of nested folders while the top-level scan completes normally. This produces a misleadingly short baseline CSV with no warning unless row counts are compared against the prior run. A second recurring symptom is drift detection flagging false positives after a legitimate AD group rename, where the group SID resolves correctly but the string match against the baseline JSON fails because the JSON still references the old group name. Reconcile by matching on SID rather than name where the baseline schema allows it. A third symptom is the self-service access package granting membership correctly but the underlying file server failing to pick up the new group membership for several hours due to Kerberos ticket caching; document this expected propagation delay in the runbook so technicians do not treat it as a fault.
#Change-Control Escalation Thresholds
Any baseline amendment affecting more than five paths in a single commit requires a second approver beyond the data owner, logged as a co-sign in the change record. If more than 10% of a share’s approved baseline entries require exception amendments within a single quarter, escalate to a full access review with the data owner rather than continuing to patch individual exceptions, since this pattern typically indicates the original baseline was scoped incorrectly rather than genuine business drift.
For deeper reference on the underlying commands and access model used here, see Microsoft’s documentation on Get-Acl and NTFS security descriptors and on Entra ID entitlement management for self-service access packages. Once this pipeline is running, the recurring access-request ticket stops being a manual chore and becomes a data quality problem that mostly solves itself, leaving technicians to handle only the genuine exceptions that actually need a human judgement call.
Evidence trail
Sources and verification
Primary documentation and external technical references used in this article.
Comments
Add a thoughtful note on Ending Shared Drive Permission Sprawl With Scripted Access Reviews. Comments are checked for spam and held for moderation before appearing.
Related articles
Automation & Scripting
Ending macOS Password Lockout Tickets With Bootstrap Token Audits
Missing bootstrap token escrow silently breaks the native macOS password reset button, forcing manual recovery key resets that automated auditing prevents.
Automation & Scripting
Stop the WMI Repair Ticket Loop with Proactive Remediation
A corrupted WMI repository silently blocks Software Center and Intune app installs until this proactive remediation script rebuilds it automatically.
Enterprise IT Management
Continuous Control Monitoring for SOC 2 Audits
How AWS Config, Okta logs and GitHub audit events feed a continuous control monitoring pipeline that replaces manual SOC 2 evidence pulls.
Enterprise IT Management
Tiered Admin Model: Killing Lateral Movement
How authentication silos, PAWs and Kerberos armoring enforce privileged access tiering to stop pass-the-hash lateral movement across AD tiers.
Learn More About KBY
About KBY
Learn about our mission, editorial standards, and commitment to trusted engineering knowledge.
Why Trust KBY
Explore the processes and policies that ensure our publications are accurate, useful, and responsible.
Newsletter
Get our latest editorial publications, research and practical insights sent directly to your inbox.
Was this useful?
Operate smarter, with fewer recurring tickets.
Receive new operational playbooks, incident-prevention guidance, automation scripts and recovery runbooks.