diff --git a/api/v1alpha1/qdrantcluster_types.go b/api/v1alpha1/qdrantcluster_types.go index f5cd6b5..c225f29 100644 --- a/api/v1alpha1/qdrantcluster_types.go +++ b/api/v1alpha1/qdrantcluster_types.go @@ -74,7 +74,12 @@ type QdrantClusterSpec struct { // +kubebuilder:default="ClusterIP" ServiceType corev1.ServiceType `json:"serviceType,omitempty"` ServiceAnnotations map[string]string `json:"serviceAnnotations,omitempty"` - APIKey *SecretKeyRef `json:"apiKey,omitempty"` + // APIKey 는 쓰기 권한 인증 키 — QDRANT__SERVICE__API_KEY env(secretKeyRef)로 주입된다. + APIKey *SecretKeyRef `json:"apiKey,omitempty"` + // ReadOnlyAPIKey 는 읽기 전용 소비자용 최소권한 키 — QDRANT__SERVICE__READ_ONLY_API_KEY. + // 검색만 하는 소비자에게 쓰기 권한 없는 키를 발급하기 위한 별개 축이다. + // +optional + ReadOnlyAPIKey *SecretKeyRef `json:"readOnlyApiKey,omitempty"` // +kubebuilder:default=1000 RunAsUser int64 `json:"runAsUser,omitempty"` // +kubebuilder:default=3000 diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 35f88ae..d1d58c6 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -235,6 +235,11 @@ func (in *QdrantClusterSpec) DeepCopyInto(out *QdrantClusterSpec) { *out = new(SecretKeyRef) **out = **in } + if in.ReadOnlyAPIKey != nil { + in, out := &in.ReadOnlyAPIKey, &out.ReadOnlyAPIKey + *out = new(SecretKeyRef) + **out = **in + } if in.Rebalance != nil { in, out := &in.Rebalance, &out.Rebalance *out = new(RebalanceSpec) diff --git a/config/crd/bases/qdrant.keiailab.com_qdrantclusters.yaml b/config/crd/bases/qdrant.keiailab.com_qdrantclusters.yaml index fae2c58..fe8c054 100644 --- a/config/crd/bases/qdrant.keiailab.com_qdrantclusters.yaml +++ b/config/crd/bases/qdrant.keiailab.com_qdrantclusters.yaml @@ -952,7 +952,8 @@ spec: type: object type: object apiKey: - description: SecretKeyRef는 API 키를 담은 Secret 참조다 + description: APIKey 는 쓰기 권한 인증 키 — QDRANT__SERVICE__API_KEY env(secretKeyRef)로 + 주입된다. properties: key: default: api-key @@ -1022,6 +1023,19 @@ spec: default: ceph-rbd type: string type: object + readOnlyApiKey: + description: |- + ReadOnlyAPIKey 는 읽기 전용 소비자용 최소권한 키 — QDRANT__SERVICE__READ_ONLY_API_KEY. + 검색만 하는 소비자에게 쓰기 권한 없는 키를 발급하기 위한 별개 축이다. + properties: + key: + default: api-key + type: string + name: + type: string + required: + - name + type: object rebalance: description: |- B-3 shard 재배치 제어 — 미지정 시 활성(enabled=true). enabled=false 는 dry-run diff --git a/deploy/chart/templates/crd.yaml b/deploy/chart/templates/crd.yaml index 9cabbcc..3125dfd 100644 --- a/deploy/chart/templates/crd.yaml +++ b/deploy/chart/templates/crd.yaml @@ -952,7 +952,8 @@ spec: type: object type: object apiKey: - description: SecretKeyRef는 API 키를 담은 Secret 참조다 + description: APIKey 는 쓰기 권한 인증 키 — QDRANT__SERVICE__API_KEY env(secretKeyRef)로 + 주입된다. properties: key: default: api-key @@ -1022,6 +1023,19 @@ spec: default: ceph-rbd type: string type: object + readOnlyApiKey: + description: |- + ReadOnlyAPIKey 는 읽기 전용 소비자용 최소권한 키 — QDRANT__SERVICE__READ_ONLY_API_KEY. + 검색만 하는 소비자에게 쓰기 권한 없는 키를 발급하기 위한 별개 축이다. + properties: + key: + default: api-key + type: string + name: + type: string + required: + - name + type: object rebalance: description: |- B-3 shard 재배치 제어 — 미지정 시 활성(enabled=true). enabled=false 는 dry-run @@ -1361,7 +1375,6 @@ spec: statusReplicasPath: .status.readyReplicas status: {} --- ---- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: diff --git a/docs/design/apikey-auth-wiring-design.md b/docs/design/apikey-auth-wiring-design.md new file mode 100644 index 0000000..9b34e26 --- /dev/null +++ b/docs/design/apikey-auth-wiring-design.md @@ -0,0 +1,78 @@ +# apiKey 인증 배선 설계 (이슈 #4) + +## 배경 + +`QdrantClusterSpec.apiKey`(`*SecretKeyRef`)는 CRD·Go 타입에 **선언만** 돼 있고 컨트롤러가 이 필드를 소비하지 않는다. CR에 `apiKey`를 지정해도 StatefulSet에 인증 env가 주입되지 않아 Qdrant는 무인증으로 기동한다(silent no-op). `retentionPolicy` unwired(v0.2.1 fix)와 동일 계열. + +현재 완화책은 NetworkPolicy/Cilium 실효 통제지만, 이는 L3/L4(누가 연결하나)라 application-level authz(무엇을 하나)를 대체하지 못한다. 허용된 소비자 ns는 무인증 전체 접근(파괴 API 포함)이다. + +## 목표 + +1. `spec.apiKey`(쓰기 키)를 `QDRANT__SERVICE__API_KEY`로 배선한다. +2. 신규 `spec.readOnlyApiKey`(읽기 전용 키)를 `QDRANT__SERVICE__READ_ONLY_API_KEY`로 배선한다 — 검색만 하는 소비자에게 최소권한 키 발급을 가능케 한다. +3. TLS 비활성 상태에서 키가 설정되면 경고를 남긴다(게이팅하지 않음, 인증은 활성). + +## 비목표 (YAGNI) + +- 소비자별 키 분배·회전 자동화(운영 영역, 오퍼레이터 밖). +- TLS 강제 게이팅(별개 축의 결정 — 클러스터 내부 트래픽에 api_key 단독은 통상 패턴). +- production.yaml에 api_key 기입(ConfigMap = 평문 노출이므로 금지). + +## 설계 + +### 1. CRD 타입 (`api/v1alpha1/qdrantcluster_types.go`) + +```go +APIKey *SecretKeyRef `json:"apiKey,omitempty"` // 기존 (쓰기) +ReadOnlyAPIKey *SecretKeyRef `json:"readOnlyApiKey,omitempty"` // 신규 (읽기 전용) +``` + +`SecretKeyRef`는 재사용(`Key` 기본값 `"api-key"`). optional 필드 추가라 기존 CR은 nil로 읽혀 하위호환 안전. `make generate`(deepcopy) + `make manifests`(CRD yaml, config/crd + deploy/chart 동기)로 재생성. + +### 2. env 배선 (`internal/resources/statefulset.go`) + +`readOnlyRootFilesystem=true`라 config 파일 런타임 수정 불가 → qdrant의 `QDRANT__
__` 이중언더스코어 env override 사용. Secret 값은 `valueFrom.secretKeyRef`로 주입해 평문 노출 0. + +```go +// apiKeyEnv 는 설정된 키에 한해 QDRANT 인증 env 를 secretKeyRef 로 만든다. +// 미설정 CR 이면 빈 슬라이스 → golden(helm template) parity 유지. +func apiKeyEnv(qc *qdrantv1alpha1.QdrantCluster) []corev1.EnvVar +``` + +기존 `Env`(`QDRANT_INIT_FILE_PATH`)에 `append(base, apiKeyEnv(qc)...)`. **미설정 시 추가 0**이 핵심 — `BuildStatefulSet` golden parity 주석("필드 임의 추가 금지")을 지킨다. `SecretKeyRef.Key`가 빈 문자열이면 `"api-key"` fallback(빌더 방어; CRD default가 이미 채우지만 직접 생성한 CR 대비). + +env 순서: 결정론 위해 base → apiKey → readOnlyApiKey 고정. + +### 3. TLS-off 경고 (`internal/controller/qdrantcluster_controller.go`) + +reconcile 흐름에서 `(APIKey != nil || ReadOnlyAPIKey != nil) && !Config.TLSEnabled`이면: + +```go +commonsevents.EmitWarningf(r.Recorder, qc, "AuthWithoutTLS", + "apiKey 가 설정됐으나 TLS 비활성 — 키가 평문으로 전송됩니다") +``` + +기존 `ImmutableFieldChanged` 가드와 동일 패턴(commons events 어댑터). 게이팅·에러 반환 없음 — 인증은 정상 활성. 로그도 함께. + +## 테스트 (`internal/resources/statefulset_test.go`) + +표준 `testing` + env 순회 검증(`TestBuildStatefulSet_Entrypoint` 패턴 따름): + +1. `미설정` → `QDRANT__SERVICE__*` env 부재(golden parity). env 개수 = 기존과 동일. +2. `apiKey만` → `QDRANT__SERVICE__API_KEY` 존재, `ValueFrom.SecretKeyRef.{Name,Key}` 정확. +3. `readOnlyApiKey만` → `QDRANT__SERVICE__READ_ONLY_API_KEY` 존재. +4. `둘 다` → 둘 다 존재. +5. `Key 미지정` → `"api-key"` fallback. + +컨트롤러 TLS-off 경고는 기존 envtest 스위트(`suite_test.go`) 관례를 따르되, 최소한 빌더/조건 단위 검증으로 커버. + +## 수용 기준 + +- CR에 `apiKey` 지정 → STS env에 `QDRANT__SERVICE__API_KEY`(secretKeyRef) 주입 → 무인증 REST 요청이 401. +- 미설정 CR → 기존 STS와 diff 0(golden parity). +- **envtest는 이 갭을 못 잡았으니**(선언≠배선), 격리 스테이징에서 무인증 401 실측을 최종 게이트로. + +## 롤아웃 + +- optional 필드 추가 + 조건부 env라 라이브 CR(`apiKey` 미지정)에는 무행동 → dark landing 아님, 회귀 0. +- 실제 인증 활성화는 별도 작업: Secret 생성 → CR에 `apiKey`/`readOnlyApiKey` 지정 → 소비자 egress에 키 배포. 이 PR은 **메커니즘만** 제공. diff --git a/go.mod b/go.mod index 210bcc8..ddc42a1 100644 --- a/go.mod +++ b/go.mod @@ -61,12 +61,12 @@ require ( github.com/x448/float16 v0.8.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect - go.opentelemetry.io/otel v1.41.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 // indirect - go.opentelemetry.io/otel/metric v1.41.0 // indirect - go.opentelemetry.io/otel/sdk v1.40.0 // indirect - go.opentelemetry.io/otel/trace v1.41.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/sdk v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect @@ -75,7 +75,7 @@ require ( golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect golang.org/x/mod v0.37.0 // indirect golang.org/x/net v0.56.0 // indirect - golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/term v0.44.0 // indirect @@ -83,9 +83,9 @@ require ( golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.47.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/grpc v1.79.3 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect + google.golang.org/grpc v1.82.1 // indirect google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/go.sum b/go.sum index f7ddb5d..5bfbcf1 100644 --- a/go.sum +++ b/go.sum @@ -158,20 +158,20 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= -go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= -go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 h1:DvJDOPmSWQHWywQS6lKL+pb8s3gBLOZUtw4N+mavW1I= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0/go.mod h1:EtekO9DEJb4/jRyN4v4Qjc2yA7AtfCBuz2FynRUWTXs= -go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= -go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= -go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= -go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= -go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= -go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= -go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= -go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -190,8 +190,8 @@ golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= @@ -206,14 +206,14 @@ golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= -google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/controller/auth_warning_test.go b/internal/controller/auth_warning_test.go new file mode 100644 index 0000000..94247a9 --- /dev/null +++ b/internal/controller/auth_warning_test.go @@ -0,0 +1,42 @@ +/* +Copyright 2026 Keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +package controller + +import ( + "testing" + + qdrantv1alpha1 "github.com/keiailab/qdrant-operator/api/v1alpha1" +) + +// authWithoutTLS 진리표 — 인증 키가 설정됐고 TLS 가 꺼진 경우에만 경고 대상. +func TestAuthWithoutTLS(t *testing.T) { + ref := &qdrantv1alpha1.SecretKeyRef{Name: "qdrant-auth", Key: "api-key"} + cases := []struct { + name string + apiKey, roKey *qdrantv1alpha1.SecretKeyRef + tls bool + want bool + }{ + {"키없음+TLSoff", nil, nil, false, false}, + {"쓰기키+TLSoff", ref, nil, false, true}, + {"읽기키+TLSoff", nil, ref, false, true}, + {"둘다+TLSoff", ref, ref, false, true}, + {"쓰기키+TLSon", ref, nil, true, false}, + {"키없음+TLSon", nil, nil, true, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + qc := &qdrantv1alpha1.QdrantCluster{} + qc.Spec.APIKey = c.apiKey + qc.Spec.ReadOnlyAPIKey = c.roKey + qc.Spec.Config.TLSEnabled = c.tls + if got := authWithoutTLS(qc); got != c.want { + t.Fatalf("authWithoutTLS=%v (want %v)", got, c.want) + } + }) + } +} diff --git a/internal/controller/qdrantcluster_controller.go b/internal/controller/qdrantcluster_controller.go index 60204ed..8c19cc7 100644 --- a/internal/controller/qdrantcluster_controller.go +++ b/internal/controller/qdrantcluster_controller.go @@ -134,6 +134,12 @@ func (r *QdrantClusterReconciler) Reconcile(ctx context.Context, req ctrl.Reques } } + // 인증 키가 켜졌는데 TLS 가 꺼져 있으면 키가 평문으로 전송된다 — 게이팅하지 않고 경고만(설계 결정). + if authWithoutTLS(qc) { + commonsevents.EmitWarningf(r.Recorder, qc, "AuthWithoutTLS", + "apiKey 가 설정됐으나 TLS 비활성 — 키가 평문으로 전송됩니다") + } + if err := r.applyOwned(ctx, qc, sts); err != nil { return ctrl.Result{}, err } @@ -142,6 +148,14 @@ func (r *QdrantClusterReconciler) Reconcile(ctx context.Context, req ctrl.Reques // stsImmutableChanged는 라이브 STS(existing)와 렌더 결과(desired) 사이에 apiserver가 거부할 // immutable 필드 변경이 있는지 검사한다. +// authWithoutTLS 는 인증 키(쓰기/읽기)가 설정됐는데 TLS 가 꺼져 있어 경고 대상인지 판정한다. +// 게이팅이 아니라 경고용 순수 함수 — 클러스터 내부 평문 트래픽에 api_key 단독은 통상 패턴이나, +// 키가 평문 전송된다는 사실을 운영자에게 표면화한다. +func authWithoutTLS(qc *qdrantv1alpha1.QdrantCluster) bool { + hasKey := qc.Spec.APIKey != nil || qc.Spec.ReadOnlyAPIKey != nil + return hasKey && !qc.Spec.Config.TLSEnabled +} + func stsImmutableChanged(existing, desired *appsv1.StatefulSet) bool { if existing.Spec.ServiceName != desired.Spec.ServiceName { return true diff --git a/internal/resources/names.go b/internal/resources/names.go index 79fdda5..40643e8 100644 --- a/internal/resources/names.go +++ b/internal/resources/names.go @@ -28,6 +28,11 @@ const ( InitVolumeName = "qdrant-init" ) +// DefaultAPIKeySecretKey 는 SecretKeyRef.Key 미지정 시 fallback 하는 Secret 데이터 키다. +// CRD default 마커(qdrantcluster_types.go)와 반드시 같은 값이어야 한다 — marker 는 리터럴만 +// 허용해 상수를 공유할 수 없으므로 값 일치를 주석으로 강제한다. +const DefaultAPIKeySecretKey = "api-key" + // 컨테이너 마운트 경로 — ConfigMountDir 와 동일 패턴. golden 컨테이너 volumeMounts SSOT. // InitMountDir 는 마운트와 QDRANT_INIT_FILE_PATH env 값이 함께 참조하므로 상수화가 필수다. const ( diff --git a/internal/resources/statefulset.go b/internal/resources/statefulset.go index aa1770b..6847ad7 100644 --- a/internal/resources/statefulset.go +++ b/internal/resources/statefulset.go @@ -85,9 +85,9 @@ func BuildStatefulSet(qc *qdrantv1alpha1.QdrantCluster) *appsv1.StatefulSet { // args 는 qdrant 이미지 WORKDIR(/qdrant) 기준 상대경로. Command: []string{"/bin/bash", "-c"}, Args: []string{"./config/initialize.sh"}, - Env: []corev1.EnvVar{ + Env: append([]corev1.EnvVar{ {Name: "QDRANT_INIT_FILE_PATH", Value: InitMountDir + "/.qdrant-initialized"}, - }, + }, apiKeyEnv(qc)...), Resources: res, Ports: []corev1.ContainerPort{ {Name: "http", ContainerPort: RESTPort, Protocol: corev1.ProtocolTCP}, @@ -147,6 +147,38 @@ func BuildStatefulSet(qc *qdrantv1alpha1.QdrantCluster) *appsv1.StatefulSet { } } +// apiKeyEnv 는 설정된 인증 키를 qdrant 이중언더스코어 env override 로 만든다. Secret 값은 +// valueFrom.secretKeyRef 로만 주입해 ConfigMap(평문) 경로를 피한다. 미설정 CR 이면 nil 반환 → +// STS env 에 아무것도 추가되지 않아 golden(helm template) parity 를 유지한다. +func apiKeyEnv(qc *qdrantv1alpha1.QdrantCluster) []corev1.EnvVar { + var env []corev1.EnvVar + if ref := qc.Spec.APIKey; ref != nil { + env = append(env, secretEnv("QDRANT__SERVICE__API_KEY", ref)) + } + if ref := qc.Spec.ReadOnlyAPIKey; ref != nil { + env = append(env, secretEnv("QDRANT__SERVICE__READ_ONLY_API_KEY", ref)) + } + return env +} + +// secretEnv 는 SecretKeyRef 를 secretKeyRef env 로 변환한다. Key 가 비면 'api-key' 로 방어 +// (CRD default 와 동일값) — 빈 Secret 키 참조로 인한 런타임 실패를 막는다. +func secretEnv(name string, ref *qdrantv1alpha1.SecretKeyRef) corev1.EnvVar { + key := ref.Key + if key == "" { + key = DefaultAPIKeySecretKey + } + return corev1.EnvVar{ + Name: name, + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: ref.Name}, + Key: key, + }, + }, + } +} + // resolveAffinity 는 spec.affinity 를 그대로 존중하되, 미지정 + replicas>=2 이면 HA 기본값으로 // soft(preferred) pod anti-affinity 를 주입한다 — 같은 노드에 복제본이 몰려 노드 1대 장애가 // 전체 중단으로 번지는 것을 막는다. required 가 아닌 preferred 인 이유: 노드가 부족하면 diff --git a/internal/resources/statefulset_test.go b/internal/resources/statefulset_test.go index e7fbe49..09f9074 100644 --- a/internal/resources/statefulset_test.go +++ b/internal/resources/statefulset_test.go @@ -314,3 +314,95 @@ func TestBuildPodDisruptionBudget(t *testing.T) { t.Fatalf("selector: %+v", p.Spec.Selector) } } + +// apiKeyQC 는 apiKey 배선 검증용 최소 스펙 — golden fixture 와 분리해 env 추가가 +// parity 를 깨지 않는지(미설정 케이스)도 독립적으로 본다. +func apiKeyQC() *qdrantv1alpha1.QdrantCluster { + qc := &qdrantv1alpha1.QdrantCluster{ObjectMeta: metav1.ObjectMeta{Name: "c1", Namespace: "data"}} + qc.Spec.Replicas = 1 + qc.Spec.Image = qdrantv1alpha1.ImageSpec{Repository: "qdrant/qdrant", Tag: "v1.18.2"} + // Size 는 포인터 필드 — CRD default 는 apiserver 라운드트립에서만 발동하므로 빌더 단위 + // 테스트에선 명시하지 않으면 nil deref panic. golden fixture 와 동일하게 채운다. + tenGi := resource.MustParse("10Gi") + qc.Spec.Persistence = qdrantv1alpha1.PersistenceSpec{Size: &tenGi, StorageClassName: "ceph-rbd", AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}} + qc.Spec.RunAsUser, qc.Spec.FSGroup = 1000, 3000 + return qc +} + +func findEnv(c corev1.Container, name string) *corev1.EnvVar { + for i := range c.Env { + if c.Env[i].Name == name { + return &c.Env[i] + } + } + return nil +} + +func TestBuildStatefulSet_APIKey_WriteKey(t *testing.T) { + qc := apiKeyQC() + qc.Spec.APIKey = &qdrantv1alpha1.SecretKeyRef{Name: "qdrant-auth", Key: "api-key"} + + c := BuildStatefulSet(qc).Spec.Template.Spec.Containers[0] + e := findEnv(c, "QDRANT__SERVICE__API_KEY") + if e == nil { + t.Fatal("apiKey 설정 시 env QDRANT__SERVICE__API_KEY 가 주입돼야 함") + } + // Secret 값은 valueFrom.secretKeyRef 로만 — Value(평문) 금지. + if e.Value != "" { + t.Fatalf("Value 는 비어야 함(평문 노출 금지), got %q", e.Value) + } + if e.ValueFrom == nil || e.ValueFrom.SecretKeyRef == nil { + t.Fatal("QDRANT__SERVICE__API_KEY 는 secretKeyRef 여야 함") + } + if ref := e.ValueFrom.SecretKeyRef; ref.Name != "qdrant-auth" || ref.Key != "api-key" { + t.Fatalf("secretKeyRef=%+v (want name=qdrant-auth key=api-key)", ref) + } +} + +func TestBuildStatefulSet_APIKey_ReadOnlyKey(t *testing.T) { + qc := apiKeyQC() + qc.Spec.ReadOnlyAPIKey = &qdrantv1alpha1.SecretKeyRef{Name: "qdrant-auth", Key: "read-only-key"} + + c := BuildStatefulSet(qc).Spec.Template.Spec.Containers[0] + e := findEnv(c, "QDRANT__SERVICE__READ_ONLY_API_KEY") + if e == nil { + t.Fatal("readOnlyApiKey 설정 시 env QDRANT__SERVICE__READ_ONLY_API_KEY 가 주입돼야 함") + } + if e.ValueFrom == nil || e.ValueFrom.SecretKeyRef == nil { + t.Fatal("QDRANT__SERVICE__READ_ONLY_API_KEY 는 secretKeyRef 여야 함") + } + if ref := e.ValueFrom.SecretKeyRef; ref.Name != "qdrant-auth" || ref.Key != "read-only-key" { + t.Fatalf("secretKeyRef=%+v (want name=qdrant-auth key=read-only-key)", ref) + } + // 쓰기 키를 안 줬으면 쓰기 API_KEY 는 주입되지 않아야 한다(두 키 독립). + if findEnv(c, "QDRANT__SERVICE__API_KEY") != nil { + t.Fatal("readOnly 만 설정했는데 쓰기 QDRANT__SERVICE__API_KEY 가 주입됨") + } +} + +func TestBuildStatefulSet_APIKey_KeyDefault(t *testing.T) { + qc := apiKeyQC() + qc.Spec.APIKey = &qdrantv1alpha1.SecretKeyRef{Name: "qdrant-auth"} // Key 미지정 + + c := BuildStatefulSet(qc).Spec.Template.Spec.Containers[0] + e := findEnv(c, "QDRANT__SERVICE__API_KEY") + if e == nil || e.ValueFrom == nil || e.ValueFrom.SecretKeyRef == nil { + t.Fatal("env QDRANT__SERVICE__API_KEY 누락") + } + // Key 미지정이면 빈 Secret 키(런타임 실패) 대신 'api-key' 로 방어. + if got := e.ValueFrom.SecretKeyRef.Key; got != "api-key" { + t.Fatalf("Key 미지정 시 기본 'api-key' 여야 함, got %q", got) + } +} + +func TestBuildStatefulSet_APIKey_미설정parity(t *testing.T) { + // 인증 키를 안 준 CR 은 인증 env 를 하나도 추가하지 않아야 한다 — golden(helm) parity. + c := BuildStatefulSet(apiKeyQC()).Spec.Template.Spec.Containers[0] + if findEnv(c, "QDRANT__SERVICE__API_KEY") != nil || findEnv(c, "QDRANT__SERVICE__READ_ONLY_API_KEY") != nil { + t.Fatal("apiKey 미설정 CR 에 인증 env 가 주입됨 — golden parity 위반") + } + // env 는 QDRANT_INIT_FILE_PATH 하나뿐이어야 한다. + if len(c.Env) != 1 { + t.Fatalf("미설정 CR env 개수=%d (want 1: QDRANT_INIT_FILE_PATH 만)", len(c.Env)) + } +}