Skip to content

Security & SOC 2 Compliance

CampusCore operates under SOC 2 Type II requirements (Trust Services Criteria CC6.1, CC6.7). This document covers the encryption and data protection architecture.

Encryption at Rest

All AWS storage resources use encryption at rest with AWS-managed keys. No application code changes are needed — encryption is transparent at the storage layer.

Resource Method Terraform Config
RDS PostgreSQL AWS-managed KMS key (storage_encrypted = true) infrastructure/modules/rds/main.tf
S3 Buckets SSE-S3 (AES-256) via aws_s3_bucket_server_side_encryption_configuration infrastructure/modules/s3_bucket/main.tf
SQS Queues SSE-SQS (sqs_managed_sse_enabled = true) infrastructure/modules/sqs_queue/main.tf
CloudTrail log stream Customer-managed KMS key with rotation, usable only by this account's own trail (security posture bundle only) infrastructure/base/cloudtrail.tf
EBS volumes and snapshots Encrypted at creation by an account-wide default, so a volume created outside Terraform cannot be born unencrypted (security posture bundle only) infrastructure/base/account_hardening.tf

Impact on application code

None. Storage-level encryption is transparent to PostgreSQL, boto3, and Django. Queries, S3 uploads/downloads, SQS messages, and pgvector HNSW indexes all work identically — AWS encrypts/decrypts at the block storage layer before data reaches the application.

Encryption in Transit

Database connections

Cloud database connections enforce TLS via sslmode=require in Django's DATABASES config, gated on IS_CLOUD_ENV. RDS provisions TLS certificates by default — the setting ensures the connection is encrypted.

Local development (Docker Compose) is unaffected because IS_CLOUD_ENV=False.

Config: campuscore_app/campus_core/settings.pyOPTIONS dict in DATABASES.

HTTPS and security headers

In cloud environments (IS_CLOUD_ENV=True), Django enforces:

Setting Value Purpose
SECURE_SSL_REDIRECT True HTTP → HTTPS redirect
SECURE_REDIRECT_EXEMPT [health_check, readiness] ALB/ECS health probes use HTTP
SESSION_COOKIE_SECURE True Session cookies only sent over HTTPS
CSRF_COOKIE_SECURE True CSRF cookies only sent over HTTPS
SESSION_COOKIE_HTTPONLY True Prevents JavaScript access to session cookie
SECURE_HSTS_SECONDS 31536000 (1 year) Strict Transport Security header
SECURE_HSTS_INCLUDE_SUBDOMAINS True HSTS applies to all subdomains

SECURE_PROXY_SSL_HEADER is set to trust the ALB's X-Forwarded-Proto header, which prevents infinite redirect loops behind the load balancer.

The load balancer redirects ahead of Django: wherever an HTTPS listener exists, the port-80 listener's own default action is a 301 to port 443, so a plain-HTTP request is never forwarded to a task at all. An ssl_mode = off environment has no HTTPS listener, and its port-80 listener forwards instead - that is the only configuration in which the ALB serves application traffic over plain HTTP.

Note: HSTS preload is intentionally not enabled — preload list submission is near-irreversible (6+ month removal process). This will be reconsidered once all client domains are stable long-term.

Config: campuscore_app/campus_core/settings.pyIS_CLOUD_ENV gated block.

Network Edge Protection

Public ALBs receive continuous automated bot traffic: PHPUnit exploit probes, .env exfiltration attempts, EC2 IMDS SSRF probes, log4shell payloads. CampusCore attaches an AWS WAFv2 Web ACL to the ALB that drops these at the edge before they reach ECS. This reduces app-server load, eliminates the observability noise these requests would generate in Sentry, and prevents new dependency CVEs from being trivially exploitable against the public surface.

Web ACL rules

Rule Type What it catches WCUs
AWSManagedRulesCommonRuleSet AWS managed OWASP-style: SQLi, XSS, traversal, generic injection 700
AWSManagedRulesKnownBadInputsRuleSet AWS managed log4shell, IMDS SSRF, known exploit payloads 200
AWSManagedRulesPHPRuleSet AWS managed PHPUnit RCE probes, PHP injection patterns 100
AWSManagedRulesAmazonIpReputationList AWS managed Known-bad IP reputation list 25
RateLimitPerIP Custom rate-based >2000 requests per 5-min from a single IP 2

Total: ~1027 / 1500 WCU budget.

Config: infrastructure/modules/waf/main.tf, associated with the ALB in infrastructure/base/main.tf.

Tuning notes

  • SizeRestrictions_BODY (part of CommonRuleSet) is overridden to count instead of block — CampusCore's chat endpoints and document uploads legitimately exceed 8KB, so the default action would 403 real users. The override keeps the metric for visibility without blocking traffic.
  • New rule additions must fit within the 1500 WCU budget or the ACL won't validate. Check vendor_name + name group sizes in the AWS WAF console before adding.
  • Bot Control is intentionally not enabled: it would add ~700 WCUs plus $10/month + $1/M requests, and the basic managed rules already neutralize the noise we observe.

Request logging

Off by default (enable_logging = false in the WAF module). When enabled:

  • Sends per-request structured logs to a CloudWatch log group named aws-waf-logs-campuscore-{env} (the aws-waf-logs- prefix is required by WAF's PutLoggingConfiguration API)
  • The logging filter is default_behavior = "DROP" with KEEP for BLOCK and COUNT actions — legitimate ALLOW traffic never reaches the log group, keeping cost bounded
  • Use only during active incident triage; CloudWatch metrics + the WAF console's sampled-requests view cover routine operations

Cost

Approximately $10/month per environment at pilot scale: $5 Web ACL + $5 for five rules + $0.60 per million requests inspected. Logging adds $0.50/GB ingest and $0.03/GB storage when enabled (typically under $3/month even during incidents).

AWS Security Posture Services

An optional Terraform bundle behind one gate: enable_full_aws_security_services, fed from the ENABLE_FULL_AWS_SECURITY_SERVICES GitHub Environment variable, default off. Production client environments run with it on - that is a named onboarding step, and scripts/onboard-client.sh forces an explicit decision rather than accepting silence. Each service is its own module - infrastructure/modules/guardduty/, aws_config/, security_hub/, and inspector/ - instantiated together behind the gate in infrastructure/base/security_services.tf:

  • GuardDuty - threat detection over CloudTrail events, VPC flow logs, and DNS logs, plus S3 Protection, RDS Protection, and Runtime Monitoring (an agent sidecar drawing on each ECS task's existing CPU/memory allocation; agent management is also on for EC2, so an instance the account grows is covered from the moment it launches).
  • Malware Protection for S3 - scans each new object in the data_files bucket, the presigned-upload bucket where untrusted files enter. The verdict lands as an object tag and infected uploads surface as GuardDuty findings.
  • AWS Config recording - the evaluation source most Security Hub controls depend on; without it the standard subscribes and reports nothing. Snapshots deliver to the log_archive bucket under config/.
  • Security Hub - subscribed to the AWS Foundational Security Best Practices standard, aggregating GuardDuty, Config, and Inspector findings into one posture score.
  • Inspector - continuous CVE scanning of the web and worker container images in ECR, and of any EC2 instance or Lambda function the account grows. The module carries suppression filters for CVEs no rebuild can fix: Debian-stable packages whose fixes ship only in testing/unstable, plus supplier-disputed advisories with no fixed release. Each filter entry documents its package and why the vulnerable path is unreachable in our images, and entries are removed when a fix ships - the next monthly base rebuild then closes the finding for real. Every entry is scoped to container images, so a suppression can never silence the same CVE on an instance or a function, where the reasoning does not hold.

The same gate carries two things that are not modules, because each is a single setting with nothing to parameterise:

  • Account-wide hardening (infrastructure/base/account_hardening.tf) - the S3 account-level public-access block, EBS encryption by default, snapshot public-access blocking, SSM document public sharing disabled, an IAM password policy (14 characters, all four character classes, 24 remembered, 90-day expiry), and the account's SECURITY alternate contact. The contact is set only when the environment configures both an operator email and a phone number, which is what AWS requires to accept one. These are account settings rather than resource settings, so an account governed by an AWS Organization is deliberately left alone.
  • CloudTrail encryption and monitoring (infrastructure/base/cloudtrail.tf) - the trail encrypts its stream with a rotating customer-managed key whose policy lets CloudTrail encrypt only for this account's own trail and lets account principals decrypt only what that trail wrote, and mirrors its events into a CloudWatch log group (365-day retention) through a role that can create streams and put events into that one group and nothing else. The S3 copy remains the archive; the log group is what makes recent activity queryable and alarmable within minutes.

Findings are viewed in the AWS consoles (GuardDuty, Security Hub); nothing is wired into the alarm SNS topic yet.

Application-Level Encryption

Connector credentials

External service credentials (OAuth tokens, API keys for Google Drive, Canvas, etc.) are encrypted at the application layer using Fernet symmetric encryption before storage in the database.

Encryption-on-store is enforced at the model boundary, not by call-site convention: Connection.save() and SSOProvider.save() reject any secret value that is not structurally Fernet ciphertext, so a plaintext write raises instead of persisting.

  • Implementation: apps/connectors/services/secrets.pyencrypt_payload(), decrypt_payload(), is_encrypted_payload()
  • Key: APP_FERNET_KEY environment variable, generated via python manage.py generate_fernet_key
  • Scope: Connection.credentials (accessed only via the model's set_credentials / decrypt_credentials / get_credentials), ConnectorConfig.config_value (when is_secret=True), and SSOProvider.x509_cert / oidc_client_secret

Sensitive Data Handling

CampusCore handles data subject to FERPA (Family Educational Rights and Privacy Act):

What counts as sensitive: - Student PII (names, emails, student IDs, enrollment data) - Authentication credentials (passwords, session tokens, SAML assertions) - Chat conversations (may contain personal questions about financial aid, grades, health services) - FERPA-protected education records (grades, disciplinary records, financial aid) - Knowledge base content sourced from non-public university systems

Rules: - Sensitive data must not appear in logs, error messages, or stack traces - Connector credentials must use the Fernet encryption layer, never plaintext DB fields - Views handling sensitive data must use appropriate auth decorators (@htmx_login_required, @registered_user_required, @htmx_superuser_required, or the role/feature decorators in campus_core/decorators.py)

User identity in agent prompts and traces (deliberate surface)

The agent's per-request dynamic context carries the signed-in user's display name, role names, and department (build_user_identity_section in campuscore_app/prompts/prompt_builder.py), so those three fields appear wherever prompts are stored: the Postgres trace store (OTelSpan attributes and llm_input events) and Sentry transactions. This is an accepted, decision-logged surface (issue #169): the fields are the minimum needed for a personalized assistant, and the trace store is superuser-gated with audited reads. Sentry's prompt-field truncation does not mitigate this data - the ## User Identity block leads the dynamic message, so it sits inside the kept prefix. student_id, external_id, sso_provider, and email are excluded by construction, pinned by TestUserIdentitySectionRendering in campuscore_app/apps/main_app/tests/test_agent_harness_contracts.py - extending the identity section with any new profile field must keep that test's exclusion list honest.

FERPA scrubbing for Sentry events

When Sentry is enabled, error events, transactions, and logs are routed through three before_send_* hooks that redact and truncate before the SDK ships them off-tenant. The scrubber is the last line of defense against PII reaching Sentry — code review is still expected to keep PII out of log calls and exception messages at the source.

Location: campuscore_app/campus_core/observability/sentry_scrubber.py. Wired in sentry_setup.py::init_sentry.

Hook Sentry data path What this scrubber does
before_send Error events + breadcrumbs Walks extra, contexts, tags, breadcrumbs, request, exception dicts and runs every string value through the redaction regexes. Drops events whose request URL contains a path in _DROP_PATH_PREFIXES (/health_check, /static/, /favicon.ico).
before_send_log Sentry Logs product entries Scrubs the log body plus attributes.
before_send_transaction Performance traces Scrubs every span's data dict and description.

Redaction patterns (compiled once at import time, applied to every string value): - Email-shaped: [A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,} - Phone-shaped: 10-digit North American with separators and optional +1 prefix - SSN-shaped: \d{3}-\d{2}-\d{4}

Matches are replaced with the literal token [redacted]. The patterns are intentionally broad — over-redaction in Sentry is preferable to leaking PII.

Redacted keys (_REDACT_KEYS, matched on the key name at any depth, the whole value dropped): feedback_text, the prose a user typed about an answer, and data_preview, the connector-read slice a stored agent trace carries for cross-turn recall.

Field truncation (large free-text fields capped to a byte ceiling before ship): - gen_ai.prompt, campuscore.agent.system_prompt, campuscore.agent.dynamic_context → 2,000 chars - gen_ai.tool.input, gen_ai.tool.output, tool.arguments, tool.result_content → 1,500 chars

This keeps Sentry payloads under their 100 KB/event limit and avoids leaking long prompts containing student data into the Sentry UI.

Extending the scrubber: add a new pattern to the constants at the top of the file (_EMAIL_RE, _PHONE_RE, etc.) or add an entry to _TRUNCATE_FIELDS. Keep regexes compiled at module level — before_send_log runs on Sentry's background worker thread for every log; a slow scrubber backs up the queue and causes drops.

Defensive posture: the scrubber should never raise on a malformed log. If a future change introduces a code path that could raise, wrap the scrubber body in try/except returning the original payload on failure — losing event detail is better than silently dropping events. See Sentry Setup → Troubleshooting for diagnosing scrubber drops.

Audit checklist for code review

When reviewing code that touches logs or Sentry: - New logger.error(...) or logger.warning(...) calls — do they format student data into the message? - New extra={...} kwargs on log calls — do they pass raw FERPA fields? - New sentry_sdk.capture_exception() or capture_message() calls — would they include sensitive request data? - Exception messages raised from views handling student data — should they be sanitize_error'd before re-raising?

The scrubber will catch obvious leaks, but the goal is to not need it.

Access model: operators and institution admins

CampusCore has exactly two administrative tiers, and one marker for each.

An institution admin is a regular account holding the institution_admin role, granted by SSO group mapping or a manual role grant. Their entire surface is the SPA: every workspace Settings tab - users and role management, features, branding, knowledge, SSO, custom domains, workspace connectors, analytics, audit log - gated by can_manage_workspace (apps/auth/services/role_service.py), which answers "operator OR institution_admin role". Workspace settings are institution self-service end to end; institutions keep the admin set small through who holds the role, and the last-admin guards stop a workspace from stripping its final administrator. They never see Django admin or any /cc_admin/ tool.

A CampusCore operator is marked solely by is_superuser. The only account that ever carries it is the bootstrap superuser created by ensure_superuser at boot, and its credentials are never shared with institutions. Operators get everything institution admins get, plus the operator surfaces: every /cc_admin/ tool (gated by @htmx_superuser_required, which audits denials), the Django admin at /cc_admin/django/ (an AdminSite subclass in campus_core/admin_site.py whose has_permission and login form both answer is_operator), and the setup wizard.

is_staff means nothing. No gate reads it, no code path sets it except create_superuser (which sets both flags), and a stray is_staff=True on an account grants no access anywhere. This is pinned by campus_core/tests/test_operator_gate_sweep.py, which walks every top-level /cc_admin/ URL pattern asserting a staff-not-superuser user is refused (the Django admin's own sub-routes all pass through the site's has_permission, so the mount point stands in for them).

The auth vocabulary is typed, in one place: AuthMode (campus_core/api/auth.py) pins auth= to session (any signed-in user), workspace_admin (can_manage_workspace), superuser (operators, via role_service.is_operator), public_access (guest chat feature), and none (bootstrap only) - a misspelled mode fails ty, not runtime; HTMX surfaces use @htmx_login_required / @htmx_superuser_required / @roles_required / @feature_required, and every operator gate answers the single is_operator predicate.

Audit logging (SOC 2 CC7.x)

The audit posture is real, enforced controls - the deep doc is audit-logging.md.

  • Application events: every security-relevant action writes an AuditLog row (actor, action, resource, outcome, IP, request id); security-critical events write synchronously inside the acting transaction and fail the action if the write fails.
  • Institution admins read the trail in Settings > Audit Log (workspace-admin only, no feature flag) and can export any filtered view as CSV; reads and exports are themselves audited.
  • Immutability: a daily job exports each complete UTC day as JSONL to a per-tenant S3 bucket with Object Lock (COMPLIANCE, 365d); Postgres keeps 400 days hot and never prunes an unarchived day.
  • AWS layer, per tenant account (all Terraform-managed): CloudTrail (multi-region, log-file validation, S3 data events on the data buckets, and - with the security posture bundle on - a customer-managed encryption key plus CloudWatch Logs delivery), ALB access logs, S3 server access logs, RDS pgaudit + postgresql log export (365d retention), WAF logging, VPC flow logs.
  • Deploy audit: each successful deploy pushes an annotated deploy-<client>-<run> tag naming the actor and SHA, plus a Slack record.
  • Vendor access: staff use of the /cc_admin/ data viewers (conversations, documents, traces) writes staff_viewed_* events - "when did CampusCore staff look at our data" is answerable from the client's own audit log.
  • Usage analytics (Settings > Analytics, analytics.md) is aggregate-only by design: nothing on it goes finer than role, person-level activity stays in the audit log, and viewing/exporting analytics writes its own audit rows.

CI Enforcement

Terraform encryption policy check

scripts/check-encryption-policies.sh runs on every PR that touches infrastructure/ via .github/workflows/infra-validation.yml. It fails the build if:

  • Any aws_s3_bucket resource lacks a matching aws_s3_bucket_server_side_encryption_configuration
  • Any aws_sqs_queue resource lacks sqs_managed_sse_enabled
  • Any aws_db_instance resource lacks storage_encrypted

Coding agent enforcement

CLAUDE.md carries a short "SOC 2 (always in force)" invariant summary in every session and points here (this doc is the canonical home) whenever a change touches storage, logging, PII, auth, or any AWS resource. This ensures the coding agent considers encryption and data protection requirements when writing new infrastructure or application code.

Adding New AWS Resources

When adding new storage resources to Terraform:

  1. S3 buckets — add an aws_s3_bucket_server_side_encryption_configuration resource (AES-256) and an aws_s3_bucket_public_access_block
  2. RDS instances — include storage_encrypted = true
  3. SQS queues — include sqs_managed_sse_enabled = true
  4. New storage types (DynamoDB, EFS, etc.) — enable encryption at rest using AWS-managed keys
  5. Public-facing load balancers or CloudFront distributions — attach an aws_wafv2_web_acl_association to the existing WAF Web ACL (REGIONAL scope for ALBs, CLOUDFRONT scope for distributions). Public ingress without WAF is a SOC 2 finding.

Use AWS-managed keys unless there's a specific requirement for customer-managed KMS keys. The CI policy check will catch missing encryption configs before merge.