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
112 changes: 112 additions & 0 deletions .codex/skills/manage-env-secrets/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
---
name: manage-env-secrets
description: Manage this profanity-filter-api project's environment variables and secret stores. Use when adding, removing, auditing, syncing, or troubleshooting env vars in local development .env files, the module.secrets submodule, 1Password items, ExternalSecret-backed k3s deployment, OAuth/SSO credentials, DB/Redis credentials, mail credentials, server keys, or any task involving secret-source/database-source/op:// references.
---

# Manage Env Secrets

## Operating Rule

Treat every env value as secret unless proven otherwise. Do not print, quote, summarize, paste, log, commit, or include raw values in final answers. Report only key names, counts, file paths, item names, and verification status.

If a command may print values, either do not run it or redirect stdout away from the conversation and verify with a separate key-name-only command. This matters because `op item edit` default output can reveal non-concealed field values.

## Source Map

Local development source:

- `module.secrets/project/profanity-filter/backend/.env`
- Use this when the user asks to prepare or adjust local/submodule env values.
- This is a secrets submodule, but values are still not safe to display.

k3s deployment source:

- Vault: `profanity-filter`
- 1Password item: `secret-source`
- 1Password item: `database-source`
- `deploy/overlays/production/external-secret.yaml` extracts both items into the `profanity-secrets` Kubernetes Secret.

Current item ownership:

- `database-source`: `DB_DRIVER`, `DB_PASSWORD`, `DB_URL`, `DB_USERNAME`, `REDIS_MAIN_HOST`, `REDIS_MAIN_PASSWORD`, `REDIS_MAIN_PORT`
- `secret-source`: application/general secrets such as `MAIL_USERNAME`, `MAIL_PASSWORD`, `SERVER_PORT`, `SERVER_KEYCODE`, `SERVER_KEYCODE_ALGORITHM`, `ACTUATOR_PATH`, OAuth/SSO fields

Do not duplicate DB/Redis fields into `secret-source` while `database-source` owns them.

## Safe Workflow

1. Inspect state without values.

Use key-name-only commands:

```bash
sed -n 's/^\([A-Za-z0-9_][A-Za-z0-9_]*\)=.*/\1/p' module.secrets/project/profanity-filter/backend/.env | sort
op item get secret-source --vault profanity-filter --format json | ruby -rjson -e 'item=JSON.parse(STDIN.read); (item["fields"] || []).map { |f| f["label"] }.compact.sort.each { |label| puts label }'
op item get database-source --vault profanity-filter --format json | ruby -rjson -e 'item=JSON.parse(STDIN.read); (item["fields"] || []).map { |f| f["label"] }.compact.sort.each { |label| puts label }'
```

2. Classify each requested key.

- DB/Redis -> `database-source`
- OAuth/SSO -> `secret-source`
- Mail/server/app control -> `secret-source`
- Unknown key -> inspect deployment/config references before choosing an item

3. Apply only the needed delta.

- Add missing keys.
- Update changed keys.
- Remove stale keys only when the user asked or the owning manifest/config no longer references them.
- Keep database and app items separated.

4. Verify without values.

After modification, query field labels only and report:

- target item
- added/updated/removed key names
- missing keys, if any
- no raw values

## 1Password Update Rules

Prefer JSON template or piped input over assignment statements. `op item edit secret-source KEY=value` is unsafe because process arguments can expose values.

When using `op item edit`, do not allow default output into the chat. Redirect stdout to `/dev/null`, then verify labels separately:

```bash
op item get secret-source --vault profanity-filter --format json \
| ruby -rjson -e '...build updated JSON without printing values...' \
| op item edit secret-source --vault profanity-filter >/dev/null
```

Then verify:

```bash
op item get secret-source --vault profanity-filter --format json \
| ruby -rjson -e 'item=JSON.parse(STDIN.read); (item["fields"] || []).map { |f| f["label"] }.compact.sort.each { |label| puts label }'
```

Use `CONCEALED` type for keys containing `PASSWORD`, `SECRET`, `TOKEN`, `KEY`, or `SIGNING_KEY` unless an existing field type is intentionally different.

Never use `--reveal` unless the user explicitly asks and the task cannot be done otherwise. Even then, avoid printing the value.

## Local .env Rules

When editing `module.secrets/project/profanity-filter/backend/.env`:

- Preserve existing unrelated lines and ordering where reasonable.
- Add new keys as `KEY=value`.
- Do not use root project `.env` or `sso/sso.env` as durable sources.
- Do not stage or commit ignored local scratch files.
- If committing the submodule, commit inside `module.secrets` first, then update the parent repo submodule pointer only if the user asks.

## Reporting

Keep reports short and factual:

- "Added 13 OAuth/SSO fields to `secret-source`; missing=none."
- "Skipped DB/Redis fields because `database-source` already owns them."
- "Did not print or reveal secret values."

Do not include command output that contains values.
4 changes: 4 additions & 0 deletions .codex/skills/manage-env-secrets/agents/openai.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
interface:
display_name: "Env Secret Manager"
short_description: "프로젝트 환경 변수와 1Password secret field를 안전하게 관리합니다."
default_prompt: "이 프로젝트의 로컬 .env, module.secrets, 1Password secret-source/database-source 환경 변수를 노출 없이 추가, 제거, 수정해줘."
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ application-prod.yml
/.env.redis
/docs/index.html
/data/**
/sso/sso.env
/profanity-api/src/main/resources/application-sso.local.yaml

.secrets
/http/http-client.env.json
Expand Down
13 changes: 9 additions & 4 deletions docs/adr/0005 SSO 기반 사용자 계정 모델 도입.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,21 @@

- 신규 사용자는 Google 또는 GitHub OAuth2 로그인으로만 가입한다.
- 내부 사용자 모델은 `users`로 표현하고, 외부 로그인 연결은 `oauth_accounts`로 분리한다.
- `users`는 서비스 내부의 사람 계정이며, 대시보드와 API 클라이언트 발급의 소유 주체가 된다.
- `users`는 서비스 내부의 사람 계정이며, 대시보드 로그인과 사용자 소유 리소스의 소유 주체가 된다.
- `oauth_accounts`는 provider, provider user id, provider email, email verified 상태처럼 외부 로그인 식별 정보를 저장한다.
- 한 사용자가 Google과 GitHub를 모두 연결할 수 있도록 `users`와 `oauth_accounts`는 1:N 관계로 둔다.
- 기존 `clients.email`은 로그인 계정으로 승격하지 않고, 기존 키 claim 및 연락용 legacy email로 취급한다.
- 기존 방식의 신규 API Key 발급은 중단한다. 단, 이미 발급된 기존 키는 호환성 유지를 위해 별도 유예 기간 동안 계속 허용한다.
- 외부 공개 API용 OAuth2 Client Credentials 토큰 발급과 신규 API Key 정책은 이 ADR에서 결정하지 않고 별도 ADR에서 다룬다.

## Implementation Scope
이번 0005 적용은 Google과 GitHub SSO 앱 등록, Spring OAuth2 client registration, authorization 진입점, callback URL, success/failure handler로 이어지는 콜백 파이프라인을 파악하고 검증하는 데 집중한다.

대시보드용 서버 로그인 토큰 발급, claim 할당, 기존 API Key 연결, 사용자 생성 및 `oauth_accounts` upsert 처리 로직은 다음 구현 단계에서 다룬다.

## Consequences
- 사람 인증과 API 호출 인증의 책임이 분리된다.
- 사람 인증과 외부 공개 API 호출 인증의 책임이 분리된다. 대시보드용 stateless 인증 토큰과 대시보드 API 호출 인증은 SSO 이후 단계에서 별도로 구현한다.
- 로컬 비밀번호 저장과 비밀번호 재설정 기능을 만들지 않아도 된다.
- 대시보드에서 “내 API 클라이언트”, “기존 키 연결”, “신규 인증 정보 발급” 같은 사용자 중심 기능을 제공할 수 있다.
- 대시보드에서 “내 계정”, “외부 로그인 연결”, “기존 키 연결” 같은 사용자 중심 기능을 제공할 수 있다.
- GitHub는 이메일 비공개 또는 미검증 케이스가 있으므로 provider email만으로 기존 키 소유권을 자동 확정하면 안 된다.
- 기존 사용자는 SSO 로그인 후 기존 API Key와 이메일 인증을 통해 기존 키를 claim하는 별도 마이그레이션 흐름이 필요하다.
- 운영자는 기존 사용자에게 전환 안내 메일을 보내고, 미전환 사용자를 추적할 수 있어야 한다.
Expand Down
2 changes: 1 addition & 1 deletion module.secrets
1 change: 1 addition & 0 deletions profanity-api/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-aop'

Expand Down
21 changes: 21 additions & 0 deletions profanity-api/src/main/java/app/security/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,17 @@
import app.security.authentication.AuthenticationService;
import app.security.filter.CustomAuthenticationEntryPoint;
import app.security.filter.CustomAuthenticationFilter;
import app.security.oauth2.CookieOAuth2AuthorizationRequestRepository;
import app.security.oauth2.OAuth2LoginFailureHandler;
import app.security.oauth2.OAuth2LoginSuccessHandler;
import app.security.oauth2.OAuth2SecuritySupportConfig;
import java.util.Arrays;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
Expand All @@ -25,11 +30,16 @@

@Configuration
@EnableWebSecurity
@Import(OAuth2SecuritySupportConfig.class)
@RequiredArgsConstructor
public class SecurityConfig {

private final AuthenticationService authenticationService;
private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
private final CookieOAuth2AuthorizationRequestRepository
cookieOAuth2AuthorizationRequestRepository;
private final OAuth2LoginSuccessHandler oauth2LoginSuccessHandler;
private final OAuth2LoginFailureHandler oauth2LoginFailureHandler;

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
Expand All @@ -43,13 +53,24 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
.headers(
headersConfigurer ->
headersConfigurer.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin))
.oauth2Login(
oauth2 ->
oauth2
.authorizationEndpoint(
authorization ->
authorization.authorizationRequestRepository(
cookieOAuth2AuthorizationRequestRepository))
.successHandler(oauth2LoginSuccessHandler)
.failureHandler(oauth2LoginFailureHandler))
.authorizeHttpRequests(
authorizationManagerRequestMatcherRegistry ->
authorizationManagerRequestMatcherRegistry
.requestMatchers(PathRequest.toStaticResources().atCommonLocations())
.permitAll()
.requestMatchers(HttpMethod.GET, "/", "/index.html")
.permitAll()
.requestMatchers("/sso/**", "/oauth2/**", "/login/oauth2/code/**")
.permitAll()
.requestMatchers(HttpMethod.POST, "/api/v1/clients/**")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/health")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ public enum ExcludePath {
OPENAPI("openapi.json", "OpenAPI JSON 스펙", List.of(HttpMethod.GET)),
OVERVIEW("overview.md", "API Overview Markdown 문서", List.of(HttpMethod.GET)),
LLMS(List.of("llms.txt", "llm.txt"), "LLM 문서 색인", List.of(HttpMethod.GET)),
SSO("sso", "SSO 정적 페이지", List.of(HttpMethod.GET)),
OAUTH2("oauth2", "OAuth2 인증 시작", List.of(HttpMethod.GET)),
OAUTH2_CALLBACK("login/oauth2/code", "OAuth2 callback", List.of(HttpMethod.GET)),
;

private final List<String> paths;
Expand Down
Loading