Skip to main content

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:

  1. Email-Based User Identity (email = unique user ID)
  2. Admin-Managed User Assignment (add/remove users to license)
  3. Usage Tracking Per User (last login, session count, agent usage)
  4. SSO Integration (Enterprise tier: automatic user provisioning)
  5. 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

  • 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