Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions .github/workflows/ui-deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
name: UI Deploy

on:
push:
branches:
- main
paths:
- ui/app/**

permissions:
contents: read
deployments: write

concurrency:
group: ui-deploy-main
cancel-in-progress: true

jobs:
deploy-production:
name: test, build and push UI
runs-on: ubuntu-24.04-arm
environment:
name: Production
url: https://developers.kr-filter.com
env:
REGISTRY_HOST: docker-registry.bottle-note.com
IMAGE_NAME: profanity-ui

steps:
- name: Checkout code
uses: actions/checkout@v6

- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
cache: npm
cache-dependency-path: ui/app/package-lock.json

- name: Install dependencies
working-directory: ui/app
run: npm ci

- name: Run UI CI
working-directory: ui/app
run: npm run typecheck && npm run build

- name: Prepare image tag
id: image
shell: bash
run: |
set -euo pipefail
tag="v0.${GITHUB_RUN_NUMBER}.${GITHUB_RUN_ATTEMPT}"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "image=${REGISTRY_HOST}/${IMAGE_NAME}:${tag}" >> "$GITHUB_OUTPUT"

- name: Ensure registry secrets are configured
shell: bash
env:
ZOT_REGISTRY_USERNAME: ${{ secrets.ZOT_REGISTRY_USERNAME }}
ZOT_REGISTRY_PASSWORD: ${{ secrets.ZOT_REGISTRY_PASSWORD }}
run: |
set -euo pipefail
if [[ -z "$ZOT_REGISTRY_USERNAME" || -z "$ZOT_REGISTRY_PASSWORD" ]]; then
echo "Production environment secrets ZOT_REGISTRY_USERNAME and ZOT_REGISTRY_PASSWORD are required." >&2
exit 1
fi

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY_HOST }}
username: ${{ secrets.ZOT_REGISTRY_USERNAME }}
password: ${{ secrets.ZOT_REGISTRY_PASSWORD }}

- name: Build and push UI image
uses: docker/build-push-action@v6
with:
context: ui/app
platforms: linux/arm64
push: true
tags: ${{ steps.image.outputs.image }}

- name: Write deployment summary
shell: bash
run: |
set -euo pipefail
{
echo "## UI deployment"
echo ""
echo "| Field | Value |"
echo "| --- | --- |"
echo "| Public URL | https://developers.kr-filter.com |"
echo "| Image | ${{ steps.image.outputs.image }} |"
echo "| Commit | $GITHUB_SHA |"
echo "| Rollout | ArgoCD Image Updater |"
} >> "$GITHUB_STEP_SUMMARY"
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,4 @@ application-prod.yml
.omc/
/.env
.DS_Store
/.playwright-mcp/
317 changes: 147 additions & 170 deletions README.md

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions deploy/overlays/production/kustomization.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ resources:
- external-secret.yaml
- http-route.yaml
- redis.yaml
- ui-deployment.yaml
- ui-service.yaml
- ui-http-route.yaml

images:
- name: docker-registry.bottle-note.com/profanity-api
newTag: v0.0.1
- name: docker-registry.bottle-note.com/profanity-ui
newTag: v0.0.0
58 changes: 58 additions & 0 deletions deploy/overlays/production/ui-deployment.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: profanity-ui
labels:
app: profanity-ui
environment: production
spec:
replicas: 2
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app: profanity-ui
template:
metadata:
labels:
app: profanity-ui
environment: production
spec:
nodeSelector:
kubernetes.io/arch: arm64
imagePullSecrets:
- name: zot-registry-secret
containers:
- name: profanity-ui
image: docker-registry.bottle-note.com/profanity-ui
imagePullPolicy: Always
ports:
- name: http
containerPort: 80
protocol: TCP
resources:
requests:
memory: "64Mi"
cpu: "50m"
limits:
memory: "256Mi"
cpu: "250m"
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
readinessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 3
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
16 changes: 16 additions & 0 deletions deploy/overlays/production/ui-http-route.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: profanity-ui-route
spec:
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: main-gateway
namespace: envoy-gateway-system
hostnames:
- developers.kr-filter.com
rules:
- backendRefs:
- name: profanity-ui
port: 80
16 changes: 16 additions & 0 deletions deploy/overlays/production/ui-service.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
apiVersion: v1
kind: Service
metadata:
name: profanity-ui
labels:
app: profanity-ui
environment: production
spec:
type: ClusterIP
ports:
- name: http
port: 80
targetPort: http
protocol: TCP
selector:
app: profanity-ui
37 changes: 21 additions & 16 deletions docs/adr/0006 OAuth2 Client Credentials 기반 API 인증 전환.md
Original file line number Diff line number Diff line change
@@ -1,32 +1,35 @@
# 6. OAuth2 Client Credentials 기반 API 인증 전환
# 6. 로그인 기반 API 자격 증명 발급과 OAuth2 인증 도입

## Status
제안 (2026.06.30)
채택 (2026.07.17)

## Context
현재 외부 API 호출 인증은 `x-api-key` 헤더 중심이다. 이 방식은 단순하지만 API Key 원문이 매 요청마다 전달되고, 표준 OAuth2 기반 OpenAPI 문서화나 외부 개발자 경험 측면에서 한계가 있다.

앞으로는 SSO 기반 대시보드에서 사용자가 API 호출용 자격증명을 발급하고, 실제 API 호출은 표준적인 Bearer 토큰 기반으로 전환해야 한다. 동시에 기존에 발급된 `x-api-key`는 호환성 때문에 즉시 중단할 수 없다.
앞으로는 SSO 기반 대시보드에서 인증된 사용자가 API 호출용 자격증명을 발급해야 한다. API Key는 빠르고 단순한 연동에 유용하고, OAuth2 Client Credentials는 짧은 수명의 Bearer token과 표준 인증 흐름이 필요한 운영 환경에 적합하다.

따라서 인증 경계는 다음처럼 분리되어야 한다.

- 대시보드 인증: Google/GitHub SSO 세션
- 신규 API 인증: OAuth2 Client Credentials 방식의 Bearer access token
- 기존 API 인증: 이미 발급된 legacy `x-api-key`
- 간편 API 인증: 로그인 후 발급한 `x-api-key`
- 표준 API 인증: OAuth2 Client Credentials 방식의 Bearer access token
- 기존 API 인증: 이미 발급된 `x-api-key`

## Decision
신규 API 호출 인증은 OAuth2 Client Credentials 기반으로 제공하고, 기존 `x-api-key` 인증은 legacy 호환 경로로 유지한다.
API Key와 OAuth2 Client Credentials를 모두 제공하되, 두 자격 증명은 Google 또는 GitHub SSO 로그인 후 대시보드에서만 발급한다.

- SSO 로그인 사용자는 대시보드에서 API 클라이언트를 생성한다.
- 비로그인 공개 `POST /api/v1/clients/register`를 통한 API Key 발급은 종료한다.
- SSO 로그인 사용자는 대시보드에서 API Key 또는 OAuth2 Client Credentials를 선택해 발급한다.
- API Key는 간단한 연동을 위한 정식 자격 증명으로 계속 제공하며 `x-api-key` 헤더로 호출한다.
- 기존 API Key도 동일한 인증 경로에서 계속 검증한다.
- API 클라이언트 생성 시 `client_id`와 `client_secret`을 발급한다.
- `client_secret`은 최초 1회만 노출하고, 서버에는 원문이 아닌 hash를 저장한다.
- 토큰 발급 엔드포인트는 `POST /oauth2/token`으로 둔다.
- 1차 구현 범위는 `grant_type=client_credentials`만 허용한다.
- `/oauth2/token`은 `client_id`와 `client_secret`을 검증해 짧은 수명의 access token을 발급한다.
- 신규 API 호출은 `Authorization: Bearer {access_token}`을 사용한다.
- 기존 `x-api-key`는 신규 발급을 중단하되, 기존 발급분은 legacy 인증 필터에서 계속 검증한다.
- 외부 API 엔드포인트는 전환 기간 동안 Bearer token과 legacy `x-api-key`를 모두 허용한다.
- 대시보드 엔드포인트는 SSO 세션만 허용하고, legacy `x-api-key`로 접근할 수 없게 한다.
- 외부 API 엔드포인트는 Bearer token과 `x-api-key`를 모두 허용한다.
- 대시보드 엔드포인트와 자격 증명 발급 API는 SSO 로그인 token만 허용하고, `x-api-key`로 접근할 수 없게 한다.

## Implementation Status

Expand All @@ -38,16 +41,18 @@
- 구현된 Bearer 인증은 사람의 대시보드 접근을 위한 `LOGIN_JWT`이며, 본 ADR의 외부 API access token과 다른 credential이다.

## Consequences
- 신규 API 인증은 OAuth2 표준 형식을 따르므로 OpenAPI 문서, SDK, 외부 개발자 경험을 개선할 수 있다.
- 사용자는 연동 복잡도와 운영 요구에 따라 API Key와 Client Credentials 중 하나를 선택할 수 있다.
- OAuth2 인증은 표준 형식을 따르므로 OpenAPI 문서, SDK, 외부 개발자 경험을 개선할 수 있다.
- `client_id/client_secret`은 토큰 발급에만 사용하고, 실제 API 호출에는 짧은 수명의 access token을 사용한다.
- access token 검증 후 SecurityContext에는 API 호출 주체를 나타내는 principal을 세팅한다.
- legacy `x-api-key`와 신규 Bearer token은 인증 수단은 다르지만, 내부 권한 판단에는 유사한 principal 모델을 제공해야 한다.
- rate limit은 인증 방식이 아니라 내부 식별자 기준으로 적용한다. 신규 방식은 API client id, legacy 방식은 기존 key id 또는 legacy client id를 기준으로 한다.
- 운영 기간 동안 두 인증 방식을 혼용하므로 SecurityFilterChain, 인증 실패 응답, rate limit 헤더, 관측 로그에서 인증 타입을 명확히 구분해야 한다.
- 기존 키 폐기 일정은 별도 운영 정책으로 결정한다. 이 ADR은 기존 키의 신규 발급 중단과 호환 유지까지만 결정한다.
- `x-api-key`와 Bearer token은 인증 수단은 다르지만, 내부 권한 판단에는 유사한 principal 모델을 제공해야 한다.
- rate limit은 인증 방식이 아니라 내부 식별자 기준으로 적용한다. OAuth2 방식은 API client id, API Key 방식은 key id 또는 API client id를 기준으로 한다.
- 두 인증 방식을 함께 제공하므로 SecurityFilterChain, 인증 실패 응답, rate limit 헤더, 관측 로그에서 인증 타입을 명확히 구분해야 한다.
- API Key도 로그인 사용자와 소유 관계를 가져야 하며, 발급·재발급·폐기 작업은 대시보드 인증 경계 안에서 수행한다.

## Alternatives
- 기존 `x-api-key` 방식 유지: 구현은 가장 단순하지만 신규 대시보드와 표준 인증 모델에 맞지 않아 제외한다.
- API Key만 제공: 구현은 가장 단순하지만 표준 인증과 짧은 수명 token이 필요한 운영 환경을 지원하지 못해 제외한다.
- OAuth2 Client Credentials만 제공: 인증 모델은 단순해지지만 간단한 연동의 진입 장벽이 커져 제외한다.
- 기존 `x-api-key`를 즉시 폐기: 보안 모델은 단순해지지만 기존 사용자의 API 호출을 깨뜨리므로 제외한다.
- OAuth2 Authorization Code를 API 호출 인증에 사용: 브라우저 사용자의 위임 인증에는 적합하지만 서버 간 API 호출용으로는 과하므로 제외한다.
- refresh token 제공: Client Credentials 1차 범위에서는 필요하지 않다. 만료 시 `client_id/client_secret`으로 access token을 다시 발급하면 된다.
Expand Down
2 changes: 2 additions & 0 deletions profanity-api/src/main/resources/application-sso.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ spring:
github:
client-id: ${GITHUB_OAUTH_CLIENT_ID:stub-github-client}
client-secret: ${GITHUB_OAUTH_CLIENT_SECRET:stub-github-secret}
redirect-uri: ${API_SERVER_URL:http://localhost:8080}/login/oauth2/code/{registrationId}
scope:
- read:user
- user:email
google:
client-id: ${GOOGLE_OAUTH_CLIENT_ID:stub-google-client}
client-secret: ${GOOGLE_OAUTH_CLIENT_SECRET:stub-google-secret}
redirect-uri: ${API_SERVER_URL:http://localhost:8080}/login/oauth2/code/{registrationId}
scope:
- openid
- profile
Expand Down
6 changes: 6 additions & 0 deletions ui/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ coverage/
.nuxt/
.svelte-kit/

# Local product-design review evidence
app/design-audit/
app/design-audit.md
app/design-qa-evidence/
app/design-qa.md

# TypeScript/Vite generated files
*.tsbuildinfo
vite.config.js
Expand Down
Loading