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
7 changes: 6 additions & 1 deletion api/v1alpha1/qdrantcluster_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 15 additions & 1 deletion config/crd/bases/qdrant.keiailab.com_qdrantclusters.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
17 changes: 15 additions & 2 deletions deploy/chart/templates/crd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1361,7 +1375,6 @@ spec:
statusReplicasPath: .status.readyReplicas
status: {}
---
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
Expand Down
78 changes: 78 additions & 0 deletions docs/design/apikey-auth-wiring-design.md
Original file line number Diff line number Diff line change
@@ -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__<SECTION>__<KEY>` 이중언더스코어 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은 **메커니즘만** 제공.
16 changes: 8 additions & 8 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -75,17 +75,17 @@ 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
golang.org/x/text v0.39.0 // indirect
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
Expand Down
40 changes: 20 additions & 20 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand All @@ -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=
Expand Down
42 changes: 42 additions & 0 deletions internal/controller/auth_warning_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
14 changes: 14 additions & 0 deletions internal/controller/qdrantcluster_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions internal/resources/names.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
Loading
Loading