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, in every environment | 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 |
| Application secrets | SSM SecureString parameters under /campuscore/<env>/, encrypted with the AWS-managed alias/aws/ssm key. The ECS agent fetches each one with the task execution role at task boot, so a task definition carries the parameter ARN and never the credential. The aws/ssm key policy lets account principals decrypt through SSM, so no IAM policy grants kms:Decrypt on every key |
infrastructure/app/container_secrets.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.py — OPTIONS 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.
Wherever an HTTPS listener exists, the load balancer serves HTTPS only: it has no port-80 listener and its security group does not open port 80, so plain HTTP does not connect at all.
The ecs_cluster module's https_only variable controls this, and the base layer sets it from ssl_mode.
An ssl_mode = off environment has no HTTPS listener, and its port-80 listener forwards to the tasks - that is the only configuration in which the ALB serves application traffic over plain HTTP.
The HTTPS listener uses ELBSecurityPolicy-TLS13-1-2-Res-PQ-2025-09, one of the policies Security Hub ELB.17 accepts.
It offers TLS 1.3, keeps TLS 1.2 for older clients with only forward-secret AES-GCM suites, and adds post-quantum key exchange for clients that offer it.
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.py — IS_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 ofCommonRuleSet) 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 + namegroup 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}(theaws-waf-logs-prefix is required by WAF'sPutLoggingConfigurationAPI) - The logging filter is
default_behavior = "DROP"withKEEPforBLOCKandCOUNTactions — legitimateALLOWtraffic 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_filesbucket, 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_archivebucket underconfig/. - 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 one thing that is not a module, because it is a set of single settings 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, SSM Automation script output logged to CloudWatch, VPC Block Public Access, an IAM password policy (14 characters, all four character classes, 24 remembered, 90-day expiry), and the account's SECURITY alternate contact. VPC Block Public Access blocks internet-gateway traffic in both directions for every VPC in the region, with an exclusion that keeps the CampusCore VPC reachable. The exclusion is created before the block and removed after it, so the app VPC is never blocked. 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¶
Every environment's trail is encrypted and streamed to CloudWatch Logs, whatever the security posture gate says (infrastructure/base/cloudtrail.tf).
Security Hub CloudTrail.2 and CloudTrail.5 fail a trail without them, and together they cost under $2 a month per environment.
- The trail encrypts its stream with a rotating customer-managed key. The key policy lets CloudTrail encrypt only for this account's own trail, and lets account principals decrypt only what that trail wrote.
- The trail mirrors its events into a CloudWatch log group with 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.
- The key and the log group carry
prevent_destroy, because losing either loses audit history.
Seeing the findings¶
GuardDuty, Security Hub and Inspector findings are viewed in the AWS consoles, and nothing is wired into the alarm SNS topic. For the vsu-troy-pilot environment, a daily GitHub Actions report also reads them, because the pilot is our own account and a fix that clears there reaches every client with the code.
The report, .github/workflows/security-findings-report.yml, runs each day at 12:00 UTC and on demand, and scripts/security_findings_report.py does the work.
- It reads three sources: Security Hub's active failed findings, Inspector's findings on the images the ECS services run, and an osv-scanner audit of the lockfiles of the running commit (
campuscore_app/uv.lockandcampuscore_app/web/pnpm-lock.yaml). - It keeps one open GitHub issue labeled
security-findings. The issue lists what to act on, then the known findings collapsed with the reason each one is known, then any source that could not be read. A comment names what became actionable and what was resolved since the last run, and the issue closes once nothing is left to act on and every source was read. - It posts one Slack message per run to the environment's
SLACK_CHANNEL_WORKFLOW_RUNS, with the counts per source, the new items and the issue number. - A finding is known when
scripts/security_findings_known.jsonrecords a decision for it, or when no fix has been released for it. That file is the one home for decided findings, and the issue and the Slack message say when an entry matched nothing, so a stale decision shows up even while the issue is closed. - It reaches the pilot through two roles that can do nothing else:
CampusCore-Findings-Reporter-Rolein the admin account, which trusts only this workflow onmain, and the read-onlyCampusCore-Findings-Reader-Rolein the pilot account (Admin Account Setup). The job that runs the downloaded osv-scanner binary holds no AWS credentials.
Each run records a deployment on the vsu-troy-pilot environment beside the real deploys, as the workflow's header comment explains, so a branch policy added to that environment later would stop the report.
Container Runtime and Images¶
Every container in every ECS task definition runs as an unprivileged user on a read-only root filesystem.
- The app images create user
appwith uid and gid 10001 and setUSER 10001:10001, and each task definition sets the sameuser, which Security Hub ECS.20 reads. readonlyRootFilesystemis on for every container (ECS.5), so a compromised process cannot change the code or install tools./tmpis the one writable path: a task volume that copies the image'sVOLUME ["/tmp"], so uid 10001 can write there.HOMEis/tmp, so tools that write under the home directory have somewhere to write.- One local in
infrastructure/app/main.tf,container_runtime_hardening, carries all three settings, and every container definition merges it.scripts/check_deploy_contract.pyfails the build if a task definition skips the merge or overrides it, or if a Dockerfile'sUSERdrifts from the local.
Both ECR repositories use IMMUTABLE tags (ECR.2), so a pushed tag always names the same image.
The deploy pushes only the commit tag for app images and the content hash for base images, and skips an app build when its commit tag already exists, so a re-run of the same commit succeeds.
Two deploy runs of one client never overlap, because the whole workflow run sits in one per-client concurrency group that a push to deploy/<client> and a manual run for <client> both name.
Database Monitoring¶
Both RDS instances, the primary and the optional read replica, publish enhanced monitoring metrics every 60 seconds (RDS.6).
These are OS-level metrics, such as CPU per process, memory and disk I/O, which RDS writes to the account's RDSOSMetrics log group through a monitoring role in the RDS module.
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.
ConnectorConfig.save() enforces the same pairing from both sides, because its column holds a credential only when the row says so: a secret row's plaintext is encrypted, and ciphertext on a row that is not secret raises.
- Implementation:
apps/connectors/services/secrets.py—encrypt_payload(),decrypt_payload(),is_encrypted_payload() - Key:
APP_FERNET_KEYenvironment variable, generated viapython manage.py generate_fernet_key. Validated at settings load by building a cipher from it, so a key that is absent, empty, or malformed stops the process at boot rather than failing at the first credential write. - Scope:
Connection.credentials(written throughConnection.store_credentials, read by adapters throughBaseConnector.read_credentials),ConnectorConfig.config_value(whenis_secret=True), andSSOProvider.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)
Retention of chat content¶
Conversations and messages are kept for the life of the deployment. No scheduled job, cascade, or database trigger deletes one: the only deletions are a person's own, through the signed-in delete control, the tail drop when someone edits a question and re-runs it, and Django admin. That is deliberate - a deployment's record of what was asked belongs to the institution, and a guest thread losing its browser session makes it unreachable from that browser without making it worthless.
The consequence for FERPA is that chat content has no expiry to rely on, so the protections that matter are the access ones: reads are owner-scoped, the operator viewer is superuser-gated with audited reads, and the Sentry scrubber keeps the content off-tenant.
What is pruned daily is django_session (prune_expired_sessions), which holds authentication records rather than content.
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, threads, exception dicts and runs every string value through the redaction regexes. threads carries the stack frames of an event raised without an exception attached, which is what a bare logger.error produces. 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 values (_SECRET_VALUE_RE) are the layer that does not depend on a name.
init_sentry compiles every secret the typed Settings carries, and _scrub_text removes those strings from every value it walks, whatever key they arrived under.
This is what covers a credential a third-party library copied somewhere we do not name: LiteLLM sends the Gemini key as an x-goog-api-key header and bakes the OpenAI key into a functools.partial whose repr is one flat string with no key in it at all.
Settings.secret_values() is the source, so the same deploy(secret=True) flag that masks a field in the model's repr also redacts its value on an event.
A secret shorter than eight characters is left out, because matching on a short string would blank out unrelated text and cost the operator the context around the error.
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; data_preview, the connector-read slice a stored agent trace carries for cross-turn recall; and every name in CREDENTIAL_NAMES.
Credential names (CREDENTIAL_NAMES in sentry_scrubber.py) are the one list two scrubbers share. sentry_setup.py hands it to Sentry's own EventScrubber, which is the layer that reads stack-frame locals, and _REDACT_KEYS applies it to the sections before_send walks. That scrubber is configured with recursive=True as well, because the SDK does not descend into dictionaries by default and a provider key travels inside one. A test walks every registered connector's connection_schema and fails when a field marked is_secret appears in neither this list nor Sentry's own denylist, so the list cannot silently fall behind a new connector.
The list also carries names nothing of ours declares - aws_secret_access_key, which the S3 adapter hands botocore, and x-goog-api-key - because a credential is renamed on its way out of the process, and the name it leaks under is the library's, not ours.
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.), an entry to _TRUNCATE_FIELDS, or - for anything that authenticates - a name to CREDENTIAL_NAMES.
A new credential that is a Settings field needs nothing added: marking it deploy(secret=True) puts its value in the redaction set already.
Reach for a name only when the value is not ours to know at boot, such as a credential an operator submits through a connection form. 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.
The operator login form is rate limited to five submissions per client address per fifteen minutes, counted before the password is checked, so a spent window refuses a correct password as readily as a wrong one.
Both refusals the form makes write an AuditAction.LOGIN_FAILED row with outcome=denied and a reason in details: rate_limited for the spent window, and not_operator for a correct password from an account that is not an active superuser.
Nothing else records the second refusal: authenticate() has already succeeded by then, so Django's user_login_failed signal never fires.
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
AuditLogrow (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, a customer-managed encryption key and CloudWatch Logs delivery), ALB access logs, S3 server access logs, RDS pgaudit +
postgresqllog 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) writesstaff_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_bucketresource lacks a matchingaws_s3_bucket_server_side_encryption_configuration - Any
aws_sqs_queueresource lackssqs_managed_sse_enabled - Any
aws_db_instanceresource lacksstorage_encryptedormonitoring_interval
campuscore_app/apps/main_app/tests/test_deploy_contract.py also fails the build if any IAM policy in infrastructure/ or the deploy role grants a KMS data-key action, such as kms:Decrypt, on every key, if an HTTPS listener uses a TLS policy ELB.17 does not accept, if a resource in account_hardening.tf is not behind the security posture gate, or if the CloudTrail key, log group or delivery role is put behind any switch.
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:
- S3 buckets - add an
aws_s3_bucket_server_side_encryption_configurationresource (AES-256) and anaws_s3_bucket_public_access_block - RDS instances - include
storage_encrypted = trueand a non-zeromonitoring_intervalwith the module's monitoring role - SQS queues - include
sqs_managed_sse_enabled = true - New storage types (DynamoDB, EFS, etc.) - enable encryption at rest using AWS-managed keys
- Public-facing load balancers or CloudFront distributions - attach an
aws_wafv2_web_acl_associationto the existing WAF Web ACL (REGIONALscope for ALBs,CLOUDFRONTscope 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.