IGA | Access Certification | Lifecycle Management
Enterprise Identity Governance & Administration
Automate access reviews, detect orphan accounts, enforce least privilege!
This project teaches Identity Governance concepts used in:
| Enterprise Platform | Our Implementation | Skill Transfer |
|---|---|---|
| Saviynt / SailPoint | Python + Authentik API | 85% |
| Access Certifications | Automated Review Campaigns | 90% |
| Joiner-Mover-Leaver | Lifecycle Automation Scripts | 85% |
| Orphan Account Detection | Scheduled Analysis Jobs | 95% |
| SoD Policies | Rule Engine + Alerts | 80% |
| Phase | Time | Difficulty |
|---|---|---|
| Understanding Concepts | 20-30 min | Reading |
| Access Certification Engine | 60-90 min | Medium |
| JML Lifecycle Scripts | 45-60 min | Medium |
| Orphan Account Detection | 30-45 min | Easy |
| SoD Policy Engine | 45-60 min | Medium |
| Approval Workflows | 30-45 min | Medium |
| Compliance Reporting | 30-45 min | Easy |
Total: 5-7 hours
Understanding Identity Governance
The policies, processes, and tools that ensure the right people have the right access at the right time
New hire onboarding
Role/dept change
Offboarding
| Without IGA | With IGA |
|---|---|
| Access accumulates over time (privilege creep) | Regular reviews remove unneeded access |
| Ex-employees retain access for months | Access revoked on last day automatically |
| No visibility into who has what | Complete access inventory |
| Audit findings: "Cannot prove access review" | Audit evidence ready instantly |
| Users can have conflicting access | SoD violations blocked automatically |
| Term | Definition |
|---|---|
| Certification Campaign | A scheduled review where managers verify their team's access |
| Attestation | Formal confirmation that access is still required |
| Orphan Account | Account with no owner (e.g., creator left company) |
| Entitlement | A specific permission or group membership |
| SoD (Segregation of Duties) | Policy preventing toxic access combinations |
| Role Mining | Analyzing access patterns to create roles |
| Birthright Access | Default access given based on job role |
IGA helps meet requirements in: SOX (financial controls), HIPAA (healthcare), PCI-DSS (payment cards), SOC2 (service orgs), GDPR (data privacy), ISO 27001 (security management)
What you need before starting
# Create project directory
mkdir -p ~/identity-governance
cd ~/identity-governance
# Create virtual environment
python3 -m venv venv
source venv/bin/activate
# Install dependencies
pip install requests pandas jinja2 schedule python-dotenv pyyaml
Automated access review campaigns
Generate campaigns, collect manager decisions, execute actions, produce audit evidence!
cat > ~/identity-governance/certification_engine.py << 'PYEOF'
#!/usr/bin/env python3
"""
Access Certification Engine
Generates certification campaigns, tracks decisions, and executes actions.
Equivalent to Saviynt/SailPoint certification campaigns.
"""
import os
import json
import requests
from datetime import datetime, timedelta
from dataclasses import dataclass, asdict
from typing import List, Dict, Optional
from enum import Enum
import uuid
# Configuration
AUTHENTIK_URL = os.getenv('AUTHENTIK_URL', 'https://authentik.yourdomain.com')
AUTHENTIK_TOKEN = os.getenv('AUTHENTIK_TOKEN', 'your-api-token')
class CertificationStatus(Enum):
PENDING = "pending"
CERTIFIED = "certified"
REVOKED = "revoked"
EXPIRED = "expired"
class CampaignStatus(Enum):
DRAFT = "draft"
ACTIVE = "active"
COMPLETED = "completed"
CANCELLED = "cancelled"
@dataclass
class AccessItem:
"""Represents a single access entitlement to be reviewed."""
id: str
user_id: str
user_name: str
user_email: str
entitlement_type: str # group, role, application
entitlement_name: str
entitlement_id: str
granted_date: str
last_used: Optional[str]
reviewer_id: str
reviewer_name: str
status: str = CertificationStatus.PENDING.value
decision_date: Optional[str] = None
decision_comment: Optional[str] = None
@dataclass
class CertificationCampaign:
"""Represents a certification campaign."""
id: str
name: str
description: str
created_date: str
due_date: str
status: str
created_by: str
items: List[AccessItem]
@property
def total_items(self) -> int:
return len(self.items)
@property
def certified_count(self) -> int:
return len([i for i in self.items if i.status == CertificationStatus.CERTIFIED.value])
@property
def revoked_count(self) -> int:
return len([i for i in self.items if i.status == CertificationStatus.REVOKED.value])
@property
def pending_count(self) -> int:
return len([i for i in self.items if i.status == CertificationStatus.PENDING.value])
@property
def completion_percentage(self) -> float:
if self.total_items == 0:
return 100.0
return ((self.total_items - self.pending_count) / self.total_items) * 100
class AuthentikClient:
"""Client for Authentik API interactions."""
def __init__(self):
self.base_url = AUTHENTIK_URL
self.headers = {
'Authorization': f'Bearer {AUTHENTIK_TOKEN}',
'Content-Type': 'application/json'
}
def get_users(self) -> List[Dict]:
"""Get all users from Authentik."""
response = requests.get(
f'{self.base_url}/api/v3/core/users/',
headers=self.headers
)
response.raise_for_status()
return response.json()['results']
def get_groups(self) -> List[Dict]:
"""Get all groups from Authentik."""
response = requests.get(
f'{self.base_url}/api/v3/core/groups/',
headers=self.headers
)
response.raise_for_status()
return response.json()['results']
def get_user_groups(self, user_pk: str) -> List[Dict]:
"""Get groups for a specific user."""
response = requests.get(
f'{self.base_url}/api/v3/core/users/{user_pk}/',
headers=self.headers
)
response.raise_for_status()
user_data = response.json()
return user_data.get('groups_obj', [])
def remove_user_from_group(self, user_pk: str, group_pk: str) -> bool:
"""Remove a user from a group (revoke access)."""
# Get current user groups
response = requests.get(
f'{self.base_url}/api/v3/core/users/{user_pk}/',
headers=self.headers
)
user_data = response.json()
current_groups = user_data.get('groups', [])
# Remove the specified group
new_groups = [g for g in current_groups if g != group_pk]
# Update user
response = requests.patch(
f'{self.base_url}/api/v3/core/users/{user_pk}/',
headers=self.headers,
json={'groups': new_groups}
)
return response.status_code == 200
class CertificationEngine:
"""Main certification engine."""
def __init__(self):
self.client = AuthentikClient()
self.campaigns: Dict[str, CertificationCampaign] = {}
self.storage_path = os.path.expanduser('~/identity-governance/campaigns')
os.makedirs(self.storage_path, exist_ok=True)
def create_campaign(
self,
name: str,
description: str,
due_days: int = 14,
scope: str = 'all_users' # all_users, group, application
) -> CertificationCampaign:
"""Create a new certification campaign."""
campaign_id = str(uuid.uuid4())[:8]
created_date = datetime.now().isoformat()
due_date = (datetime.now() + timedelta(days=due_days)).isoformat()
# Generate access items based on scope
items = self._generate_access_items()
campaign = CertificationCampaign(
id=campaign_id,
name=name,
description=description,
created_date=created_date,
due_date=due_date,
status=CampaignStatus.ACTIVE.value,
created_by='system',
items=items
)
self.campaigns[campaign_id] = campaign
self._save_campaign(campaign)
print(f"✅ Campaign '{name}' created with {len(items)} items to review")
print(f" Campaign ID: {campaign_id}")
print(f" Due Date: {due_date[:10]}")
return campaign
def _generate_access_items(self) -> List[AccessItem]:
"""Generate access items for review."""
items = []
users = self.client.get_users()
for user in users:
# Skip service accounts
if user.get('type') == 'service_account':
continue
# Get user's groups
groups = self.client.get_user_groups(user['pk'])
# Get manager (simplified - using first admin as reviewer)
reviewer_id = 'admin'
reviewer_name = 'Administrator'
for group in groups:
item = AccessItem(
id=str(uuid.uuid4())[:8],
user_id=str(user['pk']),
user_name=user.get('name', user['username']),
user_email=user.get('email', ''),
entitlement_type='group',
entitlement_name=group.get('name', ''),
entitlement_id=str(group.get('pk', '')),
granted_date=user.get('date_joined', '')[:10],
last_used=None,
reviewer_id=reviewer_id,
reviewer_name=reviewer_name
)
items.append(item)
return items
def certify_access(self, campaign_id: str, item_id: str, comment: str = "") -> bool:
"""Certify (approve) an access item."""
campaign = self.campaigns.get(campaign_id)
if not campaign:
print(f"❌ Campaign {campaign_id} not found")
return False
for item in campaign.items:
if item.id == item_id:
item.status = CertificationStatus.CERTIFIED.value
item.decision_date = datetime.now().isoformat()
item.decision_comment = comment
self._save_campaign(campaign)
print(f"✅ Certified: {item.user_name} -> {item.entitlement_name}")
return True
return False
def revoke_access(self, campaign_id: str, item_id: str, comment: str = "") -> bool:
"""Revoke an access item."""
campaign = self.campaigns.get(campaign_id)
if not campaign:
print(f"❌ Campaign {campaign_id} not found")
return False
for item in campaign.items:
if item.id == item_id:
# Execute revocation in Authentik
success = self.client.remove_user_from_group(
item.user_id,
item.entitlement_id
)
if success:
item.status = CertificationStatus.REVOKED.value
item.decision_date = datetime.now().isoformat()
item.decision_comment = comment
self._save_campaign(campaign)
print(f"🚫 Revoked: {item.user_name} -> {item.entitlement_name}")
return True
else:
print(f"❌ Failed to revoke access in Authentik")
return False
return False
def get_campaign_status(self, campaign_id: str) -> Dict:
"""Get campaign status and statistics."""
campaign = self.campaigns.get(campaign_id)
if not campaign:
return {}
return {
'id': campaign.id,
'name': campaign.name,
'status': campaign.status,
'due_date': campaign.due_date,
'total_items': campaign.total_items,
'certified': campaign.certified_count,
'revoked': campaign.revoked_count,
'pending': campaign.pending_count,
'completion': f"{campaign.completion_percentage:.1f}%"
}
def get_pending_items(self, campaign_id: str, reviewer_id: str = None) -> List[Dict]:
"""Get pending items for review."""
campaign = self.campaigns.get(campaign_id)
if not campaign:
return []
pending = [
asdict(item) for item in campaign.items
if item.status == CertificationStatus.PENDING.value
]
if reviewer_id:
pending = [i for i in pending if i['reviewer_id'] == reviewer_id]
return pending
def _save_campaign(self, campaign: CertificationCampaign):
"""Save campaign to disk."""
filepath = os.path.join(self.storage_path, f'{campaign.id}.json')
data = {
'id': campaign.id,
'name': campaign.name,
'description': campaign.description,
'created_date': campaign.created_date,
'due_date': campaign.due_date,
'status': campaign.status,
'created_by': campaign.created_by,
'items': [asdict(item) for item in campaign.items]
}
with open(filepath, 'w') as f:
json.dump(data, f, indent=2)
def load_campaigns(self):
"""Load campaigns from disk."""
for filename in os.listdir(self.storage_path):
if filename.endswith('.json'):
filepath = os.path.join(self.storage_path, filename)
with open(filepath) as f:
data = json.load(f)
items = [AccessItem(**item) for item in data['items']]
campaign = CertificationCampaign(
id=data['id'],
name=data['name'],
description=data['description'],
created_date=data['created_date'],
due_date=data['due_date'],
status=data['status'],
created_by=data['created_by'],
items=items
)
self.campaigns[campaign.id] = campaign
def main():
"""Demo the certification engine."""
engine = CertificationEngine()
# Create a new campaign
print("\n" + "="*50)
print("Creating Q4 Access Certification Campaign")
print("="*50)
campaign = engine.create_campaign(
name="Q4 2024 Access Review",
description="Quarterly access certification for all users",
due_days=14
)
# Show campaign status
print("\n" + "="*50)
print("Campaign Status")
print("="*50)
status = engine.get_campaign_status(campaign.id)
for key, value in status.items():
print(f" {key}: {value}")
# Show pending items
print("\n" + "="*50)
print("Pending Items for Review")
print("="*50)
pending = engine.get_pending_items(campaign.id)
for item in pending[:5]: # Show first 5
print(f" [{item['id']}] {item['user_name']} -> {item['entitlement_name']}")
if len(pending) > 5:
print(f" ... and {len(pending) - 5} more items")
if __name__ == '__main__':
main()
PYEOF
chmod +x ~/identity-governance/certification_engine.py
cat > ~/identity-governance/.env << 'EOF'
AUTHENTIK_URL=https://authentik.yourdomain.com
AUTHENTIK_TOKEN=your-api-token-here
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
EOF
# Load environment
source ~/identity-governance/.env
cd ~/identity-governance
source venv/bin/activate
source .env
# Run certification engine
python certification_engine.py
# Output example:
# ==================================================
# Creating Q4 Access Certification Campaign
# ==================================================
# ✅ Campaign 'Q4 2024 Access Review' created with 15 items
# Campaign ID: a1b2c3d4
# Due Date: 2024-12-15
You can now create campaigns, review access, and generate audit evidence!
Joiner-Mover-Leaver automation
Automatically provision access when people join, change roles, or leave!
cat > ~/identity-governance/lifecycle_engine.py << 'PYEOF'
#!/usr/bin/env python3
"""
JML (Joiner-Mover-Leaver) Lifecycle Engine
Automates access provisioning and deprovisioning based on HR events.
"""
import os
import json
import requests
from datetime import datetime
from typing import Dict, List, Optional
from dataclasses import dataclass
from enum import Enum
AUTHENTIK_URL = os.getenv('AUTHENTIK_URL', 'https://authentik.yourdomain.com')
AUTHENTIK_TOKEN = os.getenv('AUTHENTIK_TOKEN')
class LifecycleEvent(Enum):
JOINER = "joiner"
MOVER = "mover"
LEAVER = "leaver"
@dataclass
class RoleDefinition:
"""Defines birthright access for a role."""
role_name: str
department: str
groups: List[str]
applications: List[str]
@dataclass
class LifecycleAction:
"""Tracks a lifecycle action."""
event_type: str
user_id: str
user_name: str
timestamp: str
old_role: Optional[str]
new_role: Optional[str]
actions_taken: List[str]
class LifecycleEngine:
"""Manages identity lifecycle events."""
def __init__(self):
self.headers = {
'Authorization': f'Bearer {AUTHENTIK_TOKEN}',
'Content-Type': 'application/json'
}
self.role_definitions = self._load_role_definitions()
self.audit_log: List[LifecycleAction] = []
def _load_role_definitions(self) -> Dict[str, RoleDefinition]:
"""Load role-based access definitions."""
# In production, load from database or config file
return {
'developer': RoleDefinition(
role_name='developer',
department='Engineering',
groups=['developers', 'gitlab-users', 'jira-users'],
applications=['GitLab', 'Jira', 'Confluence']
),
'analyst': RoleDefinition(
role_name='analyst',
department='Analytics',
groups=['analysts', 'tableau-users', 'jira-users'],
applications=['Tableau', 'Jira', 'PowerBI']
),
'manager': RoleDefinition(
role_name='manager',
department='Management',
groups=['managers', 'jira-users', 'expense-approvers'],
applications=['Jira', 'Workday', 'Concur']
),
'admin': RoleDefinition(
role_name='admin',
department='IT',
groups=['admins', 'it-support', 'all-apps'],
applications=['All']
)
}
def process_joiner(self, user_data: Dict) -> LifecycleAction:
"""Process a new hire (Joiner)."""
print(f"\n👋 Processing JOINER: {user_data['name']}")
actions = []
role = user_data.get('role', 'default')
role_def = self.role_definitions.get(role)
# Create user in Authentik
user_pk = self._create_user(user_data)
if user_pk:
actions.append(f"Created user account: {user_data['username']}")
# Assign birthright access based on role
if role_def:
for group_name in role_def.groups:
if self._add_to_group(user_pk, group_name):
actions.append(f"Added to group: {group_name}")
# Send welcome notification
actions.append("Sent welcome email")
action = LifecycleAction(
event_type=LifecycleEvent.JOINER.value,
user_id=user_pk,
user_name=user_data['name'],
timestamp=datetime.now().isoformat(),
old_role=None,
new_role=role,
actions_taken=actions
)
self.audit_log.append(action)
self._print_actions(actions)
return action
def process_mover(self, user_id: str, old_role: str, new_role: str) -> LifecycleAction:
"""Process a role change (Mover)."""
user_name = self._get_user_name(user_id)
print(f"\n🔄 Processing MOVER: {user_name} ({old_role} → {new_role})")
actions = []
old_role_def = self.role_definitions.get(old_role)
new_role_def = self.role_definitions.get(new_role)
# Revoke old role access
if old_role_def:
for group_name in old_role_def.groups:
# Don't remove if still needed in new role
if new_role_def and group_name in new_role_def.groups:
continue
if self._remove_from_group(user_id, group_name):
actions.append(f"Removed from group: {group_name}")
# Grant new role access
if new_role_def:
for group_name in new_role_def.groups:
if old_role_def and group_name in old_role_def.groups:
continue # Already has access
if self._add_to_group(user_id, group_name):
actions.append(f"Added to group: {group_name}")
action = LifecycleAction(
event_type=LifecycleEvent.MOVER.value,
user_id=user_id,
user_name=user_name,
timestamp=datetime.now().isoformat(),
old_role=old_role,
new_role=new_role,
actions_taken=actions
)
self.audit_log.append(action)
self._print_actions(actions)
return action
def process_leaver(self, user_id: str, immediate: bool = False) -> LifecycleAction:
"""Process a termination (Leaver)."""
user_name = self._get_user_name(user_id)
print(f"\n👋 Processing LEAVER: {user_name}")
actions = []
if immediate:
# Immediate termination - disable account now
if self._disable_user(user_id):
actions.append("Account DISABLED immediately")
# Remove all group memberships
groups_removed = self._remove_all_groups(user_id)
actions.append(f"Removed from {groups_removed} groups")
# Revoke all sessions
if self._revoke_sessions(user_id):
actions.append("All active sessions revoked")
else:
# Graceful offboarding - scheduled for end of day
actions.append("Account scheduled for disable at EOD")
actions.append("Manager notified for knowledge transfer")
action = LifecycleAction(
event_type=LifecycleEvent.LEAVER.value,
user_id=user_id,
user_name=user_name,
timestamp=datetime.now().isoformat(),
old_role=None,
new_role=None,
actions_taken=actions
)
self.audit_log.append(action)
self._print_actions(actions)
return action
def _create_user(self, user_data: Dict) -> Optional[str]:
"""Create user in Authentik."""
payload = {
'username': user_data['username'],
'name': user_data['name'],
'email': user_data['email'],
'is_active': True,
'groups': []
}
try:
response = requests.post(
f'{AUTHENTIK_URL}/api/v3/core/users/',
headers=self.headers,
json=payload
)
if response.status_code == 201:
return str(response.json()['pk'])
except Exception as e:
print(f" ⚠️ Error creating user: {e}")
return None
def _add_to_group(self, user_id: str, group_name: str) -> bool:
"""Add user to a group."""
# First, find group by name
group_pk = self._get_group_pk(group_name)
if not group_pk:
print(f" ⚠️ Group not found: {group_name}")
return False
try:
# Get current groups
response = requests.get(
f'{AUTHENTIK_URL}/api/v3/core/users/{user_id}/',
headers=self.headers
)
current_groups = response.json().get('groups', [])
if group_pk not in current_groups:
current_groups.append(group_pk)
response = requests.patch(
f'{AUTHENTIK_URL}/api/v3/core/users/{user_id}/',
headers=self.headers,
json={'groups': current_groups}
)
return response.status_code == 200
except Exception as e:
print(f" ⚠️ Error adding to group: {e}")
return False
def _remove_from_group(self, user_id: str, group_name: str) -> bool:
"""Remove user from a group."""
group_pk = self._get_group_pk(group_name)
if not group_pk:
return False
try:
response = requests.get(
f'{AUTHENTIK_URL}/api/v3/core/users/{user_id}/',
headers=self.headers
)
current_groups = response.json().get('groups', [])
if group_pk in current_groups:
current_groups.remove(group_pk)
response = requests.patch(
f'{AUTHENTIK_URL}/api/v3/core/users/{user_id}/',
headers=self.headers,
json={'groups': current_groups}
)
return response.status_code == 200
except Exception as e:
print(f" ⚠️ Error removing from group: {e}")
return False
def _remove_all_groups(self, user_id: str) -> int:
"""Remove user from all groups."""
try:
response = requests.get(
f'{AUTHENTIK_URL}/api/v3/core/users/{user_id}/',
headers=self.headers
)
current_groups = response.json().get('groups', [])
count = len(current_groups)
response = requests.patch(
f'{AUTHENTIK_URL}/api/v3/core/users/{user_id}/',
headers=self.headers,
json={'groups': []}
)
return count if response.status_code == 200 else 0
except:
return 0
def _disable_user(self, user_id: str) -> bool:
"""Disable user account."""
try:
response = requests.patch(
f'{AUTHENTIK_URL}/api/v3/core/users/{user_id}/',
headers=self.headers,
json={'is_active': False}
)
return response.status_code == 200
except:
return False
def _revoke_sessions(self, user_id: str) -> bool:
"""Revoke all user sessions."""
# Authentik doesn't have direct session revoke API
# In practice, disabling user + password change achieves this
return True
def _get_group_pk(self, group_name: str) -> Optional[str]:
"""Get group PK by name."""
try:
response = requests.get(
f'{AUTHENTIK_URL}/api/v3/core/groups/?name={group_name}',
headers=self.headers
)
results = response.json().get('results', [])
if results:
return str(results[0]['pk'])
except:
pass
return None
def _get_user_name(self, user_id: str) -> str:
"""Get user's display name."""
try:
response = requests.get(
f'{AUTHENTIK_URL}/api/v3/core/users/{user_id}/',
headers=self.headers
)
return response.json().get('name', 'Unknown')
except:
return 'Unknown'
def _print_actions(self, actions: List[str]):
"""Print actions taken."""
for action in actions:
print(f" ✓ {action}")
def get_audit_log(self) -> List[Dict]:
"""Get audit log of all lifecycle events."""
return [
{
'event_type': a.event_type,
'user_id': a.user_id,
'user_name': a.user_name,
'timestamp': a.timestamp,
'old_role': a.old_role,
'new_role': a.new_role,
'actions': a.actions_taken
}
for a in self.audit_log
]
def main():
"""Demo lifecycle engine."""
engine = LifecycleEngine()
print("\n" + "="*60)
print("JML LIFECYCLE ENGINE DEMO")
print("="*60)
# Simulate JOINER
new_hire = {
'username': 'jsmith',
'name': 'John Smith',
'email': 'jsmith@company.com',
'role': 'developer'
}
engine.process_joiner(new_hire)
# Simulate MOVER (promotion)
# engine.process_mover('user-id-here', 'developer', 'manager')
# Simulate LEAVER
# engine.process_leaver('user-id-here', immediate=True)
# Print audit log
print("\n" + "="*60)
print("AUDIT LOG")
print("="*60)
for entry in engine.get_audit_log():
print(f"\n Event: {entry['event_type'].upper()}")
print(f" User: {entry['user_name']}")
print(f" Time: {entry['timestamp']}")
print(f" Actions: {len(entry['actions'])} performed")
if __name__ == '__main__':
main()
PYEOF
In enterprise, this would connect to Workday, SAP SuccessFactors, or BambooHR via API. Events trigger automatically when HR updates employee records.
Find accounts without owners
Find disabled users with active access, accounts without owners, service accounts without contacts!
cat > ~/identity-governance/orphan_detector.py << 'PYEOF'
#!/usr/bin/env python3
"""
Orphan Account Detector
Identifies accounts that may need attention:
- Disabled users with active group memberships
- Users without recent login
- Service accounts without owners
- Groups without members
"""
import os
import requests
from datetime import datetime, timedelta
from typing import List, Dict
from dataclasses import dataclass
AUTHENTIK_URL = os.getenv('AUTHENTIK_URL', 'https://authentik.yourdomain.com')
AUTHENTIK_TOKEN = os.getenv('AUTHENTIK_TOKEN')
@dataclass
class OrphanFinding:
"""Represents an orphan account finding."""
finding_type: str
severity: str # high, medium, low
entity_type: str # user, group, service_account
entity_id: str
entity_name: str
description: str
recommendation: str
class OrphanDetector:
"""Detects orphan accounts and access."""
def __init__(self):
self.headers = {
'Authorization': f'Bearer {AUTHENTIK_TOKEN}',
'Content-Type': 'application/json'
}
self.findings: List[OrphanFinding] = []
def run_full_scan(self) -> List[OrphanFinding]:
"""Run all orphan detection checks."""
print("\n" + "="*60)
print("🔍 ORPHAN ACCOUNT SCAN")
print("="*60)
self.findings = []
# Run all checks
self._check_disabled_with_access()
self._check_inactive_users()
self._check_empty_groups()
self._check_users_without_groups()
self._check_service_accounts()
return self.findings
def _check_disabled_with_access(self):
"""Find disabled users that still have group memberships."""
print("\n📋 Checking disabled users with active access...")
users = self._get_all_users()
for user in users:
if not user.get('is_active', True):
groups = user.get('groups_obj', [])
if groups:
finding = OrphanFinding(
finding_type="disabled_with_access",
severity="high",
entity_type="user",
entity_id=str(user['pk']),
entity_name=user.get('username', ''),
description=f"Disabled user still has {len(groups)} group memberships",
recommendation="Remove all group memberships from disabled account"
)
self.findings.append(finding)
print(f" ⚠️ {user['username']}: {len(groups)} groups")
def _check_inactive_users(self, days: int = 90):
"""Find users who haven't logged in recently."""
print(f"\n📋 Checking users inactive for {days}+ days...")
users = self._get_all_users()
cutoff = datetime.now() - timedelta(days=days)
for user in users:
if not user.get('is_active', True):
continue # Skip already disabled
last_login = user.get('last_login')
if last_login:
last_login_dt = datetime.fromisoformat(last_login.replace('Z', '+00:00'))
if last_login_dt.replace(tzinfo=None) < cutoff:
finding = OrphanFinding(
finding_type="inactive_user",
severity="medium",
entity_type="user",
entity_id=str(user['pk']),
entity_name=user.get('username', ''),
description=f"Last login: {last_login[:10]}",
recommendation="Verify user still requires access or disable"
)
self.findings.append(finding)
print(f" ⚠️ {user['username']}: last login {last_login[:10]}")
elif user.get('date_joined'):
# Never logged in
finding = OrphanFinding(
finding_type="never_logged_in",
severity="medium",
entity_type="user",
entity_id=str(user['pk']),
entity_name=user.get('username', ''),
description="User has never logged in",
recommendation="Contact user or disable account"
)
self.findings.append(finding)
print(f" ⚠️ {user['username']}: never logged in")
def _check_empty_groups(self):
"""Find groups with no members."""
print("\n📋 Checking empty groups...")
groups = self._get_all_groups()
for group in groups:
member_count = group.get('num_pk', 0)
if member_count == 0:
finding = OrphanFinding(
finding_type="empty_group",
severity="low",
entity_type="group",
entity_id=str(group['pk']),
entity_name=group.get('name', ''),
description="Group has no members",
recommendation="Delete if no longer needed, or document purpose"
)
self.findings.append(finding)
print(f" ℹ️ Group '{group['name']}': 0 members")
def _check_users_without_groups(self):
"""Find active users with no group memberships."""
print("\n📋 Checking users without any groups...")
users = self._get_all_users()
for user in users:
if not user.get('is_active', True):
continue
# Skip service accounts and admins
if user.get('type') == 'service_account':
continue
if user.get('is_superuser'):
continue
groups = user.get('groups_obj', [])
if not groups:
finding = OrphanFinding(
finding_type="no_group_membership",
severity="low",
entity_type="user",
entity_id=str(user['pk']),
entity_name=user.get('username', ''),
description="User has no group memberships",
recommendation="Assign to appropriate groups based on role"
)
self.findings.append(finding)
print(f" ℹ️ {user['username']}: no groups")
def _check_service_accounts(self):
"""Find service accounts without owner documentation."""
print("\n📋 Checking service accounts...")
users = self._get_all_users()
for user in users:
if user.get('type') == 'service_account':
# Check if owner is documented in attributes
attributes = user.get('attributes', {})
if not attributes.get('owner'):
finding = OrphanFinding(
finding_type="unowned_service_account",
severity="high",
entity_type="service_account",
entity_id=str(user['pk']),
entity_name=user.get('username', ''),
description="Service account has no documented owner",
recommendation="Document owner and purpose in attributes"
)
self.findings.append(finding)
print(f" ⚠️ Service account '{user['username']}': no owner")
def _get_all_users(self) -> List[Dict]:
"""Get all users from Authentik."""
try:
response = requests.get(
f'{AUTHENTIK_URL}/api/v3/core/users/',
headers=self.headers,
params={'page_size': 500}
)
return response.json().get('results', [])
except Exception as e:
print(f"Error fetching users: {e}")
return []
def _get_all_groups(self) -> List[Dict]:
"""Get all groups from Authentik."""
try:
response = requests.get(
f'{AUTHENTIK_URL}/api/v3/core/groups/',
headers=self.headers,
params={'page_size': 500}
)
return response.json().get('results', [])
except Exception as e:
print(f"Error fetching groups: {e}")
return []
def generate_report(self) -> str:
"""Generate findings report."""
report = []
report.append("\n" + "="*60)
report.append("ORPHAN ACCOUNT REPORT")
report.append(f"Generated: {datetime.now().isoformat()}")
report.append("="*60)
# Summary
high = len([f for f in self.findings if f.severity == 'high'])
medium = len([f for f in self.findings if f.severity == 'medium'])
low = len([f for f in self.findings if f.severity == 'low'])
report.append(f"\n📊 SUMMARY")
report.append(f" 🔴 High Severity: {high}")
report.append(f" 🟡 Medium Severity: {medium}")
report.append(f" 🟢 Low Severity: {low}")
report.append(f" Total Findings: {len(self.findings)}")
# Details by severity
for severity in ['high', 'medium', 'low']:
findings = [f for f in self.findings if f.severity == severity]
if findings:
icon = {'high': '🔴', 'medium': '🟡', 'low': '🟢'}[severity]
report.append(f"\n{icon} {severity.upper()} SEVERITY FINDINGS:")
for f in findings:
report.append(f"\n [{f.finding_type}] {f.entity_name}")
report.append(f" Description: {f.description}")
report.append(f" Recommendation: {f.recommendation}")
return '\n'.join(report)
def main():
detector = OrphanDetector()
detector.run_full_scan()
report = detector.generate_report()
print(report)
# Save report
with open(os.path.expanduser('~/identity-governance/orphan_report.txt'), 'w') as f:
f.write(report)
print("\n📄 Report saved to ~/identity-governance/orphan_report.txt")
if __name__ == '__main__':
main()
PYEOF
Run this weekly via cron: 0 8 * * 1 python orphan_detector.py
Prevent toxic access combinations
Detect when someone has both "create invoice" AND "approve payment" access!
cat > ~/identity-governance/sod_engine.py << 'PYEOF'
#!/usr/bin/env python3
"""
Segregation of Duties (SoD) Policy Engine
Detects and prevents toxic access combinations.
"""
import os
import requests
from datetime import datetime
from typing import List, Dict, Tuple
from dataclasses import dataclass
AUTHENTIK_URL = os.getenv('AUTHENTIK_URL', 'https://authentik.yourdomain.com')
AUTHENTIK_TOKEN = os.getenv('AUTHENTIK_TOKEN')
@dataclass
class SoDPolicy:
"""Defines a Segregation of Duties policy."""
id: str
name: str
description: str
group_a: str # Conflicting group 1
group_b: str # Conflicting group 2
severity: str # critical, high, medium
action: str # alert, block
@dataclass
class SoDViolation:
"""Represents a SoD violation."""
policy_id: str
policy_name: str
user_id: str
user_name: str
group_a: str
group_b: str
severity: str
detected_at: str
class SoDEngine:
"""Manages Segregation of Duties policies and detection."""
def __init__(self):
self.headers = {
'Authorization': f'Bearer {AUTHENTIK_TOKEN}',
'Content-Type': 'application/json'
}
self.policies = self._load_policies()
self.violations: List[SoDViolation] = []
def _load_policies(self) -> List[SoDPolicy]:
"""Load SoD policies (from config in production)."""
return [
SoDPolicy(
id="SOD-001",
name="Finance: Create vs Approve",
description="Cannot both create and approve financial transactions",
group_a="finance-creators",
group_b="finance-approvers",
severity="critical",
action="block"
),
SoDPolicy(
id="SOD-002",
name="IT: Dev vs Prod",
description="Developers should not have production admin access",
group_a="developers",
group_b="prod-admins",
severity="high",
action="alert"
),
SoDPolicy(
id="SOD-003",
name="HR: Data vs Payroll",
description="HR data access separate from payroll processing",
group_a="hr-data-access",
group_b="payroll-processors",
severity="high",
action="block"
),
SoDPolicy(
id="SOD-004",
name="Security: Audit vs Admin",
description="Security auditors should not be system admins",
group_a="security-auditors",
group_b="system-admins",
severity="critical",
action="alert"
)
]
def check_all_users(self) -> List[SoDViolation]:
"""Check all users for SoD violations."""
print("\n" + "="*60)
print("🔍 SEGREGATION OF DUTIES SCAN")
print("="*60)
self.violations = []
users = self._get_all_users()
for user in users:
if not user.get('is_active', True):
continue
user_groups = [g['name'] for g in user.get('groups_obj', [])]
for policy in self.policies:
if policy.group_a in user_groups and policy.group_b in user_groups:
violation = SoDViolation(
policy_id=policy.id,
policy_name=policy.name,
user_id=str(user['pk']),
user_name=user.get('name', user['username']),
group_a=policy.group_a,
group_b=policy.group_b,
severity=policy.severity,
detected_at=datetime.now().isoformat()
)
self.violations.append(violation)
self._print_violations()
return self.violations
def check_user(self, user_id: str) -> List[SoDViolation]:
"""Check a specific user for SoD violations (use before granting access)."""
violations = []
try:
response = requests.get(
f'{AUTHENTIK_URL}/api/v3/core/users/{user_id}/',
headers=self.headers
)
user = response.json()
user_groups = [g['name'] for g in user.get('groups_obj', [])]
for policy in self.policies:
if policy.group_a in user_groups and policy.group_b in user_groups:
violation = SoDViolation(
policy_id=policy.id,
policy_name=policy.name,
user_id=user_id,
user_name=user.get('name', user['username']),
group_a=policy.group_a,
group_b=policy.group_b,
severity=policy.severity,
detected_at=datetime.now().isoformat()
)
violations.append(violation)
except Exception as e:
print(f"Error checking user: {e}")
return violations
def would_violate(self, user_id: str, new_group: str) -> List[SoDPolicy]:
"""Check if adding user to a group would cause SoD violation."""
violating_policies = []
try:
response = requests.get(
f'{AUTHENTIK_URL}/api/v3/core/users/{user_id}/',
headers=self.headers
)
user = response.json()
current_groups = [g['name'] for g in user.get('groups_obj', [])]
# Check if adding new_group would violate any policy
for policy in self.policies:
if new_group == policy.group_a and policy.group_b in current_groups:
violating_policies.append(policy)
elif new_group == policy.group_b and policy.group_a in current_groups:
violating_policies.append(policy)
except Exception as e:
print(f"Error checking: {e}")
return violating_policies
def _get_all_users(self) -> List[Dict]:
"""Get all users from Authentik."""
try:
response = requests.get(
f'{AUTHENTIK_URL}/api/v3/core/users/',
headers=self.headers,
params={'page_size': 500}
)
return response.json().get('results', [])
except:
return []
def _print_violations(self):
"""Print detected violations."""
if not self.violations:
print("\n✅ No SoD violations detected!")
return
print(f"\n⚠️ Found {len(self.violations)} SoD violations:\n")
for v in self.violations:
icon = '🔴' if v.severity == 'critical' else '🟡' if v.severity == 'high' else '🟢'
print(f"{icon} [{v.policy_id}] {v.user_name}")
print(f" Policy: {v.policy_name}")
print(f" Conflict: {v.group_a} + {v.group_b}")
print()
def generate_report(self) -> str:
"""Generate SoD violations report."""
lines = []
lines.append("\n" + "="*60)
lines.append("SEGREGATION OF DUTIES REPORT")
lines.append(f"Generated: {datetime.now().isoformat()}")
lines.append("="*60)
# Policy summary
lines.append("\n📋 ACTIVE POLICIES:")
for p in self.policies:
lines.append(f" [{p.id}] {p.name} ({p.severity})")
# Violations summary
critical = len([v for v in self.violations if v.severity == 'critical'])
high = len([v for v in self.violations if v.severity == 'high'])
lines.append(f"\n📊 VIOLATIONS SUMMARY:")
lines.append(f" 🔴 Critical: {critical}")
lines.append(f" 🟡 High: {high}")
lines.append(f" Total: {len(self.violations)}")
# Violation details
if self.violations:
lines.append("\n⚠️ VIOLATION DETAILS:")
for v in self.violations:
lines.append(f"\n User: {v.user_name}")
lines.append(f" Policy: {v.policy_name}")
lines.append(f" Conflicting Groups: {v.group_a} + {v.group_b}")
lines.append(f" Severity: {v.severity.upper()}")
return '\n'.join(lines)
def main():
engine = SoDEngine()
engine.check_all_users()
report = engine.generate_report()
print(report)
# Save report
with open(os.path.expanduser('~/identity-governance/sod_report.txt'), 'w') as f:
f.write(report)
if __name__ == '__main__':
main()
PYEOF
Detective: Find existing violations (this script)
Preventive: Block violations before they happen (integrate with access request workflow)
Access requests with manager approval
Users request access, managers approve, system provisions automatically!
Authentik supports approval workflows through its Flows feature. Here's how to configure them:
cat > ~/identity-governance/access_request.py << 'PYEOF'
#!/usr/bin/env python3
"""
Access Request Workflow System
Handles access requests with approval tracking.
"""
import os
import json
import uuid
from datetime import datetime
from dataclasses import dataclass, asdict
from typing import List, Optional
from enum import Enum
class RequestStatus(Enum):
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
PROVISIONED = "provisioned"
EXPIRED = "expired"
@dataclass
class AccessRequest:
"""Represents an access request."""
id: str
requestor_id: str
requestor_name: str
requestor_email: str
requested_access: str # Group or application name
access_type: str # group, application, role
justification: str
created_at: str
status: str
approver_id: Optional[str] = None
approver_name: Optional[str] = None
approved_at: Optional[str] = None
rejection_reason: Optional[str] = None
provisioned_at: Optional[str] = None
class AccessRequestWorkflow:
"""Manages access request workflow."""
def __init__(self):
self.storage_path = os.path.expanduser('~/identity-governance/requests')
os.makedirs(self.storage_path, exist_ok=True)
self.requests: dict[str, AccessRequest] = {}
self._load_requests()
def create_request(
self,
requestor_id: str,
requestor_name: str,
requestor_email: str,
requested_access: str,
access_type: str,
justification: str
) -> AccessRequest:
"""Create a new access request."""
request_id = f"REQ-{str(uuid.uuid4())[:8].upper()}"
request = AccessRequest(
id=request_id,
requestor_id=requestor_id,
requestor_name=requestor_name,
requestor_email=requestor_email,
requested_access=requested_access,
access_type=access_type,
justification=justification,
created_at=datetime.now().isoformat(),
status=RequestStatus.PENDING.value
)
self.requests[request_id] = request
self._save_request(request)
print(f"✅ Access request created: {request_id}")
print(f" Requestor: {requestor_name}")
print(f" Access: {requested_access}")
print(f" Status: PENDING APPROVAL")
# In production: Send email to approver
self._notify_approver(request)
return request
def approve_request(
self,
request_id: str,
approver_id: str,
approver_name: str
) -> bool:
"""Approve an access request."""
request = self.requests.get(request_id)
if not request:
print(f"❌ Request {request_id} not found")
return False
if request.status != RequestStatus.PENDING.value:
print(f"❌ Request {request_id} is not pending")
return False
# Check SoD before approving
from sod_engine import SoDEngine
sod = SoDEngine()
violations = sod.would_violate(request.requestor_id, request.requested_access)
if violations:
print(f"⚠️ Cannot approve - SoD violation would occur!")
for v in violations:
print(f" Policy: {v.name}")
return False
request.status = RequestStatus.APPROVED.value
request.approver_id = approver_id
request.approver_name = approver_name
request.approved_at = datetime.now().isoformat()
self._save_request(request)
print(f"✅ Request {request_id} APPROVED by {approver_name}")
# Auto-provision access
self._provision_access(request)
return True
def reject_request(
self,
request_id: str,
approver_id: str,
approver_name: str,
reason: str
) -> bool:
"""Reject an access request."""
request = self.requests.get(request_id)
if not request:
return False
request.status = RequestStatus.REJECTED.value
request.approver_id = approver_id
request.approver_name = approver_name
request.approved_at = datetime.now().isoformat()
request.rejection_reason = reason
self._save_request(request)
print(f"❌ Request {request_id} REJECTED")
print(f" Reason: {reason}")
# Notify requestor
self._notify_requestor_rejected(request)
return True
def _provision_access(self, request: AccessRequest):
"""Provision the approved access."""
import requests as http_requests
AUTHENTIK_URL = os.getenv('AUTHENTIK_URL')
AUTHENTIK_TOKEN = os.getenv('AUTHENTIK_TOKEN')
headers = {
'Authorization': f'Bearer {AUTHENTIK_TOKEN}',
'Content-Type': 'application/json'
}
if request.access_type == 'group':
# Get group PK
response = http_requests.get(
f'{AUTHENTIK_URL}/api/v3/core/groups/?name={request.requested_access}',
headers=headers
)
groups = response.json().get('results', [])
if not groups:
print(f" ⚠️ Group not found: {request.requested_access}")
return
group_pk = groups[0]['pk']
# Get user's current groups
response = http_requests.get(
f'{AUTHENTIK_URL}/api/v3/core/users/{request.requestor_id}/',
headers=headers
)
user = response.json()
current_groups = user.get('groups', [])
if group_pk not in current_groups:
current_groups.append(group_pk)
response = http_requests.patch(
f'{AUTHENTIK_URL}/api/v3/core/users/{request.requestor_id}/',
headers=headers,
json={'groups': current_groups}
)
if response.status_code == 200:
request.status = RequestStatus.PROVISIONED.value
request.provisioned_at = datetime.now().isoformat()
self._save_request(request)
print(f" ✅ Access provisioned!")
def _notify_approver(self, request: AccessRequest):
"""Send notification to approver."""
print(f" 📧 Notification sent to approver")
def _notify_requestor_rejected(self, request: AccessRequest):
"""Notify requestor of rejection."""
print(f" 📧 Rejection notification sent to {request.requestor_email}")
def get_pending_requests(self) -> List[dict]:
"""Get all pending requests."""
return [
asdict(r) for r in self.requests.values()
if r.status == RequestStatus.PENDING.value
]
def _save_request(self, request: AccessRequest):
"""Save request to disk."""
filepath = os.path.join(self.storage_path, f'{request.id}.json')
with open(filepath, 'w') as f:
json.dump(asdict(request), f, indent=2)
def _load_requests(self):
"""Load requests from disk."""
for filename in os.listdir(self.storage_path):
if filename.endswith('.json'):
filepath = os.path.join(self.storage_path, filename)
with open(filepath) as f:
data = json.load(f)
request = AccessRequest(**data)
self.requests[request.id] = request
def main():
"""Demo workflow."""
workflow = AccessRequestWorkflow()
print("\n" + "="*60)
print("ACCESS REQUEST WORKFLOW DEMO")
print("="*60)
# Create a request
request = workflow.create_request(
requestor_id="user-123",
requestor_name="John Smith",
requestor_email="jsmith@company.com",
requested_access="developers",
access_type="group",
justification="Need access for new project assignment"
)
# Show pending
print("\n📋 Pending Requests:")
for r in workflow.get_pending_requests():
print(f" [{r['id']}] {r['requestor_name']} → {r['requested_access']}")
if __name__ == '__main__':
main()
PYEOF
Audit-ready evidence generation
Generate compliance reports instantly for SOX, HIPAA, SOC2, and more!
| Report | Purpose | Frequency |
|---|---|---|
| Access Certification Summary | Prove access reviews completed | Quarterly |
| Orphan Account Report | Show no unowned accounts | Monthly |
| SoD Violations Report | Document toxic access controls | Weekly |
| Access Request Audit Trail | Show approval workflow evidence | On-demand |
| Privileged Access Report | List all admin accounts | Monthly |
| JML Activity Log | Track provisioning/deprovisioning | Daily |
Verify your IGA implementation
cd ~/identity-governance
source venv/bin/activate
source .env
# Run certification engine
python certification_engine.py
# Run orphan detection
python orphan_detector.py
# Run SoD check
python sod_engine.py
# Run lifecycle demo
python lifecycle_engine.py
# Run access request demo
python access_request.py
Your IGA framework is operational!
You've built enterprise IGA capabilities with Python and Authentik!
| Skill | Enterprise Platform | Your Implementation |
|---|---|---|
| Access Certification | Saviynt / SailPoint | Python Certification Engine |
| JML Lifecycle | Saviynt / Workday | Python Lifecycle Engine |
| Orphan Detection | SailPoint IdentityNow | Python Orphan Detector |
| SoD Policies | Saviynt / SAP GRC | Python SoD Engine |
| Access Requests | ServiceNow / Saviynt | Python Workflow System |
"I've implemented a complete Identity Governance framework including access certification campaigns, JML lifecycle automation, orphan account detection, and SoD policy enforcement. While the enterprise platforms like Saviynt have more features, the core concepts and patterns are identical."