During routine security audits I discovered that some users in our 1Password Business instance who had accepted invitations and appeared as active users but had no actual access to company vaults. These users were consuming paid licenses without being able to use 1Password for its intended purpose.
Visual Results : The Final Email
These are some images of the final email that the user will receive:
When running 1Password CLI audits, we found users with this pattern, getting this data was covered in the post here - this wqould leave you with a output like this:
Name,Email,ID,State,Groups,DirectVaults,GroupCount,VaultCount
"John Doe","james.bond@croucher.cloud","<hex-data>","ACTIVE","Team Members","No direct vaults","1","0"
What this means:
- User has accepted their 1Password invitation (State: ACTIVE)
- User is assigned to the default "Team Members" group (GroupCount: 1)
- User has zero vault assignments (VaultCount: 0)
- User cannot access any company passwords or data
- User is consuming a paid license unnecessarily
Why does this occur?
This typically occurs when:
- User is invited to 1Password
- Users receive and accept 1Password invitations
- Owner/Administrator never assigns users to appropriate vaults
- Users appear "active" but have no functional access
- License costs accumulate for non-functional accounts
Fixing these ghost users with Powershell and emails
I therefore created a PowerShell script that:
- Reads the latest 1Password user report CSV export
- Identifies users with vault access issues (VaultCount = 0)
- Sends automated email notifications to affected users
- Gives users 14 days to contact the security team
- Creates detailed logs for audit purposes
Email Notification
The reads the CSV and then generates HTML emails containing:
- User's name and account details
- Explanation of the access issue
- 14-day timeline for resolution
- Contact information for the security team
- Professional formatting for corporate communication
Automatic File Processing
- Automatically finds the latest CSV file matching "1password-users-report*.csv"
- Handles various CSV formats and data types
- Provides error handling for missing or corrupt files
Dynamic Email Generation
- HTML emails with CSS styling for professional appearance
- Personalized content using user data from CSV
- Mobile-responsive design
- Corporate branding appropriate for internal communications
Logging
All activities are logged with timestamps:
- Users processed
- Email notifications sent
- Errors encountered
- Summary statistics
Test Mode - Simulate who would get an email
The script includes a test mode for validation:
.\remediate-email-notification.ps1 -TestMode
This shows what emails would be sent without actually sending them.
Script : remediate-email-notification.ps1
This does not include the HTML content, ensure you add that in the bold section where you see the <html content goes here> section, also update the other bold sections are required.
# 1Password User Access Notification Script
# This script processes 1Password user reports and sends notifications to users without proper access
param(
[string]$SmtpServer = "smtp.bear.local",
[string]$SmtpPort = "25",
[string]$FromEmail = "1password@croucher.cloud",
[string]$SmtpUsername = "",
[string]$SmtpPassword = "",
[switch]$UseSSL = $false,
[switch]$TestMode = $false
)
# Function to write to log file
function Write-LogEntry {
param(
[string]$Message,
[string]$Level = "INFO"
)
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$logEntry = "[$timestamp] [$Level] $Message"
Write-Host $logEntry
Add-Content -Path $script:LogFile -Value $logEntry
}
# Function to extract first and last name from full name
function Get-NameParts {
param([string]$FullName)
$nameParts = $FullName.Trim() -split '\s+'
if ($nameParts.Count -ge 2) {
$firstName = $nameParts[0]
$lastName = $nameParts[-1]
} else {
$firstName = $nameParts[0]
$lastName = ""
}
return @{
FirstName = $firstName
LastName = $lastName
}
}
# Function to send email notification
function Send-AccessNotification {
param(
[string]$UserName,
[string]$UserEmail,
[string]$UserState,
[int]$GroupCount,
[int]$VaultCount
)
try {
$nameparts = Get-NameParts -FullName $UserName
$firstName = $nameparts.FirstName
$lastName = $nameparts.LastName
# Calculate the deletion date (14 days from today)
$deletionDate = (Get-Date).AddDays(14).ToString("dddd, MMMM dd, yyyy")
$subject = "Action Required: 1Password Account Access - Account Deletion Notice"
$body = @"
<!DOCTYPE html>
<html lang="en">
<html content goes here>
</html>
"@
if ($TestMode) {
Write-LogEntry "TEST MODE: Would send email to $UserEmail with subject: $subject" "TEST"
Write-LogEntry "TEST MODE: Email body preview (first 200 chars): $($body.Substring(0, [Math]::Min(200, $body.Length)))..." "TEST"
} else {
# Configure email parameters
$emailParams = @{
To = $UserEmail
From = $FromEmail
Subject = $subject
Body = $body
BodyAsHtml = $true
SmtpServer = $SmtpServer
Port = $SmtpPort
}
# Add SSL if specified
if ($UseSSL) {
$emailParams.Add('UseSsl', $true)
}
# Add credentials only if username and password are provided
if ($SmtpUsername -and $SmtpPassword) {
$securePassword = ConvertTo-SecureString $SmtpPassword -AsPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential($SmtpUsername, $securePassword)
$emailParams.Add('Credential', $credential)
Write-LogEntry "Using SMTP authentication for $SmtpServer" "INFO"
} else {
Write-LogEntry "Using SMTP without authentication for $SmtpServer" "INFO"
}
# Send email
Send-MailMessage @emailParams
Write-LogEntry "Email sent successfully to $UserEmail" "SUCCESS"
}
return $true
}
catch {
Write-LogEntry "Failed to send email to $UserEmail. Error: $($_.Exception.Message)" "ERROR"
return $false
}
}
# Main script execution starts here
try {
# Set up logging
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
$script:LogFile = "1password_notification_log_$timestamp.txt"
Write-LogEntry "Starting 1Password User Access Notification Script" "INFO"
Write-LogEntry "Test Mode: $TestMode" "INFO"
# Find the latest 1password-users-report CSV file
$reportFiles = Get-ChildItem -Path "." -Filter "1password-users-report*.csv" | Sort-Object Name -Descending
if ($reportFiles.Count -eq 0) {
Write-LogEntry "No 1password-users-report CSV files found in current directory" "ERROR"
# List all CSV files for debugging
$allCsvFiles = Get-ChildItem -Path "." -Filter "*.csv"
Write-LogEntry "Available CSV files in directory: $($allCsvFiles.Name -join ', ')" "INFO"
exit 1
}
$latestFile = $reportFiles[0].Name
Write-LogEntry "Found latest report file: $latestFile" "INFO"
# Read and parse the CSV file
$csvData = Import-Csv -Path $latestFile
Write-LogEntry "Successfully imported $($csvData.Count) user records from CSV" "INFO"
# Initialize counters
$totalUsers = 0
$usersNeedingNotification = 0
$emailsSent = 0
$emailsFailed = 0
# Process each user record
foreach ($user in $csvData) {
$totalUsers++
# Convert counts to integers (handle potential string values)
$groupCount = [int]$user.GroupCount
$vaultCount = [int]$user.VaultCount
Write-LogEntry "Processing user: $($user.Name) - Groups: $groupCount, Vaults: $vaultCount" "INFO"
# Check if user needs notification
# Criteria: Users with no vault access (VaultCount = 0)
# Users need vault access to access company data, regardless of group membership
$needsNotification = ($vaultCount -eq 0)
# Alternative criteria if you want to notify only users with BOTH no groups AND no vaults:
# $needsNotification = ($groupCount -eq 0 -and $vaultCount -eq 0)
if ($needsNotification) {
$usersNeedingNotification++
Write-LogEntry "User $($user.Name) requires notification (Groups: $groupCount, Vaults: $vaultCount)" "WARNING"
# Send notification email
$emailResult = Send-AccessNotification -UserName $user.Name -UserEmail $user.Email -UserState $user.State -GroupCount $groupCount -VaultCount $vaultCount
if ($emailResult) {
$emailsSent++
} else {
$emailsFailed++
}
} else {
Write-LogEntry "User $($user.Name) has adequate access (Groups: $groupCount, Vaults: $vaultCount)" "INFO"
}
}
# Generate summary report
Write-LogEntry "=== PROCESSING SUMMARY ===" "INFO"
Write-LogEntry "Total users processed: $totalUsers" "INFO"
Write-LogEntry "Users needing notification: $usersNeedingNotification" "INFO"
Write-LogEntry "Emails sent successfully: $emailsSent" "INFO"
Write-LogEntry "Email failures: $emailsFailed" "INFO"
Write-LogEntry "Log file created: $script:LogFile" "INFO"
if ($TestMode) {
Write-LogEntry "Script completed in TEST MODE - no actual emails were sent" "INFO"
} else {
Write-LogEntry "Script completed successfully" "INFO"
}
} catch {
Write-LogEntry "Script execution failed: $($_.Exception.Message)" "ERROR"
Write-LogEntry "Stack trace: $($_.Exception.StackTrace)" "ERROR"
exit 1
}
# Example usage:
# .\1password-notification.ps1 -TestMode # Test run without sending emails
# .\1password-notification.ps1 -SmtpServer "mail.severntrent.co.uk" # Internal SMTP without auth
# .\1password-notification.ps1 -SmtpServer "smtp.office365.com" -SmtpPort 587 -UseSSL -SmtpUsername "your-email@severntrent.co.uk" -SmtpPassword "your-password" # External SMTP with auth