diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..67e39ce --- /dev/null +++ b/Dockerfile @@ -0,0 +1,96 @@ +# syntax=docker/dockerfile:1 +# +# The frontend deployment artifact. The repository had none — `dist/server.mjs` +# is a preview server that applies neither the security headers nor the cache +# policy `config/hosting/` declares — so a deployment had nothing to run. +# +# Two stages: the build produces `dist/` and, from the serving contract, the +# nginx configuration that matches it; the runtime is nginx with both. + +# --------------------------------------------------------------------------- +# build +# --------------------------------------------------------------------------- +# Pinned by digest: the release-provenance gate requires an immutable runner +# identity, and a floating tag cannot give one. +FROM node@sha256:3638d9a6fe4030bd716be989438248074489337ba3275657f93595428be4fc03 AS build +WORKDIR /src + +# The profile is baked at build time (scripts/generate-runtime-config.ts), so it +# has to be chosen here rather than at `docker run`. `dist/config.json` stays a +# separate file in the image, which is what makes build-once/promote possible: +# a deployment can replace just that file without rebuilding the bundle. +ARG APP_PROFILE=production +ENV APP_PROFILE=${APP_PROFILE} + +# The bundle and the nginx locations must agree on the prefix the deployment +# serves this under: "/" at a domain root, "/dev/" behind a path prefix. +ARG VITE_ROUTER_BASE_PATH=/ +ENV VITE_ROUTER_BASE_PATH=${VITE_ROUTER_BASE_PATH} + +# `CI=true` turns on the release-provenance gate (scripts/lib/build-environment.ts), +# which refuses to build without an identity for the artifact. That is the point: +# a deployed bundle that cannot say which commit it came from is not traceable, +# and the checklist asks exactly that. Supplied as build args so the caller — +# a pipeline or the deploy script — owns the values. +ENV CI=true +ARG VITE_BUILD_ID +ARG VITE_COMMIT_SHA +ARG RELEASE_ID +ARG CI_RUNNER_IMAGE +ARG SOURCE_DATE_EPOCH +ENV VITE_BUILD_ID=${VITE_BUILD_ID} +ENV VITE_COMMIT_SHA=${VITE_COMMIT_SHA} +ENV RELEASE_ID=${RELEASE_ID} +ENV CI_RUNNER_IMAGE=${CI_RUNNER_IMAGE} +ENV SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH} + +# The two values a deployment is allowed to supply (scripts/generate-runtime- +# config.ts OVERRIDES); everything else is fixed by the profile. API_BASE_URL +# has to be absolute — the runtime canonicalises it with `new URL(value)` — so +# even a same-origin deployment names its own origin here. The committed +# production profile ships a placeholder (https://api.example.com/), which is +# what a deployment that forgets this would silently serve. +ARG RUNTIME_API_BASE_URL +ARG RUNTIME_TELEMETRY_ENDPOINT +ENV RUNTIME_API_BASE_URL=${RUNTIME_API_BASE_URL} +ENV RUNTIME_TELEMETRY_ENDPOINT=${RUNTIME_TELEMETRY_ENDPOINT} + +RUN corepack enable + +# Dependencies first so a source-only change does not re-resolve them. +COPY package.json pnpm-lock.yaml ./ +RUN corepack pnpm install --frozen-lockfile --ignore-scripts + +COPY . . +RUN corepack pnpm build \ + && node scripts/generate-nginx-config.ts + +# --------------------------------------------------------------------------- +# runtime +# --------------------------------------------------------------------------- +FROM nginx@sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10 AS runtime + +# Replaces the packaged default server block; the generated file is the whole +# server definition, including the BFF proxy locations. +RUN rm /etc/nginx/conf.d/default.conf +COPY --from=build /src/dist/nginx.conf /etc/nginx/conf.d/tech-log.conf +COPY --from=build /src/dist/ /usr/share/nginx/html/ + +# The generated config is served from /usr/share/nginx/html as root, so the two +# copies above would also publish nginx.conf itself. It is not secret, but it is +# not a page either. +RUN rm -f /usr/share/nginx/html/nginx.conf /usr/share/nginx/html/server.mjs \ + && rm -rf /usr/share/nginx/html/.vite \ + # The build writes config.json 0600, which nginx (running as `nginx`) cannot + # read — the container came up healthy and answered 403 for the one file the + # SPA needs before it can boot. Normalise what is served to world-readable. + && chmod -R a+rX /usr/share/nginx/html + +EXPOSE 80 + +# No `nginx -t` here: proxy_pass names are resolved when the config loads, and +# `backend`/`keycloak` only exist on the compose network. The container's own +# startup is the check, and it fails loudly. + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://127.0.0.1${VITE_ROUTER_BASE_PATH:-/}config.json || exit 1 diff --git a/config/runtime/development.json b/config/runtime/development.json index 6c26b54..9b189e5 100644 --- a/config/runtime/development.json +++ b/config/runtime/development.json @@ -13,7 +13,7 @@ "SERVICE_WORKER": "DEFAULT", "OFFLINE_COMMANDS": "DEFAULT" }, - "TECH_LOG_STUDIO_SOURCE": "MOCK", + "TECH_LOG_STUDIO_SOURCE": "HTTP", "FEATURE_OVERRIDES": { "reference-feature": "DEFAULT" } diff --git a/deploy/keycloak/README.md b/deploy/keycloak/README.md new file mode 100644 index 0000000..660979d --- /dev/null +++ b/deploy/keycloak/README.md @@ -0,0 +1,24 @@ +# Keycloak realm + +`tech-log-realm.json` is imported by the `keycloak` service at start +(`--import-realm`). It exists because the realm was previously created by hand, +which meant §27 of the release checklist — "Keycloak Realm 설정을 복원할 수 +있다" — had no answer: nothing in either repository described the realm. + +What it declares, and why each part is load-bearing: + +- **`studio-author` realm role.** `StudioAuthzEnvironmentPostProcessor` maps this + name to `studio:read` and `studio:write`. The name is configurable through + `APP_STUDIO_AUTHOR_ROLE`; if you change it here, change it there too. +- **`tech-log-bff` confidential client.** The Authorization Code flow belongs to + the backend, not the browser — the SPA never holds a token. `redirectUris` is + relative so the same realm works on any origin the deployment is served from. +- **`realm-roles` protocol mapper.** Without it the roles never reach the token, + the registry resolves zero permissions, and every Studio call answers 403. + +## Values that must be replaced + +`CHANGE_ME_BFF_SECRET` and `CHANGE_ME_STUDIO_PASSWORD` are placeholders, and the +deploy script substitutes them from the environment before import. They are left +visible rather than pre-filled so a realm file committed with a real secret is an +obvious mistake rather than a quiet one. diff --git a/deploy/keycloak/tech-log-realm.json b/deploy/keycloak/tech-log-realm.json new file mode 100644 index 0000000..8dfd229 --- /dev/null +++ b/deploy/keycloak/tech-log-realm.json @@ -0,0 +1,57 @@ +{ + "realm": "tech-log", + "enabled": true, + "sslRequired": "none", + "registrationAllowed": false, + "loginTheme": "keycloak", + "accessTokenLifespan": 300, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "roles": { + "realm": [ + { "name": "studio-author", "description": "Tech Log Studio 편집 권한 (studio:read + studio:write)" } + ] + }, + "clients": [ + { + "clientId": "tech-log-bff", + "name": "Tech Log BFF", + "description": "백엔드가 소유하는 Authorization Code 클라이언트. SPA 는 토큰을 직접 들지 않는다.", + "enabled": true, + "publicClient": false, + "secret": "CHANGE_ME_BFF_SECRET", + "standardFlowEnabled": true, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "redirectUris": ["/login/oauth2/code/*"], + "webOrigins": ["+"], + "protocolMappers": [ + { + "name": "realm-roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "config": { + "claim.name": "realm_access.roles", + "jsonType.label": "String", + "multivalued": "true", + "access.token.claim": "true", + "id.token.claim": "true", + "userinfo.token.claim": "true" + } + } + ] + } + ], + "users": [ + { + "username": "studio", + "enabled": true, + "emailVerified": true, + "email": "studio@tech-log.local", + "firstName": "Studio", + "lastName": "Author", + "credentials": [{ "type": "password", "value": "CHANGE_ME_STUDIO_PASSWORD", "temporary": false }], + "realmRoles": ["default-roles-tech-log", "studio-author"] + } + ] +} diff --git a/docker-compose.dev-stack.yml b/docker-compose.dev-stack.yml new file mode 100644 index 0000000..9e11040 --- /dev/null +++ b/docker-compose.dev-stack.yml @@ -0,0 +1,180 @@ +# The Tech Log dev stack: one origin, five services. +# +# nginx is the only published port. Everything the browser touches — the SPA, +# /api, the OIDC redirect chain, and Keycloak under /auth — arrives on the same +# origin, which is what lets the session be a plain first-party httpOnly cookie +# instead of a cross-site one needing SameSite=None. +# +# browser ──> frontend(nginx) ──┬─> / SPA bundle +# ├─> /api backend +# ├─> /oauth2 /login /logout backend (BFF) +# └─> /auth keycloak +# +# Secrets here are development values and are meant to be replaced by the +# deployment; they are named in .env so nothing is baked into an image. + +name: tech-log + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: ${POSTGRES_DB:-tech_log} + POSTGRES_USER: ${POSTGRES_USER:-tech_log} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD} + TZ: UTC + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-tech_log} -d ${POSTGRES_DB:-tech_log}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s + restart: unless-stopped + networks: [tech-log] + + redis: + # Holds the Studio session. Losing it signs everyone out; it holds nothing + # else, so it is not backed by a volume on purpose. + image: redis:7-alpine + command: ["redis-server", "--save", "", "--appendonly", "no"] + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 5 + restart: unless-stopped + networks: [tech-log] + + keycloak: + image: quay.io/keycloak/keycloak:26.7.0 + command: ["start-dev", "--import-realm", "--http-relative-path=/auth"] + environment: + KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN:-admin} + KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:?set KEYCLOAK_ADMIN_PASSWORD} + KC_HTTP_ENABLED: "true" + # Behind nginx: Keycloak must build its URLs from the forwarded host, or + # the redirect back from the login page points at the container. + KC_HOSTNAME: ${PUBLIC_ORIGIN:?set PUBLIC_ORIGIN}/auth + KC_HOSTNAME_STRICT: "false" + KC_PROXY_HEADERS: xforwarded + KC_HEALTH_ENABLED: "true" + volumes: + - ${KEYCLOAK_IMPORT_DIR:-./deploy/keycloak}:/opt/keycloak/data/import:ro + healthcheck: + test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000 && echo -e 'GET /auth/health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && cat <&3 | grep -q '\"status\": \"UP\"'"] + interval: 15s + timeout: 5s + retries: 20 + start_period: 40s + restart: unless-stopped + networks: [tech-log] + + backend: + image: ${BACKEND_IMAGE:-tech-log-backend:local} + volumes: + - tls-public:/tls-public:ro + # The image entrypoint is `java -jar /app/app.jar`; this wraps it so the + # frontend's certificate lands in the JVM truststore first. Without it the + # OIDC metadata fetch fails PKIX validation and the process crash-loops. + entrypoint: + - /bin/sh + - -c + - | + until [ -f /tls-public/server.crt ]; do sleep 1; done + # The image runs as a non-root user, so the JVM's own cacerts is not + # writable — importing there silently did nothing and the metadata fetch + # kept failing PKIX. Copy it somewhere writable, add the edge + # certificate, and point the JVM at that. + cp "/opt/java/openjdk/lib/security/cacerts" /tmp/truststore.jks + keytool -importcert -noprompt -trustcacerts -alias tech-log-edge \ + -file /tls-public/server.crt \ + -keystore /tmp/truststore.jks -storepass changeit + exec java \ + -Djavax.net.ssl.trustStore=/tmp/truststore.jks \ + -Djavax.net.ssl.trustStorePassword=changeit \ + -jar /app/app.jar + environment: + SPRING_PROFILES_ACTIVE: local + # Persistence + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-tech_log} + SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-tech_log} + SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD} + SPRING_DATASOURCE_DRIVER_CLASS_NAME: org.postgresql.Driver + SPRING_FLYWAY_ENABLED: "true" + SPRING_JPA_HIBERNATE_DDL_AUTO: none + CA_SKELETON_PERSISTENCE_VENDOR: postgresql + # BFF session + CA_SKELETON_SECURITY_AUTH_MODE: redis-session + CA_SKELETON_SECURITY_SESSION_COOKIE_NAME: TECHLOG_SESSION + APP_REDIS_ENABLED: "true" + APP_REDIS_AUTHENTICATION_ANONYMOUS_ACCESS_ACCEPTED: "true" + SPRING_DATA_REDIS_HOST: redis + SPRING_DATA_REDIS_PORT: "6379" + # OIDC. The issuer is the browser-facing URL because the tokens carry it + # and the browser is redirected there; the container reaches the same + # Keycloak through nginx on the compose network. + SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KEYCLOAK_CLIENT_ID: ${OIDC_CLIENT_ID:-tech-log-bff} + SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KEYCLOAK_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:?set OIDC_CLIENT_SECRET} + SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KEYCLOAK_SCOPE: openid,profile,email + SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KEYCLOAK_AUTHORIZATION_GRANT_TYPE: authorization_code + SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KEYCLOAK_REDIRECT_URI: "${PUBLIC_ORIGIN}/login/oauth2/code/keycloak" + SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KEYCLOAK_ISSUER_URI: ${PUBLIC_ORIGIN}/auth/realms/${KEYCLOAK_REALM:-tech-log} + APP_STUDIO_AUTHOR_ROLE: ${STUDIO_AUTHOR_ROLE:-studio-author} + APP_STUDIO_POST_LOGIN_REDIRECT: "${PUBLIC_ORIGIN}/studio" + # Behind a proxy: trust the forwarded headers nginx sets, so redirect URLs + # and client IPs are the browser's, not the container's. + APP_SERVER_FORWARD_HEADERS_STRATEGY: framework + TZ: UTC + # The issuer in a token is the browser-facing URL, and the backend has to + # both validate that exact string and fetch the realm's metadata from it. + # Inside the container that host does not resolve, so discovery failed and + # the process crash-looped. Mapping the public host to the docker gateway + # makes one URL work from both sides — the browser reaches nginx directly, + # the backend reaches the same nginx through the published port. + extra_hosts: + - "${PUBLIC_HOST:?set PUBLIC_HOST}:host-gateway" + depends_on: + postgres: { condition: service_healthy } + redis: { condition: service_healthy } + keycloak: { condition: service_healthy } + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/api/healthcheck"] + interval: 15s + timeout: 5s + retries: 10 + start_period: 45s + restart: unless-stopped + networks: [tech-log] + + frontend: + image: ${FRONTEND_IMAGE:-tech-log-frontend:local} + # The backend fetches the realm metadata from the same HTTPS origin the + # browser uses, so it has to trust this certificate. Publishing it to a + # shared volume keeps one certificate for both sides; a deployment that + # mounts a CA-issued certificate over /etc/nginx/tls needs neither this nor + # the backend's import step. + volumes: + - tls-public:/tls-public + command: + - /bin/sh + - -c + - "cp /etc/nginx/tls/server.crt /tls-public/server.crt && exec nginx -g 'daemon off;'" + + ports: + - "${PUBLIC_HTTP_PORT:-8088}:80" + - "${PUBLIC_PORT:-8443}:443" + depends_on: + backend: { condition: service_started } + keycloak: { condition: service_started } + restart: unless-stopped + networks: [tech-log] + +networks: + tech-log: + driver: bridge + +volumes: + postgres-data: + tls-public: diff --git a/index.html b/index.html index cd9b3c5..062b26a 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,11 @@