Phase 1 Step 4: License Delivery - Design Document
Date: December 1, 2025 Author: AI Assistant Status: Implementation Ready
Overview
Provide REST API endpoints for customers to access, view, and download their CODITECT licenses generated from Stripe subscriptions.
Goals
- License Listing: Allow organizations to view all their licenses
- License Details: Provide detailed information about individual licenses
- License Download: Generate downloadable signed license files (JSON)
- License Validation: Public endpoint to validate license keys
- Multi-Tenant Security: Ensure proper organization-scoped access
API Endpoints
1. List Organization Licenses
GET /api/v1/licenses/
Authentication: Required (JWT)
Permission: Organization member
Query Parameters:
- is_active (optional): Filter by active status (true/false)
- tier (optional): Filter by tier (PRO, ENTERPRISE)
Response (200 OK):
{
"licenses": [
{
"id": "uuid-123",
"key_string": "CODITECT-2025-A7B3-X9K2",
"tier": "PRO",
"features": ["marketplace", "analytics", "priority_support"],
"expiry_date": "2026-01-01T00:00:00Z",
"is_active": true,
"is_expired": false,
"is_valid": true,
"created_at": "2025-12-01T10:00:00Z",
"auto_renew": true,
"cancel_at_period_end": false
}
],
"count": 1,
"active_license": {
"id": "uuid-123",
"key_string": "CODITECT-2025-A7B3-X9K2"
}
}
2. Get License Details
GET /api/v1/licenses/{license_id}/
Authentication: Required (JWT)
Permission: Organization member (tenant-scoped)
Response (200 OK):
{
"id": "uuid-123",
"key_string": "CODITECT-2025-A7B3-X9K2",
"tier": "PRO",
"features": ["marketplace", "analytics", "priority_support"],
"expiry_date": "2026-01-01T00:00:00Z",
"is_active": true,
"is_expired": false,
"is_valid": true,
"created_at": "2025-12-01T10:00:00Z",
"updated_at": "2025-12-01T10:00:00Z",
"organization": {
"id": "org-uuid",
"name": "Acme Corp",
"slug": "acme-corp"
},
"subscription": {
"stripe_subscription_id": "sub_xxx",
"stripe_price_id": "price_xxx",
"auto_renew": true,
"cancel_at_period_end": false
},
"usage": {
"seats_purchased": 5,
"seats_used": 3,
"seats_remaining": 2,
"active_sessions_count": 3
}
}
Response (404 Not Found):
{
"error": "License not found or does not belong to your organization"
}
3. Download License File
GET /api/v1/licenses/{license_id}/download/
Authentication: Required (JWT)
Permission: Organization member (tenant-scoped)
Response (200 OK):
Content-Type: application/json
Content-Disposition: attachment; filename="coditect-license-2025-A7B3.json"
{
"license": {
"license_key": "CODITECT-2025-A7B3-X9K2",
"organization": {
"id": "org-uuid",
"name": "Acme Corp"
},
"tier": "PRO",
"features": ["marketplace", "analytics", "priority_support"],
"expiry_date": "2026-01-01T00:00:00Z",
"issued_at": "2025-12-01T10:00:00Z",
"is_active": true
},
"signature": {
"payload": {
"license_id": "uuid-123",
"license_key": "CODITECT-2025-A7B3-X9K2",
"organization_id": "org-uuid",
"organization_name": "Acme Corp",
"tier": "PRO",
"features": ["marketplace", "analytics", "priority_support"],
"expiry_date": "2026-01-01T00:00:00Z",
"issued_at": "2025-12-01T10:00:00Z",
"is_active": true,
"auto_renew": true
},
"signature": "base64-encoded-rsa-4096-signature...",
"algorithm": "RS256",
"key_id": "projects/coditect-cloud-infra/locations/us-central1/keyRings/license-signing-keyring/cryptoKeys/license-signing-key"
},
"metadata": {
"generated_at": "2025-12-01T12:00:00Z",
"format_version": "1.0",
"verification_url": "https://api.coditect.ai/v1/licenses/validate/"
}
}
Response (404 Not Found):
{
"error": "License not found or does not belong to your organization"
}
4. Validate License Key
POST /api/v1/licenses/validate/
Authentication: Optional (public endpoint)
Rate Limit: 60 requests/minute per IP
Request Body:
{
"key_string": "CODITECT-2025-A7B3-X9K2"
}
Response (200 OK - Valid):
{
"valid": true,
"license": {
"key_string": "CODITECT-2025-A7B3-X9K2",
"tier": "PRO",
"features": ["marketplace", "analytics", "priority_support"],
"expiry_date": "2026-01-01T00:00:00Z",
"is_active": true,
"is_expired": false
},
"organization": {
"id": "org-uuid",
"name": "Acme Corp"
}
}
Response (200 OK - Invalid):
{
"valid": false,
"reason": "license_expired", // or "license_not_found", "license_inactive"
"message": "License has expired on 2025-11-30T00:00:00Z"
}
Response (400 Bad Request):
{
"error": "Invalid license key format"
}
Serializers
1. LicenseListSerializer
class LicenseListSerializer(serializers.ModelSerializer):
"""
Serializer for license list view.
Returns basic license information.
"""
class Meta:
model = License
fields = [
'id', 'key_string', 'tier', 'features',
'expiry_date', 'is_active', 'is_expired', 'is_valid',
'created_at', 'auto_renew', 'cancel_at_period_end'
]
read_only_fields = fields
2. LicenseDetailSerializer
class LicenseDetailSerializer(serializers.ModelSerializer):
"""
Serializer for detailed license view.
Includes organization, subscription, and usage information.
"""
organization = OrganizationSerializer(read_only=True)
subscription = serializers.SerializerMethodField()
usage = serializers.SerializerMethodField()
class Meta:
model = License
fields = [
'id', 'key_string', 'tier', 'features',
'expiry_date', 'is_active', 'is_expired', 'is_valid',
'created_at', 'updated_at',
'organization', 'subscription', 'usage'
]
read_only_fields = fields
def get_subscription(self, obj):
return {
'stripe_subscription_id': obj.stripe_subscription_id,
'stripe_price_id': obj.stripe_price_id,
'auto_renew': obj.auto_renew,
'cancel_at_period_end': obj.cancel_at_period_end,
}
def get_usage(self, obj):
org = obj.organization
active_sessions = LicenseSession.objects.filter(
license=obj,
ended_at__isnull=True
).count()
return {
'seats_purchased': org.seats_purchased,
'seats_used': org.seats_used,
'seats_remaining': max(0, org.seats_purchased - org.seats_used),
'active_sessions_count': active_sessions,
}
3. LicenseValidateSerializer
class LicenseValidateSerializer(serializers.Serializer):
"""
Serializer for license validation request.
"""
key_string = serializers.CharField(required=True, max_length=255)
def validate_key_string(self, value):
from licenses.services import LicenseService
if not LicenseService.validate_key_format(value):
raise serializers.ValidationError("Invalid license key format")
return value
Views Implementation
1. LicenseListView
class LicenseListView(APIView):
"""
GET /api/v1/licenses/
List all licenses for the authenticated user's organization.
"""
permission_classes = [IsAuthenticated]
def get(self, request):
organization = request.user.organization
# Get all licenses for organization
licenses = License.objects.filter(organization=organization)
# Apply filters
is_active = request.query_params.get('is_active')
if is_active is not None:
licenses = licenses.filter(is_active=is_active.lower() == 'true')
tier = request.query_params.get('tier')
if tier:
licenses = licenses.filter(tier=tier.upper())
# Serialize
serializer = LicenseListSerializer(licenses, many=True)
return Response({
'licenses': serializer.data,
'count': licenses.count(),
'active_license': {
'id': str(organization.active_license.id),
'key_string': organization.active_license.key_string,
} if organization.active_license else None
}, status=status.HTTP_200_OK)
2. LicenseDetailView
class LicenseDetailView(APIView):
"""
GET /api/v1/licenses/{license_id}/
Get detailed information about a specific license.
"""
permission_classes = [IsAuthenticated]
def get(self, request, license_id):
try:
# Tenant-scoped query (django-multitenant)
license_obj = License.objects.get(
id=license_id,
organization=request.user.organization
)
serializer = LicenseDetailSerializer(license_obj)
return Response(serializer.data, status=status.HTTP_200_OK)
except License.DoesNotExist:
return Response(
{'error': 'License not found or does not belong to your organization'},
status=status.HTTP_404_NOT_FOUND
)
3. LicenseDownloadView
class LicenseDownloadView(APIView):
"""
GET /api/v1/licenses/{license_id}/download/
Download a signed license file.
"""
permission_classes = [IsAuthenticated]
def get(self, request, license_id):
try:
# Tenant-scoped query
license_obj = License.objects.get(
id=license_id,
organization=request.user.organization
)
from licenses.services import LicenseService
# Generate signed license payload
signed_payload = LicenseService.sign_license_payload(license_obj)
# Prepare download response
download_data = {
'license': {
'license_key': license_obj.key_string,
'organization': {
'id': str(license_obj.organization.id),
'name': license_obj.organization.name,
},
'tier': license_obj.tier,
'features': license_obj.features,
'expiry_date': license_obj.expiry_date.isoformat(),
'issued_at': license_obj.created_at.isoformat(),
'is_active': license_obj.is_active,
},
'signature': signed_payload,
'metadata': {
'generated_at': timezone.now().isoformat(),
'format_version': '1.0',
'verification_url': f"{settings.BASE_URL}/api/v1/licenses/validate/",
}
}
# Create audit log
from licenses.models import AuditLog
AuditLog.objects.create(
organization=license_obj.organization,
user=request.user,
action='LICENSE_DOWNLOADED',
resource_type='license',
resource_id=license_obj.id,
metadata={'ip_address': request.META.get('REMOTE_ADDR')}
)
# Return as downloadable file
response = Response(download_data, status=status.HTTP_200_OK)
# Extract short key segment for filename
key_parts = license_obj.key_string.split('-')
short_key = f"{key_parts[1]}-{key_parts[2]}" if len(key_parts) >= 3 else "license"
response['Content-Disposition'] = f'attachment; filename="coditect-license-{short_key}.json"'
return response
except License.DoesNotExist:
return Response(
{'error': 'License not found or does not belong to your organization'},
status=status.HTTP_404_NOT_FOUND
)
4. LicenseValidateView
class LicenseValidateView(APIView):
"""
POST /api/v1/licenses/validate/
Validate a license key (public endpoint).
"""
permission_classes = [AllowAny]
throttle_classes = [AnonRateThrottle] # 60 requests/minute
def post(self, request):
serializer = LicenseValidateSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
key_string = serializer.validated_data['key_string']
try:
license_obj = License.objects.get(key_string=key_string)
# Check if valid
if not license_obj.is_active:
return Response({
'valid': False,
'reason': 'license_inactive',
'message': 'License has been deactivated'
}, status=status.HTTP_200_OK)
if license_obj.is_expired:
return Response({
'valid': False,
'reason': 'license_expired',
'message': f'License expired on {license_obj.expiry_date.isoformat()}'
}, status=status.HTTP_200_OK)
# Valid license
return Response({
'valid': True,
'license': {
'key_string': license_obj.key_string,
'tier': license_obj.tier,
'features': license_obj.features,
'expiry_date': license_obj.expiry_date.isoformat(),
'is_active': license_obj.is_active,
'is_expired': license_obj.is_expired,
},
'organization': {
'id': str(license_obj.organization.id),
'name': license_obj.organization.name,
}
}, status=status.HTTP_200_OK)
except License.DoesNotExist:
return Response({
'valid': False,
'reason': 'license_not_found',
'message': 'License key not found in database'
}, status=status.HTTP_200_OK)
URL Patterns
urlpatterns = [
# ... existing patterns ...
# Phase 1 Step 4: License Delivery
path('licenses/', LicenseListView.as_view(), name='license-list'),
path('licenses/<uuid:license_id>/', LicenseDetailView.as_view(), name='license-detail'),
path('licenses/<uuid:license_id>/download/', LicenseDownloadView.as_view(), name='license-download'),
path('licenses/validate/', LicenseValidateView.as_view(), name='license-validate'),
]
Security Considerations
-
Multi-Tenant Isolation:
- All license queries filtered by organization
- Django-multitenant automatic tenant filtering
- Explicit organization checks in views
-
Rate Limiting:
- License validation endpoint: 60 requests/minute per IP
- Prevents brute-force license key guessing
- Uses DRF's AnonRateThrottle
-
Audit Logging:
- License downloads logged with user and IP
- Validation attempts not logged (high frequency)
- All access events traceable for compliance
-
License Key Security:
- Keys are cryptographically random (not sequential)
- Validation requires database lookup
- Cannot be reverse-engineered from organization info
Implementation Checklist
- Create serializers: LicenseListSerializer, LicenseDetailSerializer, LicenseValidateSerializer
- Create views: LicenseListView, LicenseDetailView, LicenseDownloadView, LicenseValidateView
- Update URLs: Add 4 license endpoints
- Configure rate limiting: Set up AnonRateThrottle for validation endpoint
- Update serializers/init.py: Export new serializers
- Create tests: Unit tests for each endpoint
- Documentation: Update API documentation
Success Criteria
- ✅ List Licenses: Organizations can view all their licenses
- ✅ View Details: Detailed license information accessible
- ✅ Download Files: Signed license files downloadable as JSON
- ✅ Validate Keys: Public validation endpoint working with rate limiting
- ✅ Multi-Tenant Security: All endpoints properly tenant-scoped
- ✅ Audit Logging: Downloads logged for compliance
Timeline Estimate
- Serializers: 1 hour
- Views: 2 hours
- URL Configuration: 15 minutes
- Rate Limiting Setup: 30 minutes
- Testing: 1 hour
- Documentation: 30 minutes
Total: 5.5 hours
Status: Ready for Implementation Dependencies: Phase 1 Step 3 (License Generation) - ✅ Complete