ADR-018: Named User Assignment
Status: Accepted Date: 2025-11-30 Deciders: Product Team, Enterprise Team, Engineering Team Tags: named-users, team-management, enterprise, seat-assignment
Context
Team License User Management Problem
Team and Enterprise tiers have floating concurrent seat licenses but lack visibility into WHO is using seats:
Business Challenges:
- No User Attribution: Admin sees "5 seats in use" but doesn't know which developers
- Audit Requirements: Enterprise compliance needs user activity logs
- Support Burden: "Who has seat X?" requires manual investigation
- Resource Planning: Can't identify inactive users to reclaim seats
Real-World Enterprise Scenario:
Acme Corp - Enterprise License (50 Seats, $2,500/month)
Current State (No Named Users):
- 50 seats available
- 45 seats in use (90% utilization)
- Admin dashboard:
"45 active sessions"
"5 available seats"
- Question: Who are the 45 users?
- Answer: Unknown ❌
Desired State (Named User Assignment):
- 50 seats available
- 50 users assigned to license:
• alice@acme.com (active, last used: 2 hours ago)
• bob@acme.com (active, last used: 5 minutes ago)
• carol@acme.com (inactive, last used: 30 days ago) ⚠️
• ...47 more users
- Admin can:
✅ See all assigned users
✅ Identify inactive users (carol@acme.com)
✅ Reassign seat (remove carol, add dave@acme.com)
✅ Generate usage reports for billing/audit
Requirements
Team Tier (5-100 seats):
- Named User Assignment: Map email addresses to license
- Self-Service: Team admins can add/remove users
- Usage Visibility: Last login, session count
- Flexible Assignment: Users can be reassigned (no lockouts)
Enterprise Tier (100+ seats):
- All Team features plus:
- SSO Integration: Automatic user provisioning via SAML/OAuth
- SCIM Support: User sync with identity providers (Okta, Azure AD)
- Audit Logs: Complete user activity trail
- Custom Roles: Admin, Member, Read-Only roles
Compliance:
- GDPR: User data deletion on request
- SOC 2: Audit trail for access control
- HIPAA: Role-based access control (RBAC)
Decision
We will implement named user assignment with:
- Email-Based User Identity (email = unique user ID)
- Admin-Managed User Assignment (add/remove users to license)
- Usage Tracking Per User (last login, session count, agent usage)
- SSO Integration (Enterprise tier: automatic user provisioning)
- Audit Logging (all user assignment changes logged)
Named User Architecture
┌────────────────────────────────────────────────────────────────┐
│ Named User Assignment Flow │
└────────────────────────────────────────────────────────────────┘
Admin Adds User to License
│
│ POST /api/v1/licenses/{id}/users
│ {
│ "email": "alice@acme.com",
│ "role": "member"
│ }
▼
┌───────────────────────┐
│ License API │
│ │
│ 1. Validate license │
│ 2. Check seat limit │
│ 3. Check duplicate │
└───────┬───────────────┘
│
│ Query: license_users
│ WHERE license_id = ... AND status = 'active'
▼
┌───────────────────────┐
│ Seat Check │
│ │
│ Active users: 45 │
│ Seat limit: 50 │
│ 45 < 50? → OK │
└───────┬───────────────┘
│
│ Check for duplicate email
▼
┌───────────────────────┐
│ Duplicate Check │
│ │
│ alice@acme.com │
│ already assigned? │
│ → NO │
└───────┬───────────────┘
│
│ Create user assignment
▼
┌───────────────────────┐
│ INSERT INTO │
│ license_users │
│ │
│ license_id, │
│ email, │
│ role: member, │
│ status: active, │
│ assigned_at │
└───────┬───────────────┘
│
│ Send invitation email
▼
┌───────────────────────┐
│ 📧 Email: │
│ alice@acme.com │
│ │
│ "You've been added │
│ to Acme Corp │
│ CODITECT license" │
│ │
│ [Accept Invitation] │
└───────┬───────────────┘
│
│ User clicks link
▼
┌───────────────────────┐
│ User Onboarding │
│ │
│ 1. Create account │
│ 2. Link to license │
│ 3. Download CODITECT │
│ 4. Activate license │
└───────────────────────┘
User Session Tracking
alice@acme.com Uses CODITECT
│
│ License validation includes user email
▼
┌───────────────────────┐
│ POST /licenses/ │
│ validate │
│ │
│ { │
│ "license_key": │
│ "LIC-...", │
│ "user_email": │
│ "alice@acme.com" │
│ } │
└───────┬───────────────┘
│
│ Validate user assigned to license
▼
┌───────────────────────┐
│ User Lookup │
│ │
│ license_users │
│ WHERE email = │
│ "alice@acme.com" │
│ AND status = active │
│ │
│ → FOUND ✅ │
└───────┬───────────────┘
│
│ Update usage tracking
▼
┌───────────────────────┐
│ UPDATE license_users │
│ SET │
│ last_login_at = │
│ NOW(), │
│ session_count = │
│ session_count+1 │
│ WHERE id = ... │
└───────┬───────────────┘
│
│ Session allowed ✅
▼
┌───────────────────────┐
│ License Valid │
│ User: alice@acme.com │
│ Role: member │
│ Seat: 12 of 50 │
└───────────────────────┘
Implementation
1. Database Schema
File: backend/licenses/models.py
from django.db import models
from django.utils import timezone
import uuid
class LicenseUser(models.Model):
"""
Named user assignment to Team/Enterprise licenses.
Maps users (by email) to licenses with roles and usage tracking.
"""
class Role(models.TextChoices):
ADMIN = 'admin', 'Admin'
# Can add/remove users, view all usage
MEMBER = 'member', 'Member'
# Can use license, view own usage
READ_ONLY = 'read_only', 'Read-Only'
# Can view usage reports only (no CODITECT access)
class Status(models.TextChoices):
ACTIVE = 'active', 'Active'
INVITED = 'invited', 'Invited (Pending)'
SUSPENDED = 'suspended', 'Suspended'
REMOVED = 'removed', 'Removed'
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
license = models.ForeignKey('License', on_delete=models.CASCADE, related_name='license_users')
tenant = models.ForeignKey('Tenant', on_delete=models.CASCADE)
# User identity (email-based)
email = models.EmailField(db_index=True)
# Email address of user (alice@acme.com)
# User metadata
first_name = models.CharField(max_length=100, blank=True)
last_name = models.CharField(max_length=100, blank=True)
role = models.CharField(max_length=20, choices=Role.choices, default=Role.MEMBER)
# Assignment tracking
status = models.CharField(max_length=20, choices=Status.choices, default=Status.INVITED)
assigned_at = models.DateTimeField(auto_now_add=True)
assigned_by = models.EmailField(blank=True)
# Email of admin who added this user
# Invitation tracking
invitation_sent_at = models.DateTimeField(null=True, blank=True)
invitation_accepted_at = models.DateTimeField(null=True, blank=True)
invitation_token = models.CharField(max_length=64, blank=True, db_index=True)
# Secure token for invitation acceptance
# Usage tracking
last_login_at = models.DateTimeField(null=True, blank=True)
session_count = models.IntegerField(default=0)
# Total CODITECT sessions
total_agent_invocations = models.IntegerField(default=0)
total_command_executions = models.IntegerField(default=0)
# Removal tracking
removed_at = models.DateTimeField(null=True, blank=True)
removed_by = models.EmailField(blank=True)
removal_reason = models.CharField(max_length=255, blank=True)
class Meta:
db_table = 'license_users'
ordering = ['-assigned_at']
unique_together = [['license', 'email']]
indexes = [
models.Index(fields=['license', 'status']),
models.Index(fields=['email', 'status']),
models.Index(fields=['invitation_token']),
]
def __str__(self):
return f"{self.email} ({self.role}) - {self.license.license_key}"
@property
def is_active(self) -> bool:
"""Check if user is active on license."""
return self.status == self.Status.ACTIVE
@property
def days_since_last_login(self) -> int:
"""Calculate days since last login (for inactive user detection)."""
if not self.last_login_at:
return 9999 # Never logged in
delta = timezone.now() - self.last_login_at
return delta.days
class UserActivity(models.Model):
"""
Detailed user activity log for audit trail.
Tracks every user action for compliance (SOC 2, HIPAA).
"""
class ActivityType(models.TextChoices):
LOGIN = 'login', 'Login'
LOGOUT = 'logout', 'Logout'
AGENT_INVOCATION = 'agent_invocation', 'Agent Invocation'
COMMAND_EXECUTION = 'command_execution', 'Command Execution'
ADDED_TO_LICENSE = 'added_to_license', 'Added to License'
REMOVED_FROM_LICENSE = 'removed_from_license', 'Removed from License'
ROLE_CHANGED = 'role_changed', 'Role Changed'
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
license_user = models.ForeignKey('LicenseUser', on_delete=models.CASCADE)
license = models.ForeignKey('License', on_delete=models.CASCADE)
tenant = models.ForeignKey('Tenant', on_delete=models.CASCADE)
activity_type = models.CharField(max_length=50, choices=ActivityType.choices)
timestamp = models.DateTimeField(auto_now_add=True, db_index=True)
# Activity metadata
metadata = models.JSONField(default=dict)
# Examples:
# - login: {"ip_address": "1.2.3.4", "device": "MacBook Pro"}
# - agent_invocation: {"agent": "orchestrator", "duration_ms": 1500}
# - role_changed: {"old_role": "member", "new_role": "admin", "changed_by": "alice@acme.com"}
# IP and device tracking
ip_address = models.GenericIPAddressField(null=True, blank=True)
user_agent = models.TextField(blank=True)
class Meta:
db_table = 'user_activities'
ordering = ['-timestamp']
indexes = [
models.Index(fields=['license_user', '-timestamp']),
models.Index(fields=['activity_type', '-timestamp']),
]
class License(models.Model):
"""Extended License model with named user support."""
# ... existing fields ...
# Named user settings
require_user_assignment = models.BooleanField(default=False)
# If True, users must be explicitly assigned (Enterprise tier)
@property
def active_user_count(self) -> int:
"""Get count of active assigned users."""
return self.license_users.filter(status=LicenseUser.Status.ACTIVE).count()
@property
def can_add_user(self) -> bool:
"""Check if new user can be added (within seat limit)."""
return self.active_user_count < self.max_seats
def get_inactive_users(self, days=30) -> list:
"""
Get users inactive for N days.
Args:
days: Inactivity threshold (default: 30 days)
Returns:
List of LicenseUser objects
"""
cutoff = timezone.now() - timedelta(days=days)
return self.license_users.filter(
status=LicenseUser.Status.ACTIVE,
last_login_at__lt=cutoff
)
2. User Management API
File: backend/licenses/views.py
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework import status
import secrets
from .models import License, LicenseUser, UserActivity
from .notifications import send_user_invitation_email
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def add_user_to_license(request, license_id):
"""
Add named user to license.
Request Body:
{
"email": "alice@acme.com",
"first_name": "Alice",
"last_name": "Smith",
"role": "member"
}
Response:
{
"user_id": "uuid",
"email": "alice@acme.com",
"status": "invited",
"invitation_sent": true
}
Authorization:
- Requester must have admin role on license
- OR be tenant owner
Validation:
- License has available seats
- Email not already assigned
- Valid email format
"""
license_obj = get_object_or_404(License, id=license_id)
# Authorization check
if not request.user.is_license_admin(license_obj):
return Response(
{'error': 'Admin permissions required'},
status=status.HTTP_403_FORBIDDEN
)
email = request.data.get('email')
first_name = request.data.get('first_name', '')
last_name = request.data.get('last_name', '')
role = request.data.get('role', LicenseUser.Role.MEMBER)
# Validate email
if not email:
return Response({'error': 'Email required'}, status=status.HTTP_400_BAD_REQUEST)
# Check seat limit
if not license_obj.can_add_user:
return Response({
'error': 'seat_limit_exceeded',
'message': f"All {license_obj.max_seats} seats in use"
}, status=status.HTTP_429_TOO_MANY_REQUESTS)
# Check for duplicate
if LicenseUser.objects.filter(license=license_obj, email=email).exists():
return Response({
'error': 'user_already_assigned',
'message': f"{email} already assigned to this license"
}, status=status.HTTP_409_CONFLICT)
# Generate invitation token
invitation_token = secrets.token_urlsafe(32)
# Create user assignment
license_user = LicenseUser.objects.create(
license=license_obj,
tenant=license_obj.tenant,
email=email,
first_name=first_name,
last_name=last_name,
role=role,
status=LicenseUser.Status.INVITED,
assigned_by=request.user.email,
invitation_token=invitation_token,
invitation_sent_at=timezone.now()
)
# Send invitation email
send_user_invitation_email(
license_user,
invitation_url=f"{settings.FRONTEND_URL}/invitations/{invitation_token}"
)
# Log activity
UserActivity.objects.create(
license_user=license_user,
license=license_obj,
tenant=license_obj.tenant,
activity_type=UserActivity.ActivityType.ADDED_TO_LICENSE,
metadata={
'added_by': request.user.email,
'role': role
},
ip_address=get_client_ip(request)
)
return Response({
'user_id': str(license_user.id),
'email': email,
'status': license_user.status,
'invitation_sent': True,
'role': role
}, status=status.HTTP_201_CREATED)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def list_license_users(request, license_id):
"""
List all users assigned to license.
Query Params:
?status=active
&role=member
&inactive_days=30
Response:
{
"users": [
{
"user_id": "uuid",
"email": "alice@acme.com",
"first_name": "Alice",
"last_name": "Smith",
"role": "member",
"status": "active",
"assigned_at": "2025-11-01T10:00:00Z",
"last_login_at": "2025-11-29T12:00:00Z",
"session_count": 145,
"days_since_last_login": 0
}
],
"total_users": 45,
"active_users": 45,
"seat_limit": 50,
"inactive_users_30d": 5
}
"""
license_obj = get_object_or_404(License, id=license_id)
# Authorization check
if not request.user.can_view_license(license_obj):
return Response(
{'error': 'Unauthorized'},
status=status.HTTP_403_FORBIDDEN
)
# Apply filters
queryset = LicenseUser.objects.filter(license=license_obj)
status_filter = request.GET.get('status')
if status_filter:
queryset = queryset.filter(status=status_filter)
role_filter = request.GET.get('role')
if role_filter:
queryset = queryset.filter(role=role_filter)
# Get inactive users
inactive_days = int(request.GET.get('inactive_days', 30))
inactive_users = license_obj.get_inactive_users(days=inactive_days)
# Serialize users
users = []
for user in queryset:
users.append({
'user_id': str(user.id),
'email': user.email,
'first_name': user.first_name,
'last_name': user.last_name,
'role': user.role,
'status': user.status,
'assigned_at': user.assigned_at,
'last_login_at': user.last_login_at,
'session_count': user.session_count,
'days_since_last_login': user.days_since_last_login
})
return Response({
'users': users,
'total_users': queryset.count(),
'active_users': license_obj.active_user_count,
'seat_limit': license_obj.max_seats,
'inactive_users_30d': len(inactive_users)
})
@api_view(['DELETE'])
@permission_classes([IsAuthenticated])
def remove_user_from_license(request, license_id, user_id):
"""
Remove user from license.
Response:
{
"removed": true,
"message": "User removed from license"
}
Authorization:
- Requester must have admin role
- Cannot remove self if last admin
Audit:
- Logs removal in UserActivity
- Tracks who removed and why
"""
license_obj = get_object_or_404(License, id=license_id)
license_user = get_object_or_404(LicenseUser, id=user_id, license=license_obj)
# Authorization check
if not request.user.is_license_admin(license_obj):
return Response(
{'error': 'Admin permissions required'},
status=status.HTTP_403_FORBIDDEN
)
# Prevent removing last admin
if license_user.role == LicenseUser.Role.ADMIN:
admin_count = LicenseUser.objects.filter(
license=license_obj,
role=LicenseUser.Role.ADMIN,
status=LicenseUser.Status.ACTIVE
).count()
if admin_count == 1:
return Response({
'error': 'cannot_remove_last_admin',
'message': 'Assign another admin before removing the last admin'
}, status=status.HTTP_400_BAD_REQUEST)
# Remove user
removal_reason = request.data.get('reason', 'admin_removed')
license_user.status = LicenseUser.Status.REMOVED
license_user.removed_at = timezone.now()
license_user.removed_by = request.user.email
license_user.removal_reason = removal_reason
license_user.save()
# Log activity
UserActivity.objects.create(
license_user=license_user,
license=license_obj,
tenant=license_obj.tenant,
activity_type=UserActivity.ActivityType.REMOVED_FROM_LICENSE,
metadata={
'removed_by': request.user.email,
'reason': removal_reason
},
ip_address=get_client_ip(request)
)
return Response({
'removed': True,
'message': 'User removed from license'
})
Consequences
Positive
✅ User Visibility
- Admins see all assigned users
- Usage tracking per user
- Identify inactive users for seat reclamation
✅ Audit Compliance
- Complete activity log
- SOC 2, HIPAA, GDPR compliant
- Who accessed what, when
✅ Better Support
- "Who has seat X?" → Instant answer
- User-specific support tickets
- Usage analytics per user
✅ Resource Optimization
- Identify inactive users (30+ days)
- Reassign seats to active users
- Reduce wasted licenses
Negative
⚠️ Admin Overhead
- Manual user assignment required
- Invitation management
- Role administration
⚠️ Privacy Concerns
- Email addresses stored
- Usage tracking per user
- Mitigation: Privacy policy, GDPR compliance
Related ADRs
- ADR-001: Floating vs Node-Locked Licenses (team seat management)
- ADR-019: Monitoring and Observability (user activity metrics)
References
Last Updated: 2025-11-30 Owner: Enterprise Team Review Cycle: Quarterly