diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59cffc4..b3ac582 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: pip install black flake8 - name: Setup Terraform - uses: hashicorp/setup-terraform@v3 + uses: hashicorp/setup-terraform@v4 with: terraform_version: 1.5.7 @@ -37,6 +37,22 @@ jobs: - name: Terraform validate run: terraform -chdir=infra/environments/dev validate + + - name: Setup tflint + uses: terraform-linters/setup-tflint@v6 + + - name: tflint + run: | + cd infra + tflint --init + tflint --recursive --minimum-failure-severity=error + + - name: tfsec + uses: aquasecurity/tfsec-action@v1.0.3 + with: + working_directory: infra + additional_args: --minimum-severity HIGH + - name: Secrets and hygiene check run: sh scripts/security/check_secrets.sh diff --git a/README.md b/README.md index 9a9ac96..5338ea8 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,21 @@ # SecureDocs AWS +[![CI](https://github.com/giselleevita/secure-docs-aws/actions/workflows/ci.yml/badge.svg)](https://github.com/giselleevita/secure-docs-aws/actions/workflows/ci.yml) + _A security-focused document storage service on AWS that teaches IAM, S3, KMS, CloudTrail, and ownership-enforcement patterns._ +```mermaid +flowchart LR + U["Client"] -->|"JWT"| APIGW["API Gateway
(Cognito JWT authorizer)"] + APIGW --> UP["Lambda: upload"] + APIGW --> LS["Lambda: list"] + APIGW --> DL["Lambda: download"] + APIGW --> DEL["Lambda: delete"] + UP & LS & DL & DEL -->|"owner_id check"| DDB[("DynamoDB
metadata")] + UP & DL & DEL -->|"presigned URL (5 min)"| S3[("S3
private + SSE-KMS + versioned")] + APIGW & UP & LS & DL & DEL -->|"logs"| CT["CloudTrail + CloudWatch Logs"] +``` + ## Overview SecureDocs AWS is a serverless document service where authenticated users can upload, list, download, and delete only their own files. The project is deliberately small, but it exercises the core security controls that matter in a multi-user cloud system. diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index e69de29..4dec908 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -0,0 +1,35 @@ +# System Overview + +SecureDocs AWS is a serverless, single-tenant-per-user document store. Every +request is authenticated and every object access is authorized against the +caller's identity before any data is returned. + +## Request flow + +1. **Authenticate** — the client presents a Cognito-issued JWT. API Gateway's + JWT authorizer validates it before any Lambda runs. +2. **Route** — API Gateway dispatches to one of four purpose-built Lambdas: + `upload`, `list`, `download`, `delete`. Each has its own IAM role scoped to + only the actions it needs. +3. **Authorize ownership** — the Lambda looks up file metadata in DynamoDB + keyed by `owner_id` + `object_key`, and refuses (403) any access to an object + the caller does not own. +4. **Access storage** — instead of proxying bytes or handing out S3 credentials, + the Lambda returns a short-lived (5-minute) presigned URL. The bucket blocks + public access, encrypts at rest with SSE-KMS, and keeps object versions. +5. **Audit** — API Gateway, Lambda, and S3 activity is captured by CloudTrail + and CloudWatch Logs. + +## Components + +| Component | Role | +|---|---| +| Cognito | User identity and JWT issuance | +| API Gateway | JWT authorizer, routing | +| Lambda (×4) | Per-operation handlers, least-privilege IAM roles | +| DynamoDB | Ownership metadata (`owner_id`, `object_key`) | +| S3 | Encrypted, private, versioned object storage | +| KMS | Encryption keys for S3 objects | +| CloudTrail + CloudWatch | Audit trail | + +See [decisions.md](./decisions.md) for the security rationale behind each choice. diff --git a/infra/SECRETS_PATTERN.py b/infra/SECRETS_PATTERN.py deleted file mode 100644 index 27db67a..0000000 --- a/infra/SECRETS_PATTERN.py +++ /dev/null @@ -1,113 +0,0 @@ -# Python Lambda Handler — Secrets Manager Integration Pattern -# -# This snippet demonstrates how to read a secret from AWS Secrets Manager -# without storing credentials in code or environment files. -# -# Usage: Place the secret ARN in a Lambda environment variable, then call -# get_secret() at the beginning of your handler. -# -# Example Lambda deployment with secret ARN: -# environment { -# variables = { -# SECRET_ARN = aws_secretsmanager_secret.example.arn -# } -# } - -import json -import os -import boto3 - -secrets_client = boto3.client("secretsmanager") - -def get_secret(secret_arn: str) -> dict: - """ - Retrieve a secret from AWS Secrets Manager using its ARN. - - Args: - secret_arn: ARN of the secret to retrieve - - Returns: - dict: Parsed secret value (assumes JSON format) - """ - try: - response = secrets_client.get_secret_value(SecretId=secret_arn) - secret_value = json.loads(response["SecretString"]) - return secret_value - except Exception as e: - print(f"Error retrieving secret: {e}") - raise - - -def handler(event, context): - """ - Lambda handler that reads a database password from Secrets Manager. - """ - # Get the secret ARN from environment variable - secret_arn = os.environ.get("DB_SECRET_ARN") - - # Retrieve the secret (API key, database password, etc.) - secret = get_secret(secret_arn) - - # Extract specific key from secret - db_password = secret.get("password") - - # Use the secret in your business logic - # DO NOT log or print the secret value - - return { - "statusCode": 200, - "body": json.dumps({"message": "Secret retrieved successfully"}) - } - - -# ───────────────────────────────────────────────────────────────────────────── -# Corresponding Terraform Definitions: -# -# # Create a secret in AWS Secrets Manager -# resource "aws_secretsmanager_secret" "db_password" { -# name = "secure-docs/db-password" -# description = "Database password for SecureDocs" -# recovery_window_in_days = 7 -# } -# -# # Store the actual secret value -# resource "aws_secretsmanager_secret_version" "db_password" { -# secret_id = aws_secretsmanager_secret.db_password.id -# secret_string = jsonencode({ -# password = var.db_password # Injected via terraform apply -var or .tfvars -# username = "dbuser" -# }) -# } -# -# # Add permission to Lambda IAM role to read this secret -# resource "aws_iam_role_policy" "lambda_secrets_access" { -# name = "lambda-secrets-access" -# role = module.lambda_roles.upload_role_arn -# policy = jsonencode({ -# Version = "2012-10-17" -# Statement = [ -# { -# Effect = "Allow" -# Action = [ -# "secretsmanager:GetSecretValue" -# ] -# Resource = aws_secretsmanager_secret.db_password.arn -# } -# ] -# }) -# } -# -# # Lambda function with secret ARN in environment -# resource "aws_lambda_function" "example" { -# function_name = "lambda-example" -# runtime = "python3.12" -# handler = "lambda_function.handler" -# role = module.lambda_roles.upload_role_arn -# filename = "lambda_function.zip" -# -# environment { -# variables = { -# SECRET_ARN = aws_secretsmanager_secret.db_password.arn -# } -# } -# } diff --git a/infra/environments/dev/.terraform.lock.hcl b/infra/environments/dev/.terraform.lock.hcl index a3683ad..4788d58 100644 --- a/infra/environments/dev/.terraform.lock.hcl +++ b/infra/environments/dev/.terraform.lock.hcl @@ -2,44 +2,46 @@ # Manual edits may be lost in future updates. provider "registry.terraform.io/hashicorp/archive" { - version = "2.7.1" + version = "2.8.0" constraints = "~> 2.0" hashes = [ - "h1:A7EnRBVm4h9ryO9LwxYnKr4fy7ExPMwD5a1DsY7m1Y0=", - "zh:19881bb356a4a656a865f48aee70c0b8a03c35951b7799b6113883f67f196e8e", - "zh:2fcfbf6318dd514863268b09bbe19bfc958339c636bcbcc3664b45f2b8bf5cc6", - "zh:3323ab9a504ce0a115c28e64d0739369fe85151291a2ce480d51ccbb0c381ac5", - "zh:362674746fb3da3ab9bd4e70c75a3cdd9801a6cf258991102e2c46669cf68e19", - "zh:7140a46d748fdd12212161445c46bbbf30a3f4586c6ac97dd497f0c2565fe949", + "h1:WB6H5ksIZiyq1lQlD/PWeh+tn4FLsbSjVnRW3+4xe2Y=", + "zh:0d14713fdc259fb377d0b899ad3c650a34194bd52194c863303ef22a65a580e2", + "zh:369b56040c7a8085d04e7e8ffac1e2b321a3170e502f788819bc34b868ec016f", + "zh:4d1a3b983ed6af5a52bfe12794674ae55cbadfa6021b37106ade68b433ad216a", + "zh:5c547549e26e083573c78a966ca68ce6d7df6bb8f3948f66a575f07da46b74ea", + "zh:6de093e62a975eb19a5e3017ce38e6e3cb639c17b79648d2000e0a8348f0e997", + "zh:7267936c2cdbc448efeb594d73e6b56a53d6a7ae14fe88cdd2a4133adc3302f0", + "zh:7482f023050ed426b4b45116e1761643bc33b1fd4ce4a6fab207ae2571f35940", + "zh:76bbd93b234e5a2927d98b511d86565700f549b570871a194c35f944b96cefb7", "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", - "zh:875e6ce78b10f73b1efc849bfcc7af3a28c83a52f878f503bb22776f71d79521", - "zh:b872c6ed24e38428d817ebfb214da69ea7eefc2c38e5a774db2ccd58e54d3a22", - "zh:cd6a44f731c1633ae5d37662af86e7b01ae4c96eb8b04144255824c3f350392d", - "zh:e0600f5e8da12710b0c52d6df0ba147a5486427c1a2cc78f31eea37a47ee1b07", - "zh:f21b2e2563bbb1e44e73557bcd6cdbc1ceb369d471049c40eb56cb84b6317a60", - "zh:f752829eba1cc04a479cf7ae7271526b402e206d5bcf1fcce9f535de5ff9e4e6", + "zh:c6afc4bc1f002bac9c173007dd4da05fde788cd14c2916089f958c33fedb0dfa", + "zh:d3ba40bd806a3a08e9237dece679193c99afb2085de6b45d7f5d1f673cfcd368", + "zh:e1ad7ded53ecd6f0e5b473a3b44eae2b2e885653a56050ab583d387332be02e4", + "zh:e93e78575ce82be6084cc153c24ba8f385dc8d6880888ee66e918460c870953d", ] } provider "registry.terraform.io/hashicorp/aws" { - version = "6.38.0" + version = "6.50.0" constraints = "~> 6.0" hashes = [ - "h1:RDoKIzXmt7H1mNFcNIyRT+nA/gTJyO3+iW9QGN5I2eQ=", - "zh:143f118ae71059a7a7026c6b950da23fef04a06e2362ffa688bef75e43e869ed", - "zh:29ee220a017306effd877e1280f8b2934dc957e16e0e72ca0222e5514d0db522", - "zh:3a31baabf7aea7aa7669f5a3d76f3445e0e6cce5e9aea0279992765c0df12aee", - "zh:4c1908e62040dbc9901d4426ffb253f53e5dae9e3e1a9125311291ee265c8d8c", - "zh:550f4789f5f5b00e16118d4c17770be3ef4535d6b6928af1cf91ebd30f2c263b", - "zh:6537b7b70bf2c127771b0b84e4b726c834d10666b6104f017edae50c67ebae37", + "h1:D8uNiOpl3UkAX4zI5T47ALMiRFXTa1XfdQC+TBu3RmE=", + "zh:0072806bb262c6d86bc25b4a75750e469881144c14818afdba7b82db840e1588", + "zh:1ebc2dae335dad7a8b16a1985b69a63a14954282bb44fdba7d5103f77551ac7b", + "zh:2dab48fe8f3193b8216d578ac1e3674fa566435cc7dbce2953d55b72e31d0241", + "zh:2fc3d3029c2b7429472391ef339672e1fca8e6ff32c8a519bf3acedafa7e24fe", + "zh:38a36e64e7212f6cedac861ea4d449cce07131b3378de601bf9d49a99e000208", + "zh:3ac70758ed251ce78b7f541a5a79cc6fe56474412783ae1decef719bdd0f30bf", + "zh:4385d3903e685bddb2b8005b4eb7db89f030267d4d03c7d792d2f5e739cc874a", + "zh:4cce0760b87fbafd51f30faec2a737f4183b7c615f4a86557f7d3c893a610dc5", + "zh:4feaeed18694239b896c6415d9a1e5ef89e1da4f4ad60924aa0522adeb1f6599", + "zh:502fca2be1c95f443c3e67d0555601d1de65b4ca82d197c059e9c868360e3a0a", + "zh:57d037f6fdd045f2660909c3bdface9622d81165ce647479cba98d1f353c5eab", + "zh:5dc5a0b915c2ac5256d909458f5c8e40b35f78b3a36ea893c86624eaf6c54e37", "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", - "zh:af2f9cea0c8bdf5b2a2391f2d179a946c117196f7c829b919673cae3b71d2943", - "zh:c53ffa685381aa4e73158fd9f529239f95938dea330e7aca0b32e7b2a1210432", - "zh:d0995e1d64a7ec8bbc79fc3fbec3749f989e07f211a318705c37cd6a7c7d19e4", - "zh:d2348ffcffc1282983d7a5838dd5d61f372152fe6c0d10868cd6473352318750", - "zh:e449312efb73e4747165e689302a68a1df8ba5755e7f59097069acf82c94f011", - "zh:ec3a538d264ef79380e56fdf107ffb6c0446814f07fc5890c36855fe1e03196b", - "zh:f441e69699b22e32c96a8cdd3bbe694ed302c0dcfe867cd9bd683a16df362714", - "zh:f6f8eaa605ff902234d7e9bdab4fda977185fce14f8576f7b622c914c7d98008", + "zh:b84c87c58a320adbb2c74a4cad03ae5aac7f2eae21db26f00fdde98c8c4d4523", + "zh:c895f1d5cbcbeff77850ac99efd36bde0048d4e909b296882331b9b9ebf48cfa", + "zh:ead82831683619124597a1f170dd31e9b293e9cf22f558cb166d5e734fcd11e4", ] } diff --git a/infra/environments/dev/LAMBDA_VPC_PATCH.txt b/infra/environments/dev/LAMBDA_VPC_PATCH.txt deleted file mode 100644 index aec8a0b..0000000 --- a/infra/environments/dev/LAMBDA_VPC_PATCH.txt +++ /dev/null @@ -1,53 +0,0 @@ -# V3 Production Layer — Lambda VPC Integration -# -# Add the following subnet_ids and security_groups parameters to each -# aws_lambda_function resource in lambda.tf to place functions in the VPC: -# -# Example patch for lambda.tf: -# -# resource "aws_lambda_function" "upload_presigned" { -# # ... existing configuration ... -# vpc_config { -# subnet_ids = [aws_subnet.lambda_1.id, aws_subnet.lambda_2.id] -# security_group_ids = [aws_security_group.lambda.id] -# } -# } -# -# resource "aws_lambda_function" "list_files" { -# # ... existing configuration ... -# vpc_config { -# subnet_ids = [aws_subnet.lambda_1.id, aws_subnet.lambda_2.id] -# security_group_ids = [aws_security_group.lambda.id] -# } -# } -# -# resource "aws_lambda_function" "download_file" { -# # ... existing configuration ... -# vpc_config { -# subnet_ids = [aws_subnet.lambda_1.id, aws_subnet.lambda_2.id] -# security_group_ids = [aws_security_group.lambda.id] -# } -# } -# -# resource "aws_lambda_function" "delete_file" { -# # ... existing configuration ... -# vpc_config { -# subnet_ids = [aws_subnet.lambda_1.id, aws_subnet.lambda_2.id] -# security_group_ids = [aws_security_group.lambda.id] -# } -# } -# -# ───────────────────────────────────────────────────────────────────────────── -# -# The vpc_config placement allows Lambda to: -# - Access VPC endpoints for S3, DynamoDB, KMS, CloudWatch Logs, STS, Secrets Manager -# - Remain completely private (no internet connectivity) -# - Route all AWS API calls through the VPC endpoint security group -# -# Impact on cold start: -# - First invocation: +100-300ms (ENI attachment) -# - Subsequent calls: negligible latency increase -# -# Monitoring: -# - Check VPC Flow Logs in CloudWatch: /aws/vpc/secure-docs-flow-logs -# - Verify endpoint traffic in VPC Endpoint metrics (CloudWatch) diff --git a/infra/environments/dev/observability.tf b/infra/environments/dev/observability.tf index 2c51828..fda166b 100644 --- a/infra/environments/dev/observability.tf +++ b/infra/environments/dev/observability.tf @@ -114,6 +114,7 @@ resource "aws_kms_key_policy" "cloudtrail" { resource "aws_cloudtrail" "secure_docs" { name = "secure-docs-trail" s3_bucket_name = module.storage.bucket_name + kms_key_id = module.storage.kms_key_arn enable_log_file_validation = true include_global_service_events = true is_multi_region_trail = true diff --git a/infra/environments/dev/security/cloudtrail.tf b/infra/environments/dev/security/cloudtrail.tf index 900ab74..ac1f697 100644 --- a/infra/environments/dev/security/cloudtrail.tf +++ b/infra/environments/dev/security/cloudtrail.tf @@ -20,14 +20,64 @@ resource "aws_s3_bucket_server_side_encryption_configuration" "cloudtrail_logs" } } +resource "aws_kms_key" "cloudtrail" { + description = "CMK for CloudTrail trail and log-group encryption" + deletion_window_in_days = 7 + enable_key_rotation = true + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "EnableRootAccountAdmin" + Effect = "Allow" + Principal = { AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root" } + Action = "kms:*" + Resource = "*" + }, + { + Sid = "AllowCloudTrailEncrypt" + Effect = "Allow" + Principal = { Service = "cloudtrail.amazonaws.com" } + Action = ["kms:GenerateDataKey*", "kms:DescribeKey"] + Resource = "*" + Condition = { + StringLike = { + "kms:EncryptionContext:aws:cloudtrail:arn" = "arn:aws:cloudtrail:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:trail/secure-docs-aws-trail" + } + } + }, + { + Sid = "AllowCloudWatchLogsUse" + Effect = "Allow" + Principal = { Service = "logs.${data.aws_region.current.name}.amazonaws.com" } + Action = ["kms:Encrypt*", "kms:Decrypt*", "kms:ReEncrypt*", "kms:GenerateDataKey*", "kms:Describe*"] + Resource = "*" + Condition = { + ArnLike = { + "kms:EncryptionContext:aws:logs:arn" = "arn:aws:logs:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:log-group:/aws/cloudtrail/secure-docs-aws" + } + } + } + ] + }) +} + +resource "aws_kms_alias" "cloudtrail" { + name = "alias/secure-docs-cloudtrail" + target_key_id = aws_kms_key.cloudtrail.key_id +} + resource "aws_cloudwatch_log_group" "secure_docs_cloudtrail" { name = "/aws/cloudtrail/secure-docs-aws" retention_in_days = 365 + kms_key_id = aws_kms_key.cloudtrail.arn } resource "aws_cloudtrail" "secure_docs" { name = "secure-docs-aws-trail" s3_bucket_name = aws_s3_bucket.cloudtrail_logs.id + kms_key_id = aws_kms_key.cloudtrail.arn include_global_service_events = true is_multi_region_trail = false enable_log_file_validation = true diff --git a/infra/modules/dynamodb/main.tf b/infra/modules/dynamodb/main.tf index 9152363..f790369 100644 --- a/infra/modules/dynamodb/main.tf +++ b/infra/modules/dynamodb/main.tf @@ -14,6 +14,14 @@ resource "aws_dynamodb_table" "users" { type = "S" } + server_side_encryption { + enabled = true + } + + point_in_time_recovery { + enabled = true + } + tags = { Project = "secure-docs" Environment = var.environment diff --git a/tests/README.md b/tests/README.md index 72d95a7..ff6e9e3 100644 --- a/tests/README.md +++ b/tests/README.md @@ -11,14 +11,20 @@ All tests use synthetic, public-safe data and no real customer data or credentia ## How to run -- Run end-to-end verification scripts from the repository root: +Verification is manual against a deployed environment. Read the endpoint, +Cognito pool, and bucket values from `terraform output` for the target +environment (e.g. `infra/environments/dev`), obtain a Cognito JWT for a test +user, then exercise the API with `curl`: ```bash -sh scripts/verification/verify_secure_docs.sh -sh scripts/verification/test_secure_docs.sh -``` +# happy path — own file +curl -H "Authorization: Bearer $JWT" "$API/files" # 200, lists caller's files +# denial path — another user's object +curl -H "Authorization: Bearer $JWT" "$API/files/$OTHER_KEY" # 403 -- Use environment-specific values from `terraform output` for the target environment (e.g., `infra/environments/dev`). +# repo hygiene check (no deploy needed) +sh scripts/security/check_secrets.sh +``` - Confirm expected success paths and denial paths (e.g., cross-user access must return 403). ## Pass criteria