Identity Governance

IGA | Access Certification | Lifecycle Management

Certification JML Lifecycle Orphan Detection Workflows Project H
0

What Are We Building?

Enterprise Identity Governance & Administration

🔗 Requires: Project A (Authentik)
🏛️
Govern Access. Prove Compliance.

Automate access reviews, detect orphan accounts, enforce least privilege!

🏢 Enterprise IGA Platforms vs Our Solution

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%

What We're Building

Access Certification
Periodic review campaigns for managers to certify access
🔄
JML Lifecycle
Joiner-Mover-Leaver automation
👻
Orphan Detection
Find accounts without owners
⚠️
SoD Policies
Segregation of Duties enforcement
📋
Approval Workflows
Access request with manager approval
📊
Compliance Reports
Audit-ready evidence generation

Access Certification Flow

📅
Campaign
Created
👤
Manager
Reviews

Certify
or Revoke

Action
Executed
📊
Report
Generated
🎯

What You'll Have When Done

  • Automated access certification campaigns
  • Manager approval workflows for access requests
  • Orphan account detection and reporting
  • Joiner-Mover-Leaver lifecycle automation
  • Segregation of Duties violation detection
  • Compliance-ready audit reports
  • Role mining and RBAC analysis
⏱️

Time Investment

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

1

Core Concepts

Understanding Identity Governance

🧠
What is Identity Governance?

The policies, processes, and tools that ensure the right people have the right access at the right time

Access Certification
Periodic manager review of who has access to what
🔄
Lifecycle Management
Automate access changes when people join, move, or leave
⚠️
Segregation of Duties
Prevent toxic combinations of access (e.g., approve + pay)
📋
Access Requests
Self-service with approval workflows
🔄

Joiner-Mover-Leaver (JML) Lifecycle

👋
Joiner

New hire onboarding

🔄
Mover

Role/dept change

👋
Leaver

Offboarding

Why Identity Governance?

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
📚

Key Terms

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
💡
Compliance Frameworks

IGA helps meet requirements in: SOX (financial controls), HIPAA (healthcare), PCI-DSS (payment cards), SOC2 (service orgs), GDPR (data privacy), ISO 27001 (security management)

2

Prerequisites

What you need before starting

Required

  • Project A (Authentik) completed and running
  • Python 3.9+ installed
  • Basic Python knowledge
  • Some test users and groups in Authentik
📦

Python Libraries

📦 Install Dependencies
# 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
🔑

Authentik API Token

  1. Login to Authentik Admin
  2. Go to Directory → Tokens and App passwords
  3. Click Create
  4. User: akadmin, Intent: API
  5. Copy the token for use in scripts
3

Access Certification Engine

Automated access review campaigns

Prove Your Access Reviews

Generate campaigns, collect manager decisions, execute actions, produce audit evidence!

1
Create Certification Engine
⏱️ 30 min
🐍 certification_engine.py
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
2
Configure Environment
⏱️ 5 min
⚙️ .env file
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
3
Run Certification Campaign
⏱️ 5 min
🚀 Run Campaign
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
🎉
Certification Engine Ready!

You can now create campaigns, review access, and generate audit evidence!

4

JML Lifecycle Automation

Joiner-Mover-Leaver automation

🔄
Automate Identity Lifecycle

Automatically provision access when people join, change roles, or leave!

1
Create Lifecycle Engine
⏱️ 30 min
🐍 lifecycle_engine.py
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
💡
HR Integration

In enterprise, this would connect to Workday, SAP SuccessFactors, or BambooHR via API. Events trigger automatically when HR updates employee records.

5

Orphan Account Detection

Find accounts without owners

👻
Eliminate Ghost Accounts

Find disabled users with active access, accounts without owners, service accounts without contacts!

1
Create Orphan Detector
⏱️ 20 min
🐍 orphan_detector.py
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
📊
Schedule Regular Scans

Run this weekly via cron: 0 8 * * 1 python orphan_detector.py

6

Segregation of Duties

Prevent toxic access combinations

⚠️
Enforce Separation

Detect when someone has both "create invoice" AND "approve payment" access!

1
Create SoD Policy Engine
⏱️ 25 min
🐍 sod_engine.py
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
⚠️
Preventive vs Detective

Detective: Find existing violations (this script)
Preventive: Block violations before they happen (integrate with access request workflow)

7

Approval Workflows

Access requests with manager approval

📋
Self-Service with Guardrails

Users request access, managers approve, system provisions automatically!

🔄

Using Authentik's Built-in Flows

Authentik supports approval workflows through its Flows feature. Here's how to configure them:

  1. Admin Panel: Go to Flows & Stages → Flows
  2. Create Approval Stage: Add a "Prompt" stage for approval
  3. Configure Binding: Set the flow to require admin approval
  4. Email Notification: Configure email stage to notify approvers
1
Custom Access Request Tracker
⏱️ 25 min
🐍 access_request.py
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
8

Compliance Reporting

Audit-ready evidence generation

📊
Always Audit Ready

Generate compliance reports instantly for SOX, HIPAA, SOC2, and more!

📋

Available Reports

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
9

Testing & Verification

Verify your IGA implementation

Verification Checklist

  • Certification campaigns create successfully
  • Certify/Revoke actions update Authentik
  • JML automation provisions/deprovisions correctly
  • Orphan detection finds expected issues
  • SoD policies detect violations
  • Access requests track through approval
  • Reports generate with accurate data
Run All Engines
🚀 Test Commands
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
🎉

Congratulations!

Your IGA framework is operational!

🏆
Identity Governance Expert

You've built enterprise IGA capabilities with Python and Authentik!

📊

Skills Demonstrated (Enterprise Equivalent)

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
💼
Interview Talking Point

"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."