ADR-196: NDA-Gated Conditional Access Control
Status: Accepted Date: 2026-02-14 Deciders: Hal Casteel (CTO)
Context
CODITECT documentation sites (ADR-195) may contain confidential information requiring access control:
- Investor materials: Financial projections, market analysis, competitive positioning
- Technical architecture: System design details, security architecture
- Pre-launch content: Product roadmaps, pricing strategies
Requirements:
- Access requires a signed NDA on file
- Access is conditional — revocable at any time
- Access is per-project — NDA for BIO-QMS doesn't grant access to other projects
- Access is auditable — full log of who viewed what, when
- Local mode must work without authentication for development and presentations
- Integration with existing auth.coditect.ai infrastructure
Decision
D1: NDARecord Model
Django model in auth.coditect.ai tracking signed NDAs:
class NDARecord(models.Model):
"""Tracks signed Non-Disclosure Agreements."""
id = models.UUIDField(primary_key=True, default=uuid.uuid4)
signer_email = models.EmailField(db_index=True)
signer_name = models.CharField(max_length=255)
company = models.CharField(max_length=255, blank=True)
signed_at = models.DateTimeField(auto_now_add=True)
revoked_at = models.DateTimeField(null=True, blank=True)
revoked_reason = models.TextField(blank=True)
document_version = models.CharField(max_length=50) # NDA version
ip_address = models.GenericIPAddressField()
signature_hash = models.CharField(max_length=128) # SHA-512 of signed doc
class Meta:
db_table = 'nda_records'
indexes = [
models.Index(fields=['signer_email', 'revoked_at']),
]
def is_active(self):
return self.revoked_at is None
def revoke(self, reason=""):
self.revoked_at = timezone.now()
self.revoked_reason = reason
self.save()
Rationale: Centralized NDA tracking in the existing auth service avoids building a separate NDA management system. The model supports revocation, versioning, and audit requirements.
D2: DocumentViewToken with Redis Cache
Per-project view tokens issued after NDA verification:
class DocumentViewToken(models.Model):
"""Per-project access token granted after NDA verification."""
token = models.UUIDField(primary_key=True, default=uuid.uuid4)
nda_record = models.ForeignKey(NDARecord, on_delete=models.CASCADE)
project_id = models.CharField(max_length=100, db_index=True)
granted_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField()
revoked_at = models.DateTimeField(null=True, blank=True)
last_accessed = models.DateTimeField(null=True)
access_count = models.IntegerField(default=0)
class Meta:
db_table = 'document_view_tokens'
indexes = [
models.Index(fields=['project_id', 'revoked_at']),
]
def is_valid(self):
return (
self.revoked_at is None
and self.expires_at > timezone.now()
and self.nda_record.is_active()
)
Redis caching strategy:
# Cache token validity for 5 minutes
cache_key = f"dvt:{token_uuid}"
cached = redis.get(cache_key)
if cached is None:
dvt = DocumentViewToken.objects.get(token=token_uuid)
is_valid = dvt.is_valid()
redis.setex(cache_key, 300, json.dumps({"valid": is_valid, "project": dvt.project_id}))
Rationale: Redis caching provides sub-millisecond token validation for every page load, while the database remains the source of truth. 5-minute cache TTL balances performance with revocation responsiveness.
D3: Audit Trail (AuditLog)
Every document access logged for compliance:
class DocumentAccessLog(models.Model):
"""Immutable audit trail for document access."""
id = models.UUIDField(primary_key=True, default=uuid.uuid4)
token = models.ForeignKey(DocumentViewToken, on_delete=models.PROTECT)
action = models.CharField(max_length=20) # view, search, download, print
document_id = models.CharField(max_length=255)
timestamp = models.DateTimeField(auto_now_add=True)
ip_address = models.GenericIPAddressField()
user_agent = models.TextField(blank=True)
class Meta:
db_table = 'document_access_log'
indexes = [
models.Index(fields=['token', 'timestamp']),
models.Index(fields=['document_id', 'timestamp']),
]
Rationale: Immutable audit trail satisfies compliance requirements and provides analytics (most-viewed documents, access patterns, unique viewers per project).
D4: API Endpoints
REST API on auth.coditect.ai:
| Method | Endpoint | Description |
|---|---|---|
POST | /api/v1/nda/verify | Check if user has active NDA |
POST | /api/v1/nda/sign | Record NDA signature (e-sign flow) |
GET | /api/v1/nda/status | Current NDA status for authenticated user |
POST | /api/v1/tokens/grant | Issue DocumentViewToken for project |
POST | /api/v1/tokens/validate | Validate token (called by viewer) |
POST | /api/v1/tokens/revoke | Revoke a specific token |
GET | /api/v1/tokens/list | List active tokens (admin) |
POST | /api/v1/access/log | Log document access event |
GET | /api/v1/access/report | Access analytics report (admin) |
Authentication: All endpoints require JWT from auth.coditect.ai except /tokens/validate which accepts the DocumentViewToken directly.
D5: Client-Side Auth Flow
User visits docs.coditect.ai/bio-qms
│
▼
Check for DocumentViewToken (cookie)
│
├── Token exists → POST /tokens/validate
│ ├── Valid → Show content, log access
│ └── Invalid → Clear cookie, redirect to auth
│
└── No token → Redirect to auth.coditect.ai/nda?project=bio-qms
│
▼
auth.coditect.ai checks NDA status
│
├── Active NDA → POST /tokens/grant → Set cookie → Redirect back
│
└── No NDA → Show NDA signing flow
│
▼
User signs NDA → POST /nda/sign → POST /tokens/grant → Redirect back
Token storage:
- Cloud mode: httpOnly secure cookie (prevents XSS)
- Development: localStorage (convenience for local dev)
D6: Local Bypass Mode
For development and presentations:
// vite.config.js
const AUTH_MODE = process.env.VITE_AUTH_MODE || 'none';
// In viewer
if (AUTH_MODE === 'none') {
// Skip all auth checks, show full content
// Only works when served from localhost
}
Safety controls:
- Bypass mode only activates on localhost (127.0.0.1, localhost, ::1)
- Cloud deployments always require auth regardless of env var
- Build process strips bypass code in production builds
- Console warning displayed when running in bypass mode
D7: Customer Self-Service
For CODITECT customers deploying their own doc sites:
- Admin panel: Customer admins manage NDA records and tokens for their organization
- Bulk operations: CSV import of NDA signers, bulk token grant/revoke
- Webhook notifications: Token granted/revoked/expired events
- API access: Full API for programmatic NDA/token management
- White-label: Customer branding on NDA signing page
Consequences
Positive
- NDA enforcement: Only verified NDA signers access confidential content
- Instant revocation: Redis cache invalidation + token revocation in < 5 minutes
- Per-project isolation: NDA for one project doesn't grant access to others
- Full audit trail: Every access logged for compliance reporting
- Local mode: Presentations work without internet or authentication
- Reuses existing auth: Builds on auth.coditect.ai infrastructure (ADR-015)
Negative
- NDA management overhead: Requires admin to manage NDA records
- Token TTL decisions: Too short = frequent re-auth; too long = stale access
- Redis dependency: Token validation depends on Redis availability
Risks
- Redis failure: Mitigate with fallback to database validation (slower but functional)
- Token leakage: Mitigate with httpOnly cookies, short TTL, IP binding
- NDA version changes: Mitigate with version tracking, require re-sign for new versions
Implementation Phases
| Phase | Sprint | Scope |
|---|---|---|
| 1 | S3 | NDARecord + DocumentViewToken models, basic API endpoints |
| 2 | S3 | Client-side auth flow in viewer, local bypass mode |
| 3 | S4 | Audit trail, admin panel, access analytics |
| 4 | S5+ | Customer self-service, webhook notifications, white-label |
Related
- ADR-195: Push-Button Documentation Publishing Platform (the viewer being protected)
- ADR-015: Authentication Architecture (auth.coditect.ai foundation)
- ADR-090: JWT Token Strategy (token format and signing)
- ADR-145: Cloud Authentication (GCP integration)
Author: Hal Casteel Project: BIO-QMS (CODITECT Biosciences QMS Platform)