Skip to main content

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:

  1. Investor materials: Financial projections, market analysis, competitive positioning
  2. Technical architecture: System design details, security architecture
  3. 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:

MethodEndpointDescription
POST/api/v1/nda/verifyCheck if user has active NDA
POST/api/v1/nda/signRecord NDA signature (e-sign flow)
GET/api/v1/nda/statusCurrent NDA status for authenticated user
POST/api/v1/tokens/grantIssue DocumentViewToken for project
POST/api/v1/tokens/validateValidate token (called by viewer)
POST/api/v1/tokens/revokeRevoke a specific token
GET/api/v1/tokens/listList active tokens (admin)
POST/api/v1/access/logLog document access event
GET/api/v1/access/reportAccess 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

PhaseSprintScope
1S3NDARecord + DocumentViewToken models, basic API endpoints
2S3Client-side auth flow in viewer, local bypass mode
3S4Audit trail, admin panel, access analytics
4S5+Customer self-service, webhook notifications, white-label

  • 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)