diff --git a/.github/workflows/ci-workflows.yml b/.github/workflows/ci-workflows.yml index f432654..75773a9 100644 --- a/.github/workflows/ci-workflows.yml +++ b/.github/workflows/ci-workflows.yml @@ -7,14 +7,6 @@ on: - 'docker-compose.yml' - 'infra/**' - '.github/workflows/ci-workflows.yml' - push: - branches: - - main - paths: - - 'tinyurl/**' - - 'docker-compose.yml' - - 'infra/**' - - '.github/workflows/ci-workflows.yml' workflow_dispatch: jobs: @@ -51,6 +43,14 @@ jobs: runs-on: ubuntu-latest needs: build-test + env: + POSTGRES_USER: tinyurl_ci + POSTGRES_PASSWORD: ci_smoke_postgres_pass + SPRING_DATASOURCE_USERNAME: tinyurl_appuser_ci + SPRING_DATASOURCE_PASSWORD: ci_smoke_appuser_pass + SPRING_FLYWAY_USER: tinyurl_ci + SPRING_FLYWAY_PASSWORD: ci_smoke_postgres_pass + steps: - name: Checkout repository uses: actions/checkout@v4 @@ -63,6 +63,14 @@ jobs: runs-on: ubuntu-latest needs: compose-validate + env: + POSTGRES_USER: tinyurl_ci + POSTGRES_PASSWORD: ci_smoke_postgres_pass + SPRING_DATASOURCE_USERNAME: tinyurl_appuser_ci + SPRING_DATASOURCE_PASSWORD: ci_smoke_appuser_pass + SPRING_FLYWAY_USER: tinyurl_ci + SPRING_FLYWAY_PASSWORD: ci_smoke_postgres_pass + steps: - name: Checkout repository uses: actions/checkout@v4 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..aae7039 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,158 @@ +name: Deploy API + +on: + push: + branches: [main] + paths: + - 'tinyurl/**' + - 'docker-compose.prod.yml' + - 'infra/**' + - '.github/workflows/deploy.yml' + workflow_dispatch: + +jobs: + build-test: + name: Build and Test + runs-on: ubuntu-latest + + defaults: + run: + working-directory: tinyurl + + steps: + - uses: actions/checkout@v4 + + - name: Set up Java 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + cache: gradle + + - name: Make Gradle wrapper executable + run: chmod +x gradlew + + - name: Run unit tests + run: ./gradlew --no-daemon clean test + + - name: Build executable JAR + run: ./gradlew --no-daemon bootJar + + compose-smoke: + name: Compose Smoke Test + needs: build-test + runs-on: ubuntu-latest + + env: + POSTGRES_USER: tinyurl_ci + POSTGRES_PASSWORD: ci_smoke_postgres_pass + SPRING_DATASOURCE_USERNAME: tinyurl_appuser_ci + SPRING_DATASOURCE_PASSWORD: ci_smoke_appuser_pass + SPRING_FLYWAY_USER: tinyurl_ci + SPRING_FLYWAY_PASSWORD: ci_smoke_postgres_pass + + steps: + - uses: actions/checkout@v4 + + - name: Build and start stack + run: docker compose up -d --build + + - name: Wait for nginx route health + run: | + for i in {1..40}; do + if curl -fsS http://localhost:8080/actuator/health >/dev/null; then + exit 0 + fi + sleep 3 + done + echo "Service did not become healthy in time" + docker compose logs --no-color + exit 1 + + - name: Stop stack + if: always() + run: docker compose down -v + + deploy: + name: Deploy to EC2 via SSM + needs: [build-test, compose-smoke] + runs-on: ubuntu-latest + permissions: + id-token: write # Required for OIDC + contents: read + packages: write # Required for GHCR push + + steps: + - uses: actions/checkout@v4 + + - name: Configure AWS credentials (OIDC) + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_ROLE_ARN }} + aws-region: us-east-1 + + - name: Fetch parameters from AWS Parameter Store + run: | + EC2_INSTANCE_ID=$(aws ssm get-parameter \ + --name "/tinyurl/cicd/ec2-instance-id" \ + --query "Parameter.Value" --output text) + RDS_ENDPOINT=$(aws ssm get-parameter \ + --name "/tinyurl/cicd/rds-endpoint" \ + --query "Parameter.Value" --output text) + echo "EC2_INSTANCE_ID=$EC2_INSTANCE_ID" >> $GITHUB_ENV + echo "RDS_ENDPOINT=$RDS_ENDPOINT" >> $GITHUB_ENV + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push Docker image + run: | + docker build -t ghcr.io/buffden/tinyurl-api:${{ github.sha }} tinyurl/ + docker push ghcr.io/buffden/tinyurl-api:${{ github.sha }} + + - name: Deploy via SSM RunCommand + run: | + COMMAND_ID=$(aws ssm send-command \ + --instance-ids "$EC2_INSTANCE_ID" \ + --document-name "AWS-RunShellScript" \ + --parameters "commands=[ + \"export IMAGE_TAG=${{ github.sha }}\", + \"export RDS_ENDPOINT=$RDS_ENDPOINT\", + \"cd /app\", + \"docker compose -f docker-compose.prod.yml pull\", + \"docker compose -f docker-compose.prod.yml up -d\", + \"sleep 20\", + \"curl -sf http://localhost/actuator/health || exit 1\" + ]" \ + --output text \ + --query "Command.CommandId") + + echo "SSM Command ID: $COMMAND_ID" + + for i in {1..18}; do + STATUS=$(aws ssm get-command-invocation \ + --command-id "$COMMAND_ID" \ + --instance-id "$EC2_INSTANCE_ID" \ + --query "Status" \ + --output text) + echo "Status: $STATUS ($i/18)" + if [ "$STATUS" = "Success" ]; then + echo "Deploy succeeded" + exit 0 + elif [ "$STATUS" = "Failed" ] || [ "$STATUS" = "TimedOut" ] || [ "$STATUS" = "Cancelled" ]; then + echo "Deploy failed with status: $STATUS" + aws ssm get-command-invocation \ + --command-id "$COMMAND_ID" \ + --instance-id "$EC2_INSTANCE_ID" \ + --query "StandardErrorContent" \ + --output text + exit 1 + fi + sleep 10 + done + echo "Deploy timed out waiting for SSM" + exit 1 diff --git a/docker-compose.yml b/docker-compose.yml index 63e1963..c1f7b10 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,8 +30,10 @@ services: condition: service_healthy environment: SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/tinyurl - SPRING_DATASOURCE_USERNAME: ${SPRING_DATASOURCE_USERNAME:-tinyurl} - SPRING_DATASOURCE_PASSWORD: ${SPRING_DATASOURCE_PASSWORD:-tinyurl} + SPRING_DATASOURCE_USERNAME: ${SPRING_DATASOURCE_USERNAME:?SPRING_DATASOURCE_USERNAME is required} + SPRING_DATASOURCE_PASSWORD: ${SPRING_DATASOURCE_PASSWORD:?SPRING_DATASOURCE_PASSWORD is required} + SPRING_FLYWAY_USER: ${SPRING_FLYWAY_USER:?SPRING_FLYWAY_USER is required} + SPRING_FLYWAY_PASSWORD: ${SPRING_FLYWAY_PASSWORD:?SPRING_FLYWAY_PASSWORD is required} SERVER_PORT: 8080 TINYURL_BASE_URL: http://localhost:8080 expose: @@ -51,8 +53,10 @@ services: container_name: tinyurl-postgres environment: POSTGRES_DB: ${POSTGRES_DB:-tinyurl} - POSTGRES_USER: ${POSTGRES_USER:-tinyurl} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-tinyurl} + POSTGRES_USER: ${POSTGRES_USER:?POSTGRES_USER is required} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} + SPRING_DATASOURCE_USERNAME: ${SPRING_DATASOURCE_USERNAME:?SPRING_DATASOURCE_USERNAME is required} + SPRING_DATASOURCE_PASSWORD: ${SPRING_DATASOURCE_PASSWORD:?SPRING_DATASOURCE_PASSWORD is required} PGDATA: /var/lib/postgresql/data/pgdata command: - postgres @@ -66,7 +70,7 @@ services: networks: - app_internal healthcheck: - test: ["CMD-SHELL", "pg_isready -U \"${POSTGRES_USER:-tinyurl}\" -d \"${POSTGRES_DB:-tinyurl}\""] + test: ["CMD-SHELL", "pg_isready -U \"${POSTGRES_USER}\" -d \"${POSTGRES_DB:-tinyurl}\""] interval: 10s timeout: 5s retries: 5 diff --git a/docs/deployment/TROUBLESHOOTING_SSM_SESSION_MANAGER.md b/docs/deployment/TROUBLESHOOTING_SSM_SESSION_MANAGER.md new file mode 100644 index 0000000..199c2d0 --- /dev/null +++ b/docs/deployment/TROUBLESHOOTING_SSM_SESSION_MANAGER.md @@ -0,0 +1,215 @@ +# Troubleshooting — SSM Session Manager + +**Symptom:** Clicking "Start session" in AWS Console does nothing, or throws a 400 error. +**Environment:** EC2 Ubuntu 22.04, public subnet, SSM-only access (no SSH key pair), IAM role `role-tinyurl-ec2` with `AmazonSSMManagedInstanceCore`. + +--- + +## How EC2 Access Works in This Project + +There is no SSH key pair on the EC2 instance. All access is via **AWS Systems Manager Session Manager** only. Port 22 is intentionally closed in the `tinyurl-ec2` security group. + +``` +Your terminal / browser + │ + ▼ +AWS SSM Service (HTTPS port 443) + │ + ▼ +SSM Agent on EC2 (receives commands over HTTPS — no open ports needed) +``` + +--- + +## Checklist Before Debugging + +Before assuming something is broken, verify: + +1. **Correct region** — top-right of AWS Console must show `us-east-1`. Session Manager is region-scoped. +2. **IAM role attached** — EC2 → Instances → `tinyurl-prod` → Security tab → IAM Role must show `role-tinyurl-ec2`. +3. **Correct subnet** — EC2 → Instances → `tinyurl-prod` → Networking tab → Subnet must be `10.0.1.0/24` (public). Private subnets (`10.0.3.x`, `10.0.4.x`) cannot reach SSM endpoints without VPC endpoints. + +--- + +## Issue 1 — Clicking "Start session" Does Nothing + +**Cause:** Browser popup blocker. Session Manager opens in a new tab — if popups are blocked for `console.aws.amazon.com`, clicking Start session does nothing visually with no error shown. + +**Fix:** +- Chrome: click the blocked popup icon in the address bar → Allow popups from `console.aws.amazon.com` +- Safari: Settings → Websites → Pop-up Windows → Allow for `console.aws.amazon.com` +- Firefox: click the notification bar → Allow popups + +**If it still does nothing after allowing popups:** try a different browser. Chrome has been observed failing silently with a `400 Bad Request` on `https://freetier.us-east-1.api.aws/` even with popups allowed. **Firefox resolved this instantly** with no other changes needed. + +--- + +## Issue 2 — 400 Bad Request on `freetier.us-east-1.api.aws` + +**Symptom:** Browser DevTools shows: +``` +POST https://freetier.us-east-1.api.aws/ +Status: 400 Bad Request +``` + +**Cause:** The AWS console browser client fails to establish the WebSocket tunnel. This is a browser-side issue, not an EC2 or IAM issue. + +**Fixes to try in order:** + +1. **Save Session Manager preferences** (must be done at least once per account): + - Systems Manager → Session Manager → Preferences → Edit → scroll to bottom → Save + - Even saving with all defaults is enough — the preferences just need to exist + +2. **Switch browser** — Firefox resolves this when Chrome fails. The WebSocket implementation differs between browsers. + +3. **Use AWS CLI instead of browser** (permanent fix — see Issue 5 below) + +--- + +## Issue 3 — Instance Not Appearing in Fleet Manager + +**Symptom:** Systems Manager → Fleet Manager shows no instances, or the instance disappears. + +**Cause:** SSM Agent process crashed or lost connection to AWS SSM service. The instance may still appear in Session Manager's target list (cached) but cannot accept sessions. + +**Diagnosis:** +- Go to **Systems Manager → Run Command → Run command** +- Document: `AWS-RunShellScript`, Command: `systemctl status amazon-ssm-agent` +- Target: your instance +- If this stays "In Progress" for more than 2 minutes → SSM Agent is unresponsive + +**Fix — Stop and Start EC2 instance:** + +> Use **Stop then Start**, NOT Reboot. Stop+Start moves the instance to fresh hardware and fully reinitialises all services. Reboot keeps the same hardware and may not recover a crashed agent. + +1. EC2 → Instances → `tinyurl-prod` → Instance state → **Stop** +2. Wait for status: **Stopped** (~30 seconds) +3. Instance state → **Start** +4. Wait for **2/2 status checks passed** (~3 minutes) +5. Wait an additional **3–5 minutes** for SSM Agent to register with Fleet Manager +6. Check Fleet Manager — instance should appear as **Online** + +> After stop+start, Docker containers restart automatically because they have `restart: unless-stopped` in `docker-compose.prod.yml`. + +> After stop+start, the EC2 **public IP changes**. This does not affect SSM Session Manager but update any direct IP references if needed. The private IP stays the same. + +--- + +## Issue 4 — EC2 Instance Connect Fails After Opening Port 22 + +**Symptom:** Added port 22 inbound rule to `tinyurl-ec2` security group with "My IP" but EC2 Instance Connect still fails. + +**Cause:** EC2 Instance Connect browser-based connect does NOT come from your IP. The connection to port 22 is made by **AWS's EC2 Instance Connect service**, not your browser directly. The source IP must be the AWS service range. + +**Fix — Use the correct source IP range:** + +The security group inbound rule must be: + +| Type | Port | Source | +|---|---|---| +| SSH | 22 | `18.206.107.24/29` | + +This IP range is **officially AWS-owned** — verified from AWS's own published IP ranges: +```bash +curl -s https://ip-ranges.amazonaws.com/ip-ranges.json | python3 -c " +import json, sys +data = json.load(sys.stdin) +results = [p for p in data['prefixes'] + if p.get('service') == 'EC2_INSTANCE_CONNECT' + and p.get('region') == 'us-east-1'] +for r in results: print(r) +" +# Output: {'ip_prefix': '18.206.107.24/29', 'region': 'us-east-1', 'service': 'EC2_INSTANCE_CONNECT', ...} +``` + +**After connecting, remove port 22 immediately:** +- EC2 → Security Groups → `tinyurl-ec2` → Inbound rules → Edit → delete SSH rule → Save + +> EC2 Instance Connect is a temporary emergency access method only. SSM Session Manager is the correct permanent access method for this project. + +--- + +## Issue 5 — Permanent Fix: AWS CLI Session Manager + +Bypasses the browser WebSocket entirely. Use this as the primary access method. + +**Install Session Manager plugin on Mac:** + +```bash +# Apple Silicon (M1/M2/M3) +curl "https://s3.amazonaws.com/session-manager-downloads/plugin/latest/mac_arm64/sessionmanager-bundle.zip" \ + -o "sessionmanager-bundle.zip" +unzip sessionmanager-bundle.zip +sudo ./sessionmanager-bundle/install \ + -i /usr/local/sessionmanagerplugin \ + -b /usr/local/bin/session-manager-plugin + +# Intel Mac +curl "https://s3.amazonaws.com/session-manager-downloads/plugin/latest/mac/sessionmanager-bundle.zip" \ + -o "sessionmanager-bundle.zip" +unzip sessionmanager-bundle.zip +sudo ./sessionmanager-bundle/install \ + -i /usr/local/sessionmanagerplugin \ + -b /usr/local/bin/session-manager-plugin +``` + +**Connect to EC2:** + +```bash +aws ssm start-session --target --region us-east-1 +``` + +Once connected, switch to the ubuntu user: + +```bash +sudo su - ubuntu +``` + +--- + +## Verifying SSM Agent Health From Inside the Instance + +If you get into the instance via EC2 Instance Connect, run these to diagnose SSM: + +```bash +# Check agent status +sudo systemctl status snap.amazon-ssm-agent.amazon-ssm-agent.service + +# Check last 50 log lines +sudo journalctl -u snap.amazon-ssm-agent.amazon-ssm-agent.service --no-pager -n 50 + +# Restart agent if needed +sudo systemctl restart snap.amazon-ssm-agent.amazon-ssm-agent.service + +# Confirm agent is enabled on boot +sudo systemctl enable snap.amazon-ssm-agent.amazon-ssm-agent.service +``` + +**Healthy agent output looks like:** +``` +Active: active (running) +INFO EC2RoleProvider Successfully connected with instance profile role credentials +INFO [CredentialRefresher] Credentials ready +INFO [CredentialRefresher] Next credential rotation will be in 29.9... minutes +``` + +**The `/etc/amazon/ssm/seelog.xml: no such file or directory` warning is harmless** — the agent falls back to default logging config and works normally. + +--- + +## Quick Reference — Access Methods + +| Method | When to use | Requires | +|---|---|---| +| SSM Session Manager (Firefox) | Normal day-to-day access | Nothing extra | +| AWS CLI `ssm start-session` | When browser fails | Session Manager plugin installed | +| EC2 Instance Connect | Emergency only — SSM agent dead | Port 22 open from `18.206.107.24/29` | + +--- + +## What NOT to Do + +- **Do not leave port 22 open** after finishing EC2 Instance Connect — remove the rule immediately +- **Do not use "My IP" as the source** for EC2 Instance Connect — use `18.206.107.24/29` +- **Do not use Reboot** to recover a crashed SSM agent — use Stop then Start +- **Do not panic if the instance disappears from Fleet Manager** — Stop+Start always recovers it diff --git a/docs/security/DB_LEAST_PRIVILEGE.md b/docs/security/DB_LEAST_PRIVILEGE.md new file mode 100644 index 0000000..cbd317e --- /dev/null +++ b/docs/security/DB_LEAST_PRIVILEGE.md @@ -0,0 +1,266 @@ +# Database Least Privilege — App User Separation + +**Goal:** The Spring Boot application runs with a DB user that can only do what it needs at runtime (DML). The master user is reserved for admin and migrations (DDL). +**Effort:** ~30 minutes +**Risk if skipped:** If the application is ever compromised, an attacker has full DDL access — they can `DROP TABLE`, `CREATE` backdoor tables, or destroy the database entirely. + +--- + +## The Problem With Using the Master User for the App + +The master RDS user (`tinyurl`) is a superuser. It can: + +```sql +DROP TABLE url_mappings; -- destroy all data +TRUNCATE url_mappings; -- wipe all rows +ALTER TABLE url_mappings ...; -- modify schema +CREATE TABLE exfil ...; -- create new tables +GRANT ... TO ...; -- escalate privileges +``` + +The Spring Boot app at runtime needs **none of these**. It only reads and writes rows. +If a SQL injection or dependency vulnerability ever allowed an attacker to execute arbitrary SQL, +the master user gives them the keys to everything. A least-privilege user limits the blast radius +to what the app legitimately does anyway. + +--- + +## What the App Actually Needs + +Based on the schema in `V1__init_schema.sql`: + +| Object | Permissions needed | Why | +|---|---|---| +| `public` schema | `USAGE` | Required to access any object in the schema | +| `url_mappings` table | `SELECT, INSERT, UPDATE, DELETE` | Normal CRUD operations | +| `url_seq` sequence | `USAGE, SELECT` | `nextval('url_seq')` called on every URL creation | + +**Does NOT need:** `CREATE`, `DROP`, `ALTER`, `TRUNCATE`, `REFERENCES`, `TRIGGER` + +--- + +## Why Flyway Needs to Stay on the Master User + +Flyway runs database migrations — `CREATE TABLE`, `CREATE SEQUENCE`, `ALTER TABLE`. These are DDL +operations. The least-privilege app user cannot run them. + +The solution is to split credentials: + +``` +Master user (tinyurl) + └── Used by: Flyway only (runs at startup to apply migrations) + └── Permissions: full DDL + DML + +App user (tinyurl_appuser) + └── Used by: Spring Boot datasource at runtime + └── Permissions: DML only on specific tables +``` + +Spring Boot supports this natively — `spring.flyway.user` overrides the datasource user for +migrations only. + +--- + +## Step 1 — Connect to RDS as Master User + +From your local machine (requires RDS to be temporarily accessible, or use EC2 via SSM): + +```bash +# Option A — via EC2 SSM Session Manager (recommended, no public exposure needed) +# In AWS Console → EC2 → tinyurl-prod → Connect → Session Manager + +# Then on EC2: +psql -h -U tinyurl -d tinyurl_production_db +``` + +```bash +# Option B — temporarily allow your IP in the RDS security group, then: +psql -h -U tinyurl -d tinyurl_production_db +``` + +--- + +## Step 2 — Create the Least-Privilege App User + +Run this SQL once, connected as the master user: + +```sql +-- Create the app user with a strong password +CREATE USER tinyurl_appuser WITH PASSWORD ''; + +-- Allow the user to connect to the database +GRANT CONNECT ON DATABASE tinyurl_production_db TO tinyurl_appuser; + +-- Allow the user to see objects in the public schema +GRANT USAGE ON SCHEMA public TO tinyurl_appuser; + +-- Grant DML on the app table only +GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE url_mappings TO tinyurl_appuser; + +-- Grant sequence access (required for nextval in JpaUrlRepository) +GRANT USAGE, SELECT ON SEQUENCE url_seq TO tinyurl_appuser; + +-- Future tables/sequences created by Flyway migrations are automatically +-- granted to tinyurl_appuser via default privileges +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO tinyurl_appuser; + +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT USAGE, SELECT ON SEQUENCES TO tinyurl_appuser; +``` + +> The `ALTER DEFAULT PRIVILEGES` lines are critical — without them, every new table added +> by a future Flyway migration would require a manual `GRANT` to `tinyurl_appuser`. + +--- + +## Step 3 — Verify the App User's Permissions + +Still connected as master, confirm `tinyurl_appuser` has exactly what it needs and nothing more: + +```sql +-- Check table privileges +SELECT grantee, table_name, privilege_type +FROM information_schema.role_table_grants +WHERE grantee = 'tinyurl_appuser'; + +-- Expected output: +-- tinyurl_appuser | url_mappings | SELECT +-- tinyurl_appuser | url_mappings | INSERT +-- tinyurl_appuser | url_mappings | UPDATE +-- tinyurl_appuser | url_mappings | DELETE + +-- Check sequence privileges +SELECT grantee, object_name, privilege_type +FROM information_schema.usage_privileges +WHERE grantee = 'tinyurl_appuser'; + +-- Confirm tinyurl_appuser CANNOT do DDL (this should fail): +-- GRANT CONNECT ON DATABASE tinyurl_production_db TO tinyurl_appuser; -- run as tinyurl_appuser, should fail +``` + +--- + +## Step 4 — Update AWS Parameter Store + +Add two new parameters and update the existing username: + +| Parameter | Type | Value | +|---|---|---| +| `/tinyurl/prod/spring/datasource/username` | String | `tinyurl_appuser` | +| `/tinyurl/prod/spring/datasource/password` | SecureString | `` | +| `/tinyurl/prod/spring/flyway/user` | String | `tinyurl` (master) | +| `/tinyurl/prod/spring/flyway/password` | SecureString | `` | + +> The `spring/flyway/user` and `spring/flyway/password` entries tell Spring Boot to use +> the master credentials for Flyway migrations only. The datasource credentials +> (used for all runtime queries) are `tinyurl_appuser`. + +--- + +## Step 5 — Update Local `.env` + +```bash +# .env +POSTGRES_USER=tinyurl +POSTGRES_PASSWORD= + +SPRING_DATASOURCE_USERNAME=tinyurl_appuser +SPRING_DATASOURCE_PASSWORD= + +# Flyway uses master user for local dev migrations +SPRING_FLYWAY_USER=tinyurl +SPRING_FLYWAY_PASSWORD= +``` + +--- + +## Step 6 — Create the App User in Local Postgres + +For local dev, the Postgres container also needs the `tinyurl_appuser` user. +This is handled automatically by `infra/postgres/init/002_app_user.sh` — a shell init script +that Docker Postgres runs on first container initialization. + +The script reads `SPRING_DATASOURCE_USERNAME` and `SPRING_DATASOURCE_PASSWORD` from the +environment (injected by docker-compose from `.env`) so no credentials are hardcoded in the repo. + +Key points about the grants in that script: + +- Grants on `TABLE url_mappings` only — **not** `ALL TABLES` (which would include `flyway_schema_history`) +- `ALTER DEFAULT PRIVILEGES` covers any future tables added by Flyway migrations automatically +- `set -e` + `ON_ERROR_STOP=1` — container init fails loudly if the script errors + +**Fresh setup (no existing volume):** just run `docker compose up -d` — the script runs automatically. + +**If the container already exists with data**, the init script will not re-run. Apply the grants +manually by running the same SQL the script would have executed: + +```bash +docker compose exec postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c " + CREATE USER $SPRING_DATASOURCE_USERNAME WITH PASSWORD '$SPRING_DATASOURCE_PASSWORD'; + GRANT CONNECT ON DATABASE $POSTGRES_DB TO $SPRING_DATASOURCE_USERNAME; + GRANT USAGE ON SCHEMA public TO $SPRING_DATASOURCE_USERNAME; + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE url_mappings TO $SPRING_DATASOURCE_USERNAME; + GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO $SPRING_DATASOURCE_USERNAME; + ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO $SPRING_DATASOURCE_USERNAME; + ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT USAGE, SELECT ON SEQUENCES TO $SPRING_DATASOURCE_USERNAME; +" +``` + +> The env vars (`$POSTGRES_USER`, `$SPRING_DATASOURCE_USERNAME`, etc.) are read from your shell, +> so source your `.env` first: `set -a && source .env && set +a` + +To verify the grants are scoped correctly (url_mappings only, not flyway_schema_history): + +```bash +docker compose exec postgres psql -U tinyurl -d tinyurl -c \ + "SELECT grantee, table_name, privilege_type FROM information_schema.role_table_grants WHERE grantee = 'tinyurl_appuser';" +``` + +Expected: exactly 4 rows — SELECT, INSERT, UPDATE, DELETE on `url_mappings`. + +--- + +## Step 7 — Verify End-to-End + +After deploying (or restarting locally): + +```bash +# 1. Create a short URL — exercises INSERT + nextval +curl -X POST https://go.buffden.com/api/urls \ + -H "Content-Type: application/json" \ + -d '{"url":"https://example.com"}' + +# 2. Use the short URL — exercises SELECT +curl -I https://go.buffden.com/ + +# 3. Check health +curl https://go.buffden.com/actuator/health +``` + +If any of these fail with a DB error, check: +- Parameter Store values are correct +- Spring Boot restarted and picked up new config +- `tinyurl_appuser` grants were applied before the app started + +--- + +## What This Protects Against + +| Attack scenario | Before (master user) | After (app user) | +|---|---|---| +| SQL injection in URL input | Attacker can `DROP TABLE`, wipe DB | Attacker can only read/write rows — schema intact | +| Compromised dependency (supply chain) | Full DB access | DML only, no DDL | +| App misconfiguration runs bad migration | Could destroy schema | Cannot — no DDL permissions | +| Attacker reads app credentials from EC2 | Gets superuser access | Gets DML-only access | + +--- + +## Summary + +The master user (`tinyurl`) keeps full DDL privileges and is only used by Flyway at startup. +The app user (`tinyurl_appuser`) has DML-only access to exactly the tables and sequences the +application needs. An attacker who steals the app's DB credentials cannot structurally +damage the database. diff --git a/infra/postgres/init/002_app_user.sh b/infra/postgres/init/002_app_user.sh new file mode 100755 index 0000000..c6dc2e2 --- /dev/null +++ b/infra/postgres/init/002_app_user.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Creates the least-privilege application user for local development. +# Reads SPRING_DATASOURCE_USERNAME and SPRING_DATASOURCE_PASSWORD from +# the environment (injected by docker-compose from .env). +# Runs automatically on first container initialisation. + +set -e + +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL + CREATE USER $SPRING_DATASOURCE_USERNAME WITH PASSWORD '$SPRING_DATASOURCE_PASSWORD'; + GRANT CONNECT ON DATABASE $POSTGRES_DB TO $SPRING_DATASOURCE_USERNAME; + GRANT USAGE ON SCHEMA public TO $SPRING_DATASOURCE_USERNAME; + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE url_mappings TO $SPRING_DATASOURCE_USERNAME; + GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO $SPRING_DATASOURCE_USERNAME; + ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO $SPRING_DATASOURCE_USERNAME; + ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT USAGE, SELECT ON SEQUENCES TO $SPRING_DATASOURCE_USERNAME; +EOSQL diff --git a/tinyurl/src/main/java/com/tinyurl/config/CorsConfig.java b/tinyurl/src/main/java/com/tinyurl/config/CorsConfig.java index fd67442..8b0f0e9 100644 --- a/tinyurl/src/main/java/com/tinyurl/config/CorsConfig.java +++ b/tinyurl/src/main/java/com/tinyurl/config/CorsConfig.java @@ -21,6 +21,7 @@ public CorsFilter corsFilter() { config.setAllowedOrigins(allowedOrigins); config.setAllowedMethods(List.of("GET", "POST", "OPTIONS")); config.setAllowedHeaders(List.of("Content-Type", "Accept")); + config.setAllowCredentials(false); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();