From a0067399b5a9ed3c7d4c495a49d4ca5621c3c7ac Mon Sep 17 00:00:00 2001 From: Deepansh Date: Tue, 21 Jul 2026 19:52:10 +0530 Subject: [PATCH 1/9] add the driver and the configurations --- go.mod | 2 +- go.sum | 28 ++++++++++------ modules/dagger/config.go | 24 ++++++++++++++ modules/dagger/driver.go | 14 ++++++++ modules/dagger/driver_plan.go | 12 +++++++ modules/kafka/config.go | 56 ++++++++++++++++++++++++++++---- modules/kafka/driver.go | 10 ++++-- modules/kafka/schema/config.json | 51 +++++++++++++++++++++++++++++ 8 files changed, 177 insertions(+), 20 deletions(-) diff --git a/go.mod b/go.mod index cf30fcf2..916754d2 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( github.com/spf13/cobra v1.7.0 github.com/stretchr/testify v1.11.1 github.com/xeipuuv/gojsonschema v1.2.0 + go.nhat.io/otelsql v0.16.0 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 go.opentelemetry.io/otel v1.38.0 @@ -49,7 +50,6 @@ require ( require ( github.com/felixge/httpsnoop v1.0.4 // indirect - go.nhat.io/otelsql v0.16.0 // indirect go.opencensus.io v0.24.0 // indirect ) diff --git a/go.sum b/go.sum index b00a0475..eb7502af 100644 --- a/go.sum +++ b/go.sum @@ -46,8 +46,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= -github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= @@ -93,6 +93,8 @@ github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24 github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= +github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= github.com/briandowns/spinner v1.23.0 h1:alDF2guRWqa/FOZZYWjlMIx2L6H0wyewPxo/CH4Pt2A= github.com/briandowns/spinner v1.23.0/go.mod h1:rPG4gmXeN3wQV/TsAY4w8lPdIM6RX3yqeBQJSrbXjuE= github.com/bshuster-repo/logrus-logstash-hook v1.0.0 h1:e+C0SB5R1pu//O4MQ3f9cFuPGoOVeF2fE4Og9otCc70= @@ -194,8 +196,6 @@ github.com/foxcpp/go-mockdns v1.0.0/go.mod h1:lgRN6+KxQBawyIghpnl5CezHFGS9VLzvtV github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k= github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY= github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= -github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= @@ -380,6 +380,8 @@ github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU= github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= +github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= @@ -452,8 +454,6 @@ github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxec github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= @@ -597,9 +597,8 @@ github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD github.com/schollz/progressbar/v3 v3.13.1 h1:o8rySDYiQ59Mwzy2FELeHY5ZARXZTVJC7iHD6PEFUiE= github.com/schollz/progressbar/v3 v3.13.1/go.mod h1:xvrbki8kfT1fzWzBT/UZd9L6GA+jdL7HAgq2RFnO6fQ= github.com/seccomp/libseccomp-golang v0.9.2-0.20220502022130-f33da4d89646/go.mod h1:JA8cRccbGaA1s33RQf7Y1+q9gHmZX1yB/z9WDN1C6fg= -github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= -github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= -github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= @@ -643,6 +642,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ= +github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU= github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww= github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= @@ -660,6 +661,10 @@ github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17 github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= +github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= +github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= +github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= +github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -700,6 +705,10 @@ go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.45.0 h1:tfi go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.45.0/go.mod h1:AKFZIEPOnqB00P63bTjOiah4ZTaRzl1TKwUWpZdYUHI= go.opentelemetry.io/otel/exporters/prometheus v0.56.0 h1:GnCIi0QyG0yy2MrJLzVrIM7laaJstj//flf1zEJCG+E= go.opentelemetry.io/otel/exporters/prometheus v0.56.0/go.mod h1:JQcVZtbIIPM+7SWBB+T6FK+xunlyidwLp++fN0sUaOk= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.24.0 h1:JYE2HM7pZbOt5Jhk8ndWZTUWYOVift2cHjXVMkPdmdc= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.24.0/go.mod h1:yMb/8c6hVsnma0RpsBMNo0fEiQKeclawtgaIaOp2MLY= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.24.0 h1:s0PHtIkN+3xrbDOpt2M8OTG92cWqUESvzh2MxiR5xY8= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.24.0/go.mod h1:hZlFbDbRt++MMPCCfSJfmhkGIWnX1h3XjkfxZUjLrIA= go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= @@ -888,7 +897,6 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= diff --git a/modules/dagger/config.go b/modules/dagger/config.go index e328eb14..57fe8038 100644 --- a/modules/dagger/config.go +++ b/modules/dagger/config.go @@ -133,6 +133,26 @@ type Config struct { DaggerK8sHAURL string `json:"dagger_k8s_ha_url,omitempty"` CloudProvider string `json:"cloud_provider,omitempty"` FSOSSEndpoint string `json:"fs_oss_endpoint,omitempty"` + + // TaskManagerServiceAccount, when set, becomes + // kubernetes.taskmanager.service-account in the FlinkDeployment. It is the + // OAuth identity aegis-kafka authorized for ACL streams. Empty preserves the + // default (serviceAccount: flink) behaviour. + TaskManagerServiceAccount string `json:"taskmanager_service_account,omitempty"` + + // ACLMounts is the set of secret / projected-token volume mounts required by + // ACL (SASL_SSL/OAUTHBEARER, SSL, PLAIN/SCRAM) source streams. It is + + ACLMounts []ACLMount `json:"acl_mounts,omitempty"` +} + +// ACLMount describes a single podTemplate volume+mount for an ACL stream. +// Type is either "secret" or "projected". +type ACLMount struct { + Name string `json:"name"` + MountPath string `json:"mountPath"` + SecretName string `json:"secretName,omitempty"` + Type string `json:"type"` } type ChartValues struct { @@ -154,6 +174,10 @@ type SourceKafka struct { SourceKafkaName string `json:"SOURCE_KAFKA_NAME"` SourceKafkaConsumerConfigGroupID string `json:"SOURCE_KAFKA_CONSUMER_CONFIG_GROUP_ID"` SourceKafkaConsumerConfigBootstrapServers string `json:"SOURCE_KAFKA_CONSUMER_CONFIG_BOOTSTRAP_SERVERS"` + // SourceKafkaConsumerAdditionalConfigurations carries the SASL/SSL consumer + // config for ACL streams. It is nil for plaintext streams so that STREAMS + // stays byte-for-byte identical to the pre-ACL behaviour. + SourceKafkaConsumerAdditionalConfigurations map[string]interface{} `json:"SOURCE_KAFKA_CONSUMER_ADDITIONAL_CONFIGURATIONS,omitempty"` } type SourceParquet struct { diff --git a/modules/dagger/driver.go b/modules/dagger/driver.go index 9eef8afc..8942570a 100644 --- a/modules/dagger/driver.go +++ b/modules/dagger/driver.go @@ -255,6 +255,16 @@ func (dd *daggerDriver) getHelmRelease(res resource.Resource, conf Config, requiredDuringSchedulingIgnoredDuringExecutionInterface := kubernetes.PreferenceSliceToInterfaceSlice(requiredDuringSchedulingIgnoredDuringExecution) preferredDuringSchedulingIgnoredDuringExecutionInterface := kubernetes.WeightedPreferencesToInterfaceSlice(preferredDuringSchedulingIgnoredDuringExecution) + aclMounts := make([]map[string]any, 0, len(conf.ACLMounts)) + for _, m := range conf.ACLMounts { + aclMounts = append(aclMounts, map[string]any{ + "name": m.Name, + "mountPath": m.MountPath, + "secretName": m.SecretName, + "type": m.Type, + }) + } + rc.Values = map[string]any{ labelsConfKey: modules.CloneAndMergeMaps(deploymentLabels, entropyLabels), "image": imageRepository, @@ -292,6 +302,10 @@ func (dd *daggerDriver) getHelmRelease(res resource.Resource, conf Config, "requiredDuringSchedulingIgnoredDuringExecution": requiredDuringSchedulingIgnoredDuringExecutionInterface, "preferredDuringSchedulingIgnoredDuringExecution": preferredDuringSchedulingIgnoredDuringExecutionInterface, }, + // ACL (SASL/SSL) source support. Empty for plaintext daggers, which keeps + // the rendered FlinkDeployment byte-for-byte identical to before. + "acl_mounts": aclMounts, + "kubernetes_taskmanager_service_account": conf.TaskManagerServiceAccount, } return rc, nil diff --git a/modules/dagger/driver_plan.go b/modules/dagger/driver_plan.go index 11085492..60c84560 100644 --- a/modules/dagger/driver_plan.go +++ b/modules/dagger/driver_plan.go @@ -43,6 +43,12 @@ func (dd *daggerDriver) planCreate(exr module.ExpandedResource, act module.Actio return nil, err } + // resolve ACL source streams (bootstrap servers + SASL/SSL consumer config + + // podTemplate mounts). No-op for plaintext sources. + if err := applyStreamSecurity(exr, conf); err != nil { + return nil, errors.ErrInvalid.WithMsgf("failed to resolve source streams").WithCausef("%s", err.Error()) + } + //transformation #12 conf.EnvVariables[keyStreams] = string(mustMarshalJSON(conf.Source)) conf.EnvVariables[keyFlinkParallelism] = fmt.Sprint(conf.Replicas) @@ -97,6 +103,9 @@ func (dd *daggerDriver) planChange(exr module.ExpandedResource, act module.Actio } newConf.Source = mergeConsumerGroupId(curConf.Source, newConf.Source) + if err := applyStreamSecurity(exr, newConf); err != nil { + return nil, errors.ErrInvalid.WithMsgf("failed to resolve source streams").WithCausef("%s", err.Error()) + } newConf.EnvVariables[keyStreams] = string(mustMarshalJSON(newConf.Source)) newConf.EnvVariables[keyFlinkParallelism] = fmt.Sprint(newConf.Replicas) @@ -185,6 +194,9 @@ func (dd *daggerDriver) planReset(exr module.ExpandedResource, act module.Action curConf.ResetOffset = resetValue curConf.Source = dd.consumerReset(context.Background(), *curConf, resetValue) + if err := applyStreamSecurity(exr, curConf); err != nil { + return nil, errors.ErrInvalid.WithMsgf("failed to resolve source streams").WithCausef("%s", err.Error()) + } curConf.EnvVariables[keyStreams] = string(mustMarshalJSON(curConf.Source)) curConf.ChartValues = &dd.conf.ChartValues diff --git a/modules/kafka/config.go b/modules/kafka/config.go index 5d23e503..9e427e03 100644 --- a/modules/kafka/config.go +++ b/modules/kafka/config.go @@ -16,13 +16,55 @@ var ( ) type Config struct { - Entity string `json:"entity,omitempty"` - Environment string `json:"environment,omitempty"` - Landscape string `json:"landscape,omitempty"` - Organization string `json:"organization,omitempty"` - AdvertiseMode AdvertiseMode `json:"advertise_mode"` - Brokers []Broker `json:"brokers,omitempty"` - Type string `json:"type"` + Entity string `json:"entity,omitempty"` + Environment string `json:"environment,omitempty"` + Landscape string `json:"landscape,omitempty"` + Organization string `json:"organization,omitempty"` + AdvertiseMode AdvertiseMode `json:"advertise_mode"` + Brokers []Broker `json:"brokers,omitempty"` + Type string `json:"type"` + Security *SecurityProfile `json:"security,omitempty"` +} + +// SecurityProfile carries the optional SASL/SSL authentication details for a +// stream. When nil (or empty SecurityProtocol), the stream is treated as a +// plaintext stream and behaves exactly as before. Only references to secrets +// are stored here — never inline secret values. +type SecurityProfile struct { + // SecurityProtocol is the Kafka security.protocol, e.g. SASL_SSL, + // SASL_PLAINTEXT, SSL or empty/PLAINTEXT. + SecurityProtocol string `json:"security_protocol,omitempty"` + // SaslMechanism is the SASL mechanism, e.g. OAUTHBEARER, PLAIN, + // SCRAM-SHA-512. + SaslMechanism string `json:"sasl_mechanism,omitempty"` + // SSLProtocol is the ssl.protocol, e.g. SSL / TLS. + SSLProtocol string `json:"ssl_protocol,omitempty"` + // SSLTruststoreType is the truststore type, e.g. PKCS12 or JKS. + SSLTruststoreType string `json:"ssl_truststore_type,omitempty"` + // SSLCertSecret is the name of the K8s secret holding the truststore/certs. + SSLCertSecret string `json:"ssl_cert_secret,omitempty"` + // SSLTruststorePasswordDetails references the secret + key holding the + // truststore password. + SSLTruststorePasswordDetails *SecretKeyRef `json:"ssl_truststore_password_details,omitempty"` + // ACLs holds per-team credential references for PLAIN/SCRAM mechanisms, + // keyed by team/group. References only — never inline secret values. + ACLs map[string]ACLCredentialRef `json:"acls,omitempty"` +} + +// SecretKeyRef is a reference to a single key inside a K8s secret. +type SecretKeyRef struct { + SecretName string `json:"secretName,omitempty"` + Key string `json:"key,omitempty"` +} + +// ACLCredentialRef references the secret material for a PLAIN/SCRAM credential. +// It never carries inline username/password values. +type ACLCredentialRef struct { + // SecretName is the K8s secret holding the credential material. + SecretName string `json:"secretName,omitempty"` + // UsernameKey / PasswordKey are the keys inside SecretName. + UsernameKey string `json:"usernameKey,omitempty"` + PasswordKey string `json:"passwordKey,omitempty"` } type AdvertiseMode struct { diff --git a/modules/kafka/driver.go b/modules/kafka/driver.go index 20ea9d86..7d32a7ec 100644 --- a/modules/kafka/driver.go +++ b/modules/kafka/driver.go @@ -21,6 +21,10 @@ type kafkaDriver struct { type Output struct { URL string `json:"url"` + // Security exposes the stream's optional security profile so dependents + // (e.g. the dagger module) can build the SASL/SSL consumer config. Nil for + // plaintext streams. + Security *SecurityProfile `json:"security,omitempty"` } type driverConf struct { @@ -47,7 +51,8 @@ func (m *kafkaDriver) Plan(ctx context.Context, res module.ExpandedResource, res.Resource.State = resource.State{ Status: resource.StatusCompleted, Output: modules.MustJSON(Output{ - URL: mapUrl(cfg), + URL: mapUrl(cfg), + Security: cfg.Security, }), } @@ -69,7 +74,8 @@ func (m *kafkaDriver) Output(ctx context.Context, res module.ExpandedResource) ( } return modules.MustJSON(Output{ - URL: mapUrl(cfg), + URL: mapUrl(cfg), + Security: cfg.Security, }), nil } diff --git a/modules/kafka/schema/config.json b/modules/kafka/schema/config.json index 3977ae43..04b7968e 100644 --- a/modules/kafka/schema/config.json +++ b/modules/kafka/schema/config.json @@ -48,6 +48,57 @@ }, "organization": { "type": "string" + }, + "security": { + "type": "object", + "additionalProperties": true, + "properties": { + "security_protocol": { + "type": "string" + }, + "sasl_mechanism": { + "type": "string" + }, + "ssl_protocol": { + "type": "string" + }, + "ssl_truststore_type": { + "type": "string" + }, + "ssl_cert_secret": { + "type": "string" + }, + "ssl_truststore_password_details": { + "type": "object", + "additionalProperties": true, + "properties": { + "secretName": { + "type": "string" + }, + "key": { + "type": "string" + } + } + }, + "acls": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": true, + "properties": { + "secretName": { + "type": "string" + }, + "usernameKey": { + "type": "string" + }, + "passwordKey": { + "type": "string" + } + } + } + } + } } } } \ No newline at end of file From 4a23926c22482b10dba374a23160008aaa4901ae Mon Sep 17 00:00:00 2001 From: Deepansh Date: Wed, 22 Jul 2026 13:49:52 +0530 Subject: [PATCH 2/9] add: dagger security injection --- modules/dagger/kafka_security.go | 308 ++++++++++++++++++++++++++ modules/dagger/kafka_security_test.go | 166 ++++++++++++++ 2 files changed, 474 insertions(+) create mode 100644 modules/dagger/kafka_security.go create mode 100644 modules/dagger/kafka_security_test.go diff --git a/modules/dagger/kafka_security.go b/modules/dagger/kafka_security.go new file mode 100644 index 00000000..ad2cb2e9 --- /dev/null +++ b/modules/dagger/kafka_security.go @@ -0,0 +1,308 @@ +package dagger + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + + "github.com/goto/entropy/core/module" + kafkamod "github.com/goto/entropy/modules/kafka" +) + +// SASL/SSL consumer config keys injected into +// SOURCE_KAFKA_CONSUMER_ADDITIONAL_CONFIGURATIONS. +const ( + keyConsumerSecurityProtocol = "SOURCE_KAFKA_CONSUMER_CONFIG_SECURITY_PROTOCOL" + keyConsumerSaslMechanism = "SOURCE_KAFKA_CONSUMER_CONFIG_SASL_MECHANISM" + keyConsumerSaslJaasConfig = "SOURCE_KAFKA_CONSUMER_CONFIG_SASL_JAAS_CONFIG" + keyConsumerSaslLoginCallbackHandler = "SOURCE_KAFKA_CONSUMER_CONFIG_SASL_LOGIN_CALLBACK_HANDLER_CLASS" + keyConsumerSSLProtocol = "SOURCE_KAFKA_CONSUMER_CONFIG_SSL_PROTOCOL" + keyConsumerSSLTruststoreType = "SOURCE_KAFKA_CONSUMER_CONFIG_SSL_TRUSTSTORE_TYPE" + keyConsumerSSLTruststoreLocation = "SOURCE_KAFKA_CONSUMER_CONFIG_SSL_TRUSTSTORE_LOCATION" + keyConsumerSSLTruststorePassword = "SOURCE_KAFKA_CONSUMER_CONFIG_SSL_TRUSTSTORE_PASSWORD" + keyConsumerSSLTruststorePasswordDetails = "SOURCE_KAFKA_CONSUMER_CONFIG_SSL_TRUSTSTORE_PASSWORD_DETAILS" + keyConsumerSSLCertSecret = "SOURCE_KAFKA_CONSUMER_CONFIG_SSL_CERT_SECRET" + keyConsumerConfigProviders = "SOURCE_KAFKA_CONSUMER_CONFIG_CONFIG_PROVIDERS" + keyConsumerConfigProvidersLiteralClass = "SOURCE_KAFKA_CONSUMER_CONFIG_CONFIG_PROVIDERS_LITERALFILE_CLASS" +) + +// GTF Kafka security constants (mirrors odin app/dagger/constants.js). +const ( + oauthSaslLoginCallbackHandlerClass = "io.gtflabs.kafka.security.oauthbearer.kubernetes.PodLoginCallbackHandler" + oauthConsumerSaslJaasConfig = "org.apache.kafka.common.security.oauthbearer.OAuthBearerLoginModule required;" + scramLoginModule = "org.apache.kafka.common.security.scram.ScramLoginModule" + plainLoginModule = "org.apache.kafka.common.security.plain.PlainLoginModule" + literalFileConfigProviderClass = "com.gtf.dagger.kafka.configproviders.LiteralFileConfigProvider" + literalFileConfigProviderName = "literalfile" +) + +const ( + securityProtocolSASLSSL = "SASL_SSL" + securityProtocolSASLPlaintext = "SASL_PLAINTEXT" + securityProtocolSSL = "SSL" + saslMechanismOauthbearer = "OAUTHBEARER" + saslMechanismPlain = "PLAIN" + saslMechanismScram = "SCRAM-SHA-512" + truststoreTypePKCS12 = "PKCS12" +) + +// mount path templates, kept consistent between the injected consumer config +// (R3) and the podTemplate mounts (R4). +const ( + kafkaTokenVolumeName = "kafka-token" + kafkaTokenMountPath = "/var/run/secrets/kafka/serviceaccount" + certsMountPathFmt = "/var/secrets/%s/certs" + passwordsMountPathFmt = "/var/secrets/%s/passwords" + credentialsMountPathFmt = "/var/secrets/%s/credentials" +) + +var invalidVolumeNameChars = regexp.MustCompile(`[^a-zA-Z0-9]+`) + +func isOauthbearerStream(sp *kafkamod.SecurityProfile) bool { + return sp != nil && + sp.SecurityProtocol == securityProtocolSASLSSL && + sp.SaslMechanism == saslMechanismOauthbearer && + sp.SSLCertSecret != "" && + sp.SSLTruststorePasswordDetails != nil +} + +func isPlainOrScramStream(sp *kafkamod.SecurityProfile) bool { + if sp == nil { + return false + } + protoOK := sp.SecurityProtocol == securityProtocolSASLPlaintext || sp.SecurityProtocol == securityProtocolSASLSSL + mechOK := sp.SaslMechanism == saslMechanismPlain || sp.SaslMechanism == saslMechanismScram + return protoOK && mechOK +} + +func isTLSStream(sp *kafkamod.SecurityProfile) bool { + return sp != nil && sp.SecurityProtocol == securityProtocolSSL +} + +// hasSecurityProfile reports whether the profile requires any SASL/SSL wiring. +func hasSecurityProfile(sp *kafkamod.SecurityProfile) bool { + return sp != nil && sp.SecurityProtocol != "" && !strings.EqualFold(sp.SecurityProtocol, "PLAINTEXT") +} + +func truststoreExtension(truststoreType string) string { + if strings.EqualFold(truststoreType, truststoreTypePKCS12) { + return "p12" + } + return "jks" +} + +// buildAdditionalConfigurations builds the SOURCE_KAFKA_CONSUMER_ADDITIONAL_CONFIGURATIONS +// map for a source stream, branching on its security profile. streamName is the +// stable per-stream directory name used both here and in the podTemplate mounts. +// Returns nil for plaintext streams so STREAMS stays unchanged. +func buildAdditionalConfigurations(streamName string, sp *kafkamod.SecurityProfile, team string) map[string]interface{} { + if !hasSecurityProfile(sp) { + return nil + } + + cfg := map[string]interface{}{} + cfg[keyConsumerSecurityProtocol] = sp.SecurityProtocol + if sp.SaslMechanism != "" { + cfg[keyConsumerSaslMechanism] = sp.SaslMechanism + } + + // SSL material is shared by the TLS and OAUTHBEARER paths. + if isTLSStream(sp) || isOauthbearerStream(sp) { + if sp.SSLProtocol != "" { + cfg[keyConsumerSSLProtocol] = sp.SSLProtocol + } + if sp.SSLTruststoreType != "" { + cfg[keyConsumerSSLTruststoreType] = sp.SSLTruststoreType + } + if sp.SSLCertSecret != "" { + cfg[keyConsumerSSLCertSecret] = sp.SSLCertSecret + } + if sp.SSLTruststorePasswordDetails != nil { + // map (not struct) so JSON key order is deterministic and matches + // the reference fixture ({key, secretName}). + cfg[keyConsumerSSLTruststorePasswordDetails] = map[string]string{ + "key": sp.SSLTruststorePasswordDetails.Key, + "secretName": sp.SSLTruststorePasswordDetails.SecretName, + } + } + } + + if isOauthbearerStream(sp) { + cfg[keyConsumerSaslLoginCallbackHandler] = oauthSaslLoginCallbackHandlerClass + cfg[keyConsumerSaslJaasConfig] = oauthConsumerSaslJaasConfig + cfg[keyConsumerSSLTruststoreLocation] = fmt.Sprintf( + "/var/secrets/%s/certs/truststore.%s", streamName, truststoreExtension(sp.SSLTruststoreType)) + if sp.SSLTruststorePasswordDetails != nil { + cfg[keyConsumerSSLTruststorePassword] = fmt.Sprintf( + "${literalfile:/var/secrets/%s/passwords/%s:literal-value}", + streamName, sp.SSLTruststorePasswordDetails.Key) + } + cfg[keyConsumerConfigProviders] = literalFileConfigProviderName + cfg[keyConsumerConfigProvidersLiteralClass] = literalFileConfigProviderClass + } + + if isPlainOrScramStream(sp) { + cfg[keyConsumerSaslJaasConfig] = buildSASLJaasConfig(streamName, sp, team) + // credentials are referenced via the literalfile provider (never inlined). + if _, ok := sp.ACLs[team]; ok { + cfg[keyConsumerConfigProviders] = literalFileConfigProviderName + cfg[keyConsumerConfigProvidersLiteralClass] = literalFileConfigProviderClass + } + } + + return cfg +} + +// buildSASLJaasConfig builds the JAAS config string for PLAIN/SCRAM mechanisms. +// Credentials are referenced through the literalfile config provider pointing +func buildSASLJaasConfig(streamName string, sp *kafkamod.SecurityProfile, team string) string { + loginModule := scramLoginModule + if sp.SaslMechanism == saslMechanismPlain { + loginModule = plainLoginModule + } + + cred, ok := sp.ACLs[team] + if !ok || cred.SecretName == "" { + return fmt.Sprintf("%s required;", loginModule) + } + + userRef := fmt.Sprintf("${literalfile:/var/secrets/%s/credentials/%s:literal-value}", streamName, cred.UsernameKey) + passRef := fmt.Sprintf("${literalfile:/var/secrets/%s/credentials/%s:literal-value}", streamName, cred.PasswordKey) + return fmt.Sprintf("%s required username=%q password=%q;", loginModule, userRef, passRef) +} + +// sanitizeVolumeName renders a k8s-safe (<=63 char, lowercase alnum/dash) volume name. +func sanitizeVolumeName(name string) string { + sanitized := invalidVolumeNameChars.ReplaceAllString(name, "-") + sanitized = strings.ToLower(strings.Trim(sanitized, "-")) + if len(sanitized) > 63 { + sanitized = strings.Trim(sanitized[:63], "-") + } + return sanitized +} + +// buildACLMounts derives the podTemplate volume mounts for the given sources' +// security profiles. Returns nil when no source needs ACL mounts so the +// podTemplate is unchanged for plaintext daggers. +func buildACLMounts(sources []Source, profiles map[string]*kafkamod.SecurityProfile, team string) []ACLMount { + var mounts []ACLMount + + for _, src := range sources { + streamName := src.SourceKafkaName + sp := profiles[streamName] + if !hasSecurityProfile(sp) { + continue + } + + if isOauthbearerStream(sp) { + mounts = append(mounts, ACLMount{ + Name: sanitizeVolumeName(streamName + "-" + sp.SSLCertSecret), + MountPath: fmt.Sprintf(certsMountPathFmt, streamName), + SecretName: sp.SSLCertSecret, + Type: "secret", + }) + mounts = append(mounts, ACLMount{ + Name: sanitizeVolumeName(streamName + "-" + sp.SSLTruststorePasswordDetails.SecretName), + MountPath: fmt.Sprintf(passwordsMountPathFmt, streamName), + SecretName: sp.SSLTruststorePasswordDetails.SecretName, + Type: "secret", + }) + } else if isTLSStream(sp) && sp.SSLCertSecret != "" { + mounts = append(mounts, ACLMount{ + Name: sanitizeVolumeName(streamName + "-" + sp.SSLCertSecret), + MountPath: fmt.Sprintf(certsMountPathFmt, streamName), + SecretName: sp.SSLCertSecret, + Type: "secret", + }) + if sp.SSLTruststorePasswordDetails != nil && sp.SSLTruststorePasswordDetails.SecretName != "" { + mounts = append(mounts, ACLMount{ + Name: sanitizeVolumeName(streamName + "-" + sp.SSLTruststorePasswordDetails.SecretName), + MountPath: fmt.Sprintf(passwordsMountPathFmt, streamName), + SecretName: sp.SSLTruststorePasswordDetails.SecretName, + Type: "secret", + }) + } + } + + // PLAIN/SCRAM: mount the team's referenced credential secret so the + // literalfile provider can read username/password without inlining them. + if isPlainOrScramStream(sp) { + if cred, ok := sp.ACLs[team]; ok && cred.SecretName != "" { + mounts = append(mounts, ACLMount{ + Name: sanitizeVolumeName(streamName + "-" + cred.SecretName), + MountPath: fmt.Sprintf(credentialsMountPathFmt, streamName), + SecretName: cred.SecretName, + Type: "secret", + }) + } + } + } + + if len(mounts) == 0 { + return nil + } + + // single shared projected kafka service-account token. + mounts = append(mounts, ACLMount{ + Name: kafkaTokenVolumeName, + MountPath: kafkaTokenMountPath, + Type: "projected", + }) + + return mounts +} + +// applyStreamSecurity resolves each source's kafka dependency, injects the +// SASL/SSL consumer config into STREAMS, and records the podTemplate ACL mounts +// on conf. It is a no-op (leaves conf untouched) for daggers whose sources have +// no kafka dependency or no security profile. +func applyStreamSecurity(exr module.ExpandedResource, conf *Config) error { + profiles, err := resolveSourceStreams(exr, conf) + if err != nil { + return err + } + conf.ACLMounts = buildACLMounts(conf.Source, profiles, conf.Team) + return nil +} + +// resolveSourceStreams looks up each source's kind=kafka dependency (keyed by +// SOURCE_KAFKA_NAME), populates bootstrap servers and the SASL/SSL consumer +// config, and returns the resolved security profiles keyed by stream name. +// Sources without a matching kafka dependency (or with a plaintext profile) are +// left untouched — preserving byte-for-byte identical STREAMS for ODS daggers. +func resolveSourceStreams(exr module.ExpandedResource, conf *Config) (map[string]*kafkamod.SecurityProfile, error) { + profiles := map[string]*kafkamod.SecurityProfile{} + + for i := range conf.Source { + streamName := conf.Source[i].SourceKafkaName + if streamName == "" { + continue + } + + dep, ok := exr.Dependencies[streamName] + if !ok || dep.Kind != kafkamod.Module.Kind { + continue + } + + var out kafkamod.Output + if err := json.Unmarshal(dep.Output, &out); err != nil { + return nil, fmt.Errorf("invalid kafka dependency output for stream %q: %w", streamName, err) + } + + // bootstrap servers: explicit source value wins, else resolved URL. + if conf.Source[i].SourceKafkaConsumerConfigBootstrapServers == "" && out.URL != "" { + conf.Source[i].SourceKafkaConsumerConfigBootstrapServers = out.URL + } + + if !hasSecurityProfile(out.Security) { + continue + } + + profiles[streamName] = out.Security + conf.Source[i].SourceKafkaConsumerAdditionalConfigurations = + buildAdditionalConfigurations(streamName, out.Security, conf.Team) + } + + return profiles, nil +} diff --git a/modules/dagger/kafka_security_test.go b/modules/dagger/kafka_security_test.go new file mode 100644 index 00000000..8e552aac --- /dev/null +++ b/modules/dagger/kafka_security_test.go @@ -0,0 +1,166 @@ +package dagger + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/goto/entropy/core/module" + kafkamod "github.com/goto/entropy/modules/kafka" +) + +const pocStream = "al-gp-id-s-central-kf" + +func oauthbearerProfile() *kafkamod.SecurityProfile { + return &kafkamod.SecurityProfile{ + SecurityProtocol: "SASL_SSL", + SaslMechanism: "OAUTHBEARER", + SSLProtocol: "SSL", + SSLTruststoreType: "PKCS12", + SSLCertSecret: "kafka-central-cert", + SSLTruststorePasswordDetails: &kafkamod.SecretKeyRef{ + SecretName: "scp-kafka-ssl-secrets", + Key: "truststore_password", + }, + } +} + +// Acceptance criterion #1: the injected additional-configurations block matches +// the reference fixture (compared semantically, order-independent). +func TestBuildAdditionalConfigurations_OAUTHBEARER_MatchesFixture(t *testing.T) { + got := buildAdditionalConfigurations(pocStream, oauthbearerProfile(), "team-x") + + fixture := `{ + "SOURCE_KAFKA_CONSUMER_CONFIG_CONFIG_PROVIDERS": "literalfile", + "SOURCE_KAFKA_CONSUMER_CONFIG_CONFIG_PROVIDERS_LITERALFILE_CLASS": "com.gtf.dagger.kafka.configproviders.LiteralFileConfigProvider", + "SOURCE_KAFKA_CONSUMER_CONFIG_SASL_JAAS_CONFIG": "org.apache.kafka.common.security.oauthbearer.OAuthBearerLoginModule required;", + "SOURCE_KAFKA_CONSUMER_CONFIG_SASL_LOGIN_CALLBACK_HANDLER_CLASS": "io.gtflabs.kafka.security.oauthbearer.kubernetes.PodLoginCallbackHandler", + "SOURCE_KAFKA_CONSUMER_CONFIG_SASL_MECHANISM": "OAUTHBEARER", + "SOURCE_KAFKA_CONSUMER_CONFIG_SECURITY_PROTOCOL": "SASL_SSL", + "SOURCE_KAFKA_CONSUMER_CONFIG_SSL_CERT_SECRET": "kafka-central-cert", + "SOURCE_KAFKA_CONSUMER_CONFIG_SSL_PROTOCOL": "SSL", + "SOURCE_KAFKA_CONSUMER_CONFIG_SSL_TRUSTSTORE_LOCATION": "/var/secrets/al-gp-id-s-central-kf/certs/truststore.p12", + "SOURCE_KAFKA_CONSUMER_CONFIG_SSL_TRUSTSTORE_PASSWORD": "${literalfile:/var/secrets/al-gp-id-s-central-kf/passwords/truststore_password:literal-value}", + "SOURCE_KAFKA_CONSUMER_CONFIG_SSL_TRUSTSTORE_PASSWORD_DETAILS": { "key": "truststore_password", "secretName": "scp-kafka-ssl-secrets" }, + "SOURCE_KAFKA_CONSUMER_CONFIG_SSL_TRUSTSTORE_TYPE": "PKCS12" + }` + + var want map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(fixture), &want)) + + // round-trip got through JSON so nested types compare equal to the fixture. + gotJSON, err := json.Marshal(got) + require.NoError(t, err) + var gotNormalized map[string]interface{} + require.NoError(t, json.Unmarshal(gotJSON, &gotNormalized)) + + assert.Equal(t, want, gotNormalized) +} + +// Acceptance criterion #2: the podTemplate mounts match the reference fixture. +func TestBuildACLMounts_OAUTHBEARER_MatchesFixture(t *testing.T) { + sources := []Source{{SourceKafka: SourceKafka{SourceKafkaName: pocStream}}} + profiles := map[string]*kafkamod.SecurityProfile{pocStream: oauthbearerProfile()} + + mounts := buildACLMounts(sources, profiles, "team-x") + + require.Len(t, mounts, 3) + assert.Equal(t, ACLMount{ + Name: "al-gp-id-s-central-kf-kafka-central-cert", + MountPath: "/var/secrets/al-gp-id-s-central-kf/certs", + SecretName: "kafka-central-cert", + Type: "secret", + }, mounts[0]) + assert.Equal(t, ACLMount{ + Name: "al-gp-id-s-central-kf-scp-kafka-ssl-secrets", + MountPath: "/var/secrets/al-gp-id-s-central-kf/passwords", + SecretName: "scp-kafka-ssl-secrets", + Type: "secret", + }, mounts[1]) + assert.Equal(t, ACLMount{ + Name: "kafka-token", + MountPath: "/var/run/secrets/kafka/serviceaccount", + Type: "projected", + }, mounts[2]) +} + +// Acceptance criterion #5 / R6: a plaintext source produces no additional config +// and no mounts (byte-for-byte unchanged STREAMS/podTemplate). +func TestPlaintextSource_NoSecurityWiring(t *testing.T) { + assert.Nil(t, buildAdditionalConfigurations(pocStream, nil, "team-x")) + assert.Nil(t, buildAdditionalConfigurations(pocStream, &kafkamod.SecurityProfile{}, "team-x")) + assert.Nil(t, buildAdditionalConfigurations(pocStream, &kafkamod.SecurityProfile{SecurityProtocol: "PLAINTEXT"}, "team-x")) + + sources := []Source{{SourceKafka: SourceKafka{SourceKafkaName: pocStream}}} + assert.Nil(t, buildACLMounts(sources, map[string]*kafkamod.SecurityProfile{}, "team-x")) +} + +// R2: bootstrap servers are populated from the resolved stream URL when not set, +// and the additional configuration is attached to the source. +func TestResolveSourceStreams_PopulatesBootstrapAndConfig(t *testing.T) { + out := kafkamod.Output{URL: "broker-1:9098,broker-2:9098", Security: oauthbearerProfile()} + outJSON, err := json.Marshal(out) + require.NoError(t, err) + + exr := module.ExpandedResource{ + Dependencies: map[string]module.ResolvedDependency{ + pocStream: {Kind: kafkamod.Module.Kind, Output: outJSON}, + }, + } + conf := &Config{ + Team: "team-x", + Source: []Source{{SourceKafka: SourceKafka{SourceKafkaName: pocStream}}}, + } + + profiles, err := resolveSourceStreams(exr, conf) + require.NoError(t, err) + + assert.Equal(t, "broker-1:9098,broker-2:9098", conf.Source[0].SourceKafkaConsumerConfigBootstrapServers) + assert.NotNil(t, conf.Source[0].SourceKafkaConsumerAdditionalConfigurations) + assert.Contains(t, profiles, pocStream) +} + +// R2: an explicit bootstrap servers value on the source is not overwritten. +func TestResolveSourceStreams_KeepsExplicitBootstrap(t *testing.T) { + out := kafkamod.Output{URL: "resolved:9098"} + outJSON, err := json.Marshal(out) + require.NoError(t, err) + + exr := module.ExpandedResource{ + Dependencies: map[string]module.ResolvedDependency{ + pocStream: {Kind: kafkamod.Module.Kind, Output: outJSON}, + }, + } + conf := &Config{ + Source: []Source{{SourceKafka: SourceKafka{ + SourceKafkaName: pocStream, + SourceKafkaConsumerConfigBootstrapServers: "explicit:9092", + }}}, + } + + _, err = resolveSourceStreams(exr, conf) + require.NoError(t, err) + assert.Equal(t, "explicit:9092", conf.Source[0].SourceKafkaConsumerConfigBootstrapServers) +} + +// PLAIN/SCRAM: JAAS config references credentials through the literalfile +// provider — never inlining the secret values. +func TestBuildAdditionalConfigurations_PlainScram_NoInlinedSecrets(t *testing.T) { + sp := &kafkamod.SecurityProfile{ + SecurityProtocol: "SASL_SSL", + SaslMechanism: "SCRAM-SHA-512", + ACLs: map[string]kafkamod.ACLCredentialRef{ + "team-x": {SecretName: "team-x-creds", UsernameKey: "username", PasswordKey: "password"}, + }, + } + + got := buildAdditionalConfigurations(pocStream, sp, "team-x") + jaas, _ := got[keyConsumerSaslJaasConfig].(string) + + assert.Contains(t, jaas, "ScramLoginModule") + assert.Contains(t, jaas, "${literalfile:/var/secrets/al-gp-id-s-central-kf/credentials/username:literal-value}") + assert.Contains(t, jaas, "${literalfile:/var/secrets/al-gp-id-s-central-kf/credentials/password:literal-value}") + assert.Equal(t, "literalfile", got[keyConsumerConfigProviders]) +} From 5445fd1ece7415885509163fa715d83943d28235 Mon Sep 17 00:00:00 2001 From: Deepansh Date: Fri, 24 Jul 2026 16:01:11 +0530 Subject: [PATCH 3/9] add option to prefetch the security profile from dex --- modules/dagger/config.go | 9 +++-- modules/dagger/kafka_security.go | 50 ++++++++++++++++----------- modules/dagger/kafka_security_test.go | 42 +++++++++++++++++++++- modules/dagger/schema/config.json | 37 ++++++++++++++++++++ 4 files changed, 115 insertions(+), 23 deletions(-) diff --git a/modules/dagger/config.go b/modules/dagger/config.go index 57fe8038..342df4ff 100644 --- a/modules/dagger/config.go +++ b/modules/dagger/config.go @@ -12,6 +12,7 @@ import ( "github.com/goto/entropy/core/module" "github.com/goto/entropy/modules" "github.com/goto/entropy/modules/flink" + kafkamod "github.com/goto/entropy/modules/kafka" "github.com/goto/entropy/pkg/errors" "github.com/goto/entropy/pkg/validator" ) @@ -141,9 +142,13 @@ type Config struct { TaskManagerServiceAccount string `json:"taskmanager_service_account,omitempty"` // ACLMounts is the set of secret / projected-token volume mounts required by - // ACL (SASL_SSL/OAUTHBEARER, SSL, PLAIN/SCRAM) source streams. It is - + // ACL (SASL_SSL/OAUTHBEARER, SSL, PLAIN/SCRAM) source streams. It is computed + // by applyStreamSecurity from the resolved stream security profiles. ACLMounts []ACLMount `json:"acl_mounts,omitempty"` + + // StreamSecurity holds the kafka security profile fetched by Dex, keyed by + // SOURCE_KAFKA_NAME. References only — never inline secret values. + StreamSecurity map[string]*kafkamod.SecurityProfile `json:"stream_security,omitempty"` } // ACLMount describes a single podTemplate volume+mount for an ACL stream. diff --git a/modules/dagger/kafka_security.go b/modules/dagger/kafka_security.go index ad2cb2e9..33612540 100644 --- a/modules/dagger/kafka_security.go +++ b/modules/dagger/kafka_security.go @@ -253,24 +253,28 @@ func buildACLMounts(sources []Source, profiles map[string]*kafkamod.SecurityProf return mounts } -// applyStreamSecurity resolves each source's kafka dependency, injects the +// applyStreamSecurity resolves each source's kafka security profile, injects the // SASL/SSL consumer config into STREAMS, and records the podTemplate ACL mounts // on conf. It is a no-op (leaves conf untouched) for daggers whose sources have -// no kafka dependency or no security profile. +// no security profile. func applyStreamSecurity(exr module.ExpandedResource, conf *Config) error { profiles, err := resolveSourceStreams(exr, conf) - if err != nil { + if err != nil { return err } conf.ACLMounts = buildACLMounts(conf.Source, profiles, conf.Team) return nil } -// resolveSourceStreams looks up each source's kind=kafka dependency (keyed by +// resolveSourceStreams resolves each source's kafka security profile (keyed by // SOURCE_KAFKA_NAME), populates bootstrap servers and the SASL/SSL consumer // config, and returns the resolved security profiles keyed by stream name. -// Sources without a matching kafka dependency (or with a plaintext profile) are -// left untouched — preserving byte-for-byte identical STREAMS for ODS daggers. +// +// The profile is resolved inline-first: conf.StreamSecurity (populated by Dex on +// the product path) takes precedence; otherwise it falls back to the kafka +// dependency Output (raw-Entropy path). Sources with neither a profile nor a +// plaintext profile are left untouched — preserving byte-for-byte identical +// STREAMS for ODS daggers. func resolveSourceStreams(exr module.ExpandedResource, conf *Config) (map[string]*kafkamod.SecurityProfile, error) { profiles := map[string]*kafkamod.SecurityProfile{} @@ -280,28 +284,34 @@ func resolveSourceStreams(exr module.ExpandedResource, conf *Config) (map[string continue } - dep, ok := exr.Dependencies[streamName] - if !ok || dep.Kind != kafkamod.Module.Kind { - continue - } + // inline profile (Dex product path) wins over the kafka dependency. + security := conf.StreamSecurity[streamName] - var out kafkamod.Output - if err := json.Unmarshal(dep.Output, &out); err != nil { - return nil, fmt.Errorf("invalid kafka dependency output for stream %q: %w", streamName, err) - } + // fall back to the kafka dependency (raw-Entropy path): it also carries + // the resolved broker URL for bootstrap servers. + if security == nil { + if dep, ok := exr.Dependencies[streamName]; ok && dep.Kind == kafkamod.Module.Kind { + var out kafkamod.Output + if err := json.Unmarshal(dep.Output, &out); err != nil { + return nil, fmt.Errorf("invalid kafka dependency output for stream %q: %w", streamName, err) + } - // bootstrap servers: explicit source value wins, else resolved URL. - if conf.Source[i].SourceKafkaConsumerConfigBootstrapServers == "" && out.URL != "" { - conf.Source[i].SourceKafkaConsumerConfigBootstrapServers = out.URL + // bootstrap servers: explicit source value wins, else resolved URL. + if conf.Source[i].SourceKafkaConsumerConfigBootstrapServers == "" && out.URL != "" { + conf.Source[i].SourceKafkaConsumerConfigBootstrapServers = out.URL + } + + security = out.Security + } } - if !hasSecurityProfile(out.Security) { + if !hasSecurityProfile(security) { continue } - profiles[streamName] = out.Security + profiles[streamName] = security conf.Source[i].SourceKafkaConsumerAdditionalConfigurations = - buildAdditionalConfigurations(streamName, out.Security, conf.Team) + buildAdditionalConfigurations(streamName, security, conf.Team) } return profiles, nil diff --git a/modules/dagger/kafka_security_test.go b/modules/dagger/kafka_security_test.go index 8e552aac..bfb4b278 100644 --- a/modules/dagger/kafka_security_test.go +++ b/modules/dagger/kafka_security_test.go @@ -122,7 +122,47 @@ func TestResolveSourceStreams_PopulatesBootstrapAndConfig(t *testing.T) { assert.Contains(t, profiles, pocStream) } -// R2: an explicit bootstrap servers value on the source is not overwritten. +// Product (Dex) path: the security profile is inlined on conf.StreamSecurity +// with NO kafka dependency present, and the ACL wiring still fires. +func TestResolveSourceStreams_InlineProfile_NoDependency(t *testing.T) { + conf := &Config{ + Team: "team-x", + Source: []Source{{SourceKafka: SourceKafka{SourceKafkaName: pocStream}}}, + StreamSecurity: map[string]*kafkamod.SecurityProfile{ + pocStream: oauthbearerProfile(), + }, + } + + profiles, err := resolveSourceStreams(module.ExpandedResource{}, conf) + require.NoError(t, err) + + require.Contains(t, profiles, pocStream) + assert.NotNil(t, conf.Source[0].SourceKafkaConsumerAdditionalConfigurations) + assert.Equal(t, + "SASL_SSL", + conf.Source[0].SourceKafkaConsumerAdditionalConfigurations[keyConsumerSecurityProtocol]) + + mounts := buildACLMounts(conf.Source, profiles, conf.Team) + require.Len(t, mounts, 3) +} + +// a full plaintext dagger (no stream_security, no kafka dependency) produces +// no additional configs, no mounts, and no taskmanager SA — STREAMS/podTemplate +// unchanged. +func TestApplyStreamSecurity_PlaintextDagger_NoWiring(t *testing.T) { + conf := &Config{ + Team: "team-x", + Source: []Source{{SourceKafka: SourceKafka{SourceKafkaName: pocStream}}}, + } + + require.NoError(t, applyStreamSecurity(module.ExpandedResource{}, conf)) + + assert.Nil(t, conf.Source[0].SourceKafkaConsumerAdditionalConfigurations) + assert.Nil(t, conf.ACLMounts) + assert.Empty(t, conf.TaskManagerServiceAccount) +} + +// an explicit bootstrap servers value on the source is not overwritten. func TestResolveSourceStreams_KeepsExplicitBootstrap(t *testing.T) { out := kafkamod.Output{URL: "resolved:9098"} outJSON, err := json.Marshal(out) diff --git a/modules/dagger/schema/config.json b/modules/dagger/schema/config.json index 3effabd0..e12b35b7 100644 --- a/modules/dagger/schema/config.json +++ b/modules/dagger/schema/config.json @@ -19,6 +19,43 @@ "deployment_id": { "type": "string" }, + "taskmanager_service_account": { + "type": "string" + }, + "stream_security": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": true, + "properties": { + "security_protocol": { "type": "string" }, + "sasl_mechanism": { "type": "string" }, + "ssl_protocol": { "type": "string" }, + "ssl_truststore_type": { "type": "string" }, + "ssl_cert_secret": { "type": "string" }, + "ssl_truststore_password_details": { + "type": "object", + "additionalProperties": true, + "properties": { + "secretName": { "type": "string" }, + "key": { "type": "string" } + } + }, + "acls": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": true, + "properties": { + "secretName": { "type": "string" }, + "usernameKey": { "type": "string" }, + "passwordKey": { "type": "string" } + } + } + } + } + } + }, "sink_type": { "type": "string", "enum": [ From 9c348c2622fb65d0f43657825b43dbd05bf94579 Mon Sep 17 00:00:00 2001 From: Deepansh Date: Thu, 30 Jul 2026 23:12:21 +0530 Subject: [PATCH 4/9] fix: dex to not resolve kafka stream configurations --- cli/serve.go | 8 +- cli/worker.go | 2 +- modules/dagger/config.go | 1 + modules/dagger/driver.go | 3 + modules/dagger/driver_plan.go | 30 ++--- modules/dagger/kafka_security.go | 82 +++++++++--- modules/dagger/kafka_security_test.go | 44 ++++++- modules/dagger/module.go | 176 +++++++++++++------------- modules/kubernetes/module.go | 3 +- 9 files changed, 219 insertions(+), 130 deletions(-) diff --git a/cli/serve.go b/cli/serve.go index 6f3d0420..ccd4a4b4 100644 --- a/cli/serve.go +++ b/cli/serve.go @@ -70,7 +70,7 @@ func StartServer(ctx context.Context, cfg Config, migrate, spawnWorker bool) err } store := setupStorage(cfg.PGConnStr, cfg.Syncer, cfg.Service) - moduleService := module.NewService(setupRegistry(), store) + moduleService := module.NewService(setupRegistry(store), store) resourceService := core.New(store, moduleService, time.Now, cfg.Syncer.SyncBackoffInterval, cfg.Syncer.MaxRetries, cfg.Telemetry.ServiceName) if migrate { @@ -95,14 +95,16 @@ func StartServer(ctx context.Context, cfg Config, migrate, spawnWorker bool) err ) } -func setupRegistry() module.Registry { +func setupRegistry(store *postgres.Store) module.Registry { supported := []module.Descriptor{ kubernetes.Module, firehose.Module, job.Module, kafka.Module, flink.Module, - dagger.Module, + // dagger resolves ACL kafka streams by fetching their resource internally + // (no dependency), so it needs a resource getter backed by the store. + dagger.Module(store.GetByURN), } registry := &modules.Registry{} diff --git a/cli/worker.go b/cli/worker.go index eca9fae2..5b652e98 100644 --- a/cli/worker.go +++ b/cli/worker.go @@ -59,7 +59,7 @@ func cmdWorker() *cobra.Command { func StartWorkers(ctx context.Context, cfg Config) error { store := setupStorage(cfg.PGConnStr, cfg.Syncer, cfg.Service) - moduleService := module.NewService(setupRegistry(), store) + moduleService := module.NewService(setupRegistry(store), store) resourceService := core.New(store, moduleService, time.Now, cfg.Syncer.SyncBackoffInterval, cfg.Syncer.MaxRetries, cfg.Telemetry.ServiceName) eg := &errgroup.Group{} diff --git a/modules/dagger/config.go b/modules/dagger/config.go index 342df4ff..063af565 100644 --- a/modules/dagger/config.go +++ b/modules/dagger/config.go @@ -179,6 +179,7 @@ type SourceKafka struct { SourceKafkaName string `json:"SOURCE_KAFKA_NAME"` SourceKafkaConsumerConfigGroupID string `json:"SOURCE_KAFKA_CONSUMER_CONFIG_GROUP_ID"` SourceKafkaConsumerConfigBootstrapServers string `json:"SOURCE_KAFKA_CONSUMER_CONFIG_BOOTSTRAP_SERVERS"` + SourceKafkaSecurityEnabled bool `json:"SOURCE_KAFKA_SECURITY_ENABLED,omitempty"` // SourceKafkaConsumerAdditionalConfigurations carries the SASL/SSL consumer // config for ACL streams. It is nil for plaintext streams so that STREAMS // stays byte-for-byte identical to the pre-ACL behaviour. diff --git a/modules/dagger/driver.go b/modules/dagger/driver.go index 8942570a..ba9a0ed3 100644 --- a/modules/dagger/driver.go +++ b/modules/dagger/driver.go @@ -69,8 +69,11 @@ type daggerDriver struct { kubeGetCRD kubeGetCRDFn consumerReset consumerResetFn kubeProxyService kubeProxyServiceFn + getResource ResourceGetter } +type ResourceGetter func(ctx context.Context, urn string) (*resource.Resource, error) + type ( kubeDeployFn func(ctx context.Context, isCreate bool, conf kube.Config, hc helm.ReleaseConfig) error kubeGetPodFn func(ctx context.Context, conf kube.Config, ns string, labels map[string]string) ([]kube.Pod, error) diff --git a/modules/dagger/driver_plan.go b/modules/dagger/driver_plan.go index 60c84560..d5208709 100644 --- a/modules/dagger/driver_plan.go +++ b/modules/dagger/driver_plan.go @@ -24,33 +24,33 @@ const ( KeySchemaRegistryStencilURLs = "SCHEMA_REGISTRY_STENCIL_URLS" ) -func (dd *daggerDriver) Plan(_ context.Context, exr module.ExpandedResource, act module.ActionRequest) (*resource.Resource, error) { +func (dd *daggerDriver) Plan(ctx context.Context, exr module.ExpandedResource, act module.ActionRequest) (*resource.Resource, error) { switch act.Name { case module.CreateAction: - return dd.planCreate(exr, act) + return dd.planCreate(ctx, exr, act) case ResetAction: - return dd.planReset(exr, act) + return dd.planReset(ctx, exr, act) default: - return dd.planChange(exr, act) + return dd.planChange(ctx, exr, act) } } -func (dd *daggerDriver) planCreate(exr module.ExpandedResource, act module.ActionRequest) (*resource.Resource, error) { +func (dd *daggerDriver) planCreate(ctx context.Context, exr module.ExpandedResource, act module.ActionRequest) (*resource.Resource, error) { conf, err := readConfig(exr, act.Params, dd.conf) if err != nil { return nil, err } - // resolve ACL source streams (bootstrap servers + SASL/SSL consumer config + - // podTemplate mounts). No-op for plaintext sources. - if err := applyStreamSecurity(exr, conf); err != nil { + // resolve ACL source streams (SASL/SSL consumer config + podTemplate mounts). + // No-op for plaintext sources. + if err := dd.applyStreamSecurity(ctx, exr, conf); err != nil { return nil, errors.ErrInvalid.WithMsgf("failed to resolve source streams").WithCausef("%s", err.Error()) } //transformation #12 - conf.EnvVariables[keyStreams] = string(mustMarshalJSON(conf.Source)) + conf.EnvVariables[keyStreams] = streamsJSON(conf.Source) conf.EnvVariables[keyFlinkParallelism] = fmt.Sprint(conf.Replicas) chartVals := mergeChartValues(&dd.conf.ChartValues, conf.ChartValues) @@ -89,7 +89,7 @@ func (dd *daggerDriver) planCreate(exr module.ExpandedResource, act module.Actio return &exr.Resource, nil } -func (dd *daggerDriver) planChange(exr module.ExpandedResource, act module.ActionRequest) (*resource.Resource, error) { +func (dd *daggerDriver) planChange(ctx context.Context, exr module.ExpandedResource, act module.ActionRequest) (*resource.Resource, error) { curConf, err := readConfig(exr, exr.Resource.Spec.Configs, dd.conf) if err != nil { return nil, err @@ -103,10 +103,10 @@ func (dd *daggerDriver) planChange(exr module.ExpandedResource, act module.Actio } newConf.Source = mergeConsumerGroupId(curConf.Source, newConf.Source) - if err := applyStreamSecurity(exr, newConf); err != nil { + if err := dd.applyStreamSecurity(ctx, exr, newConf); err != nil { return nil, errors.ErrInvalid.WithMsgf("failed to resolve source streams").WithCausef("%s", err.Error()) } - newConf.EnvVariables[keyStreams] = string(mustMarshalJSON(newConf.Source)) + newConf.EnvVariables[keyStreams] = streamsJSON(newConf.Source) newConf.EnvVariables[keyFlinkParallelism] = fmt.Sprint(newConf.Replicas) //we want to update these irrespective of the user input @@ -173,7 +173,7 @@ func (dd *daggerDriver) planChange(exr module.ExpandedResource, act module.Actio return &exr.Resource, nil } -func (dd *daggerDriver) planReset(exr module.ExpandedResource, act module.ActionRequest) (*resource.Resource, error) { +func (dd *daggerDriver) planReset(ctx context.Context, exr module.ExpandedResource, act module.ActionRequest) (*resource.Resource, error) { resetValue, err := kafka.ParseResetV2Params(act.Params) if err != nil { return nil, err @@ -194,10 +194,10 @@ func (dd *daggerDriver) planReset(exr module.ExpandedResource, act module.Action curConf.ResetOffset = resetValue curConf.Source = dd.consumerReset(context.Background(), *curConf, resetValue) - if err := applyStreamSecurity(exr, curConf); err != nil { + if err := dd.applyStreamSecurity(ctx, exr, curConf); err != nil { return nil, errors.ErrInvalid.WithMsgf("failed to resolve source streams").WithCausef("%s", err.Error()) } - curConf.EnvVariables[keyStreams] = string(mustMarshalJSON(curConf.Source)) + curConf.EnvVariables[keyStreams] = streamsJSON(curConf.Source) curConf.ChartValues = &dd.conf.ChartValues curConf.JarURI = dd.conf.JarURI diff --git a/modules/dagger/kafka_security.go b/modules/dagger/kafka_security.go index 33612540..477b1d40 100644 --- a/modules/dagger/kafka_security.go +++ b/modules/dagger/kafka_security.go @@ -1,12 +1,14 @@ package dagger import ( + "context" "encoding/json" "fmt" "regexp" "strings" "github.com/goto/entropy/core/module" + "github.com/goto/entropy/core/resource" kafkamod "github.com/goto/entropy/modules/kafka" ) @@ -254,12 +256,12 @@ func buildACLMounts(sources []Source, profiles map[string]*kafkamod.SecurityProf } // applyStreamSecurity resolves each source's kafka security profile, injects the -// SASL/SSL consumer config into STREAMS, and records the podTemplate ACL mounts -// on conf. It is a no-op (leaves conf untouched) for daggers whose sources have -// no security profile. -func applyStreamSecurity(exr module.ExpandedResource, conf *Config) error { - profiles, err := resolveSourceStreams(exr, conf) - if err != nil { +// SASL/SSL consumer config into the source, and records the podTemplate ACL +// mounts on conf. It is a no-op (leaves conf untouched) for daggers whose +// sources have no security profile. +func (dd *daggerDriver) applyStreamSecurity(ctx context.Context, exr module.ExpandedResource, conf *Config) error { + profiles, err := dd.resolveSourceStreams(ctx, exr, conf) + if err != nil { return err } conf.ACLMounts = buildACLMounts(conf.Source, profiles, conf.Team) @@ -267,15 +269,16 @@ func applyStreamSecurity(exr module.ExpandedResource, conf *Config) error { } // resolveSourceStreams resolves each source's kafka security profile (keyed by -// SOURCE_KAFKA_NAME), populates bootstrap servers and the SASL/SSL consumer -// config, and returns the resolved security profiles keyed by stream name. +// SOURCE_KAFKA_NAME), populates the SASL/SSL consumer config, and returns the +// resolved profiles keyed by stream name. // -// The profile is resolved inline-first: conf.StreamSecurity (populated by Dex on -// the product path) takes precedence; otherwise it falls back to the kafka -// dependency Output (raw-Entropy path). Sources with neither a profile nor a -// plaintext profile are left untouched — preserving byte-for-byte identical -// STREAMS for ODS daggers. -func resolveSourceStreams(exr module.ExpandedResource, conf *Config) (map[string]*kafkamod.SecurityProfile, error) { +// Resolution order per source: an inline conf.StreamSecurity entry, then a +// declared kafka dependency (raw-Entropy path), then — when the source carries +// the SOURCE_KAFKA_SECURITY_ENABLED flag (the Dex product path) — the kafka +// resource fetched internally by URN via dd.getResource, with no dependency. +// Sources with none of these (plaintext) are left untouched, preserving +// byte-for-byte identical STREAMS for ODS daggers. +func (dd *daggerDriver) resolveSourceStreams(ctx context.Context, exr module.ExpandedResource, conf *Config) (map[string]*kafkamod.SecurityProfile, error) { profiles := map[string]*kafkamod.SecurityProfile{} for i := range conf.Source { @@ -284,27 +287,36 @@ func resolveSourceStreams(exr module.ExpandedResource, conf *Config) (map[string continue } - // inline profile (Dex product path) wins over the kafka dependency. + // 1. inline profile, if Dex ever sends one. security := conf.StreamSecurity[streamName] - // fall back to the kafka dependency (raw-Entropy path): it also carries - // the resolved broker URL for bootstrap servers. + // 2. declared kafka dependency (raw-Entropy path): also carries the URL. if security == nil { if dep, ok := exr.Dependencies[streamName]; ok && dep.Kind == kafkamod.Module.Kind { var out kafkamod.Output if err := json.Unmarshal(dep.Output, &out); err != nil { return nil, fmt.Errorf("invalid kafka dependency output for stream %q: %w", streamName, err) } - - // bootstrap servers: explicit source value wins, else resolved URL. if conf.Source[i].SourceKafkaConsumerConfigBootstrapServers == "" && out.URL != "" { conf.Source[i].SourceKafkaConsumerConfigBootstrapServers = out.URL } - security = out.Security } } + // 3. flag set (Dex product path): fetch the kafka resource internally by + // URN and read its security profile — no dependency declared. + if security == nil && conf.Source[i].SourceKafkaSecurityEnabled { + out, err := dd.fetchKafkaOutput(ctx, exr.Resource.Project, streamName) + if err != nil { + return nil, err + } + if conf.Source[i].SourceKafkaConsumerConfigBootstrapServers == "" && out.URL != "" { + conf.Source[i].SourceKafkaConsumerConfigBootstrapServers = out.URL + } + security = out.Security + } + if !hasSecurityProfile(security) { continue } @@ -316,3 +328,33 @@ func resolveSourceStreams(exr module.ExpandedResource, conf *Config) (map[string return profiles, nil } + +// fetchKafkaOutput fetches the kafka stream's resource by URN and decodes its +// Output (url + security profile). SOURCE_KAFKA_NAME is the kafka resource name. +func (dd *daggerDriver) fetchKafkaOutput(ctx context.Context, project, streamName string) (kafkamod.Output, error) { + var out kafkamod.Output + if dd.getResource == nil { + return out, fmt.Errorf("cannot resolve kafka stream %q: resource getter not configured", streamName) + } + + urn := resource.GenerateURN(kafkamod.Module.Kind, project, streamName) + res, err := dd.getResource(ctx, urn) + if err != nil { + return out, fmt.Errorf("failed to fetch kafka stream %q (%s): %w", streamName, urn, err) + } + if err := json.Unmarshal(res.State.Output, &out); err != nil { + return out, fmt.Errorf("invalid kafka output for stream %q: %w", streamName, err) + } + return out, nil +} + +// streamsJSON marshals sources for the STREAMS env var, stripping the transient +// SOURCE_KAFKA_SECURITY_ENABLED flag so it never leaks into the running job. +func streamsJSON(sources []Source) string { + sanitized := make([]Source, len(sources)) + copy(sanitized, sources) + for i := range sanitized { + sanitized[i].SourceKafkaSecurityEnabled = false + } + return string(mustMarshalJSON(sanitized)) +} diff --git a/modules/dagger/kafka_security_test.go b/modules/dagger/kafka_security_test.go index bfb4b278..66998f54 100644 --- a/modules/dagger/kafka_security_test.go +++ b/modules/dagger/kafka_security_test.go @@ -1,6 +1,7 @@ package dagger import ( + "context" "encoding/json" "testing" @@ -8,6 +9,7 @@ import ( "github.com/stretchr/testify/require" "github.com/goto/entropy/core/module" + "github.com/goto/entropy/core/resource" kafkamod "github.com/goto/entropy/modules/kafka" ) @@ -114,7 +116,7 @@ func TestResolveSourceStreams_PopulatesBootstrapAndConfig(t *testing.T) { Source: []Source{{SourceKafka: SourceKafka{SourceKafkaName: pocStream}}}, } - profiles, err := resolveSourceStreams(exr, conf) + profiles, err := (&daggerDriver{}).resolveSourceStreams(context.Background(), exr, conf) require.NoError(t, err) assert.Equal(t, "broker-1:9098,broker-2:9098", conf.Source[0].SourceKafkaConsumerConfigBootstrapServers) @@ -133,7 +135,7 @@ func TestResolveSourceStreams_InlineProfile_NoDependency(t *testing.T) { }, } - profiles, err := resolveSourceStreams(module.ExpandedResource{}, conf) + profiles, err := (&daggerDriver{}).resolveSourceStreams(context.Background(), module.ExpandedResource{}, conf) require.NoError(t, err) require.Contains(t, profiles, pocStream) @@ -146,6 +148,40 @@ func TestResolveSourceStreams_InlineProfile_NoDependency(t *testing.T) { require.Len(t, mounts, 3) } +func TestResolveSourceStreams_FlagFetchesInternally(t *testing.T) { + out := kafkamod.Output{URL: "11.0.0.1:9098", Security: oauthbearerProfile()} + outJSON, err := json.Marshal(out) + require.NoError(t, err) + + var gotURN string + dd := &daggerDriver{ + getResource: func(_ context.Context, urn string) (*resource.Resource, error) { + gotURN = urn + return &resource.Resource{State: resource.State{Output: outJSON}}, nil + }, + } + + exr := module.ExpandedResource{Resource: resource.Resource{Project: "al-dp-id-s"}} + conf := &Config{ + Team: "team-x", + Source: []Source{{SourceKafka: SourceKafka{ + SourceKafkaName: pocStream, + SourceKafkaSecurityEnabled: true, + }}}, + } + + profiles, err := dd.resolveSourceStreams(context.Background(), exr, conf) + require.NoError(t, err) + + assert.Equal(t, resource.GenerateURN(kafkamod.Module.Kind, "al-dp-id-s", pocStream), gotURN) + require.Contains(t, profiles, pocStream) + assert.Equal(t, "11.0.0.1:9098", conf.Source[0].SourceKafkaConsumerConfigBootstrapServers) + assert.NotNil(t, conf.Source[0].SourceKafkaConsumerAdditionalConfigurations) + + // the flag must be stripped from the STREAMS env var. + assert.NotContains(t, streamsJSON(conf.Source), "SOURCE_KAFKA_SECURITY_ENABLED") +} + // a full plaintext dagger (no stream_security, no kafka dependency) produces // no additional configs, no mounts, and no taskmanager SA — STREAMS/podTemplate // unchanged. @@ -155,7 +191,7 @@ func TestApplyStreamSecurity_PlaintextDagger_NoWiring(t *testing.T) { Source: []Source{{SourceKafka: SourceKafka{SourceKafkaName: pocStream}}}, } - require.NoError(t, applyStreamSecurity(module.ExpandedResource{}, conf)) + require.NoError(t, (&daggerDriver{}).applyStreamSecurity(context.Background(), module.ExpandedResource{}, conf)) assert.Nil(t, conf.Source[0].SourceKafkaConsumerAdditionalConfigurations) assert.Nil(t, conf.ACLMounts) @@ -180,7 +216,7 @@ func TestResolveSourceStreams_KeepsExplicitBootstrap(t *testing.T) { }}}, } - _, err = resolveSourceStreams(exr, conf) + _, err = (&daggerDriver{}).resolveSourceStreams(context.Background(), exr, conf) require.NoError(t, err) assert.Equal(t, "explicit:9092", conf.Source[0].SourceKafkaConsumerConfigBootstrapServers) } diff --git a/modules/dagger/module.go b/modules/dagger/module.go index d251e578..aae21008 100644 --- a/modules/dagger/module.go +++ b/modules/dagger/module.go @@ -29,96 +29,102 @@ type FlinkCRDStatus struct { ReconciliationStatus string `json:"reconciliationStatus"` } -var Module = module.Descriptor{ - Kind: "dagger", - Dependencies: map[string]string{ - keyFlinkDependency: flink.Module.Kind, - }, - Actions: []module.ActionDesc{ - { - Name: module.CreateAction, - Description: "Creates a new dagger", +// Module builds the dagger module descriptor. getResource lets the driver fetch +// a referenced kafka stream's resource internally (by URN) to resolve its +// security profile, without declaring it as a dependency. +func Module(getResource ResourceGetter) module.Descriptor { + return module.Descriptor{ + Kind: "dagger", + Dependencies: map[string]string{ + keyFlinkDependency: flink.Module.Kind, }, - { - Name: module.UpdateAction, - Description: "Updates an existing dagger", - }, - { - Name: StopAction, - Description: "Suspends a running dagger", - }, - { - Name: StartAction, - Description: "Starts a suspended dagger", - }, - { - Name: ResetAction, - Description: "Resets the offset of a dagger", - }, - }, - DriverFactory: func(confJSON json.RawMessage) (module.Driver, error) { - conf := defaultDriverConf // clone the default value - if err := json.Unmarshal(confJSON, &conf); err != nil { - return nil, err - } else if err := validator.TaggedStruct(conf); err != nil { - return nil, err - } - - return &daggerDriver{ - conf: conf, - timeNow: time.Now, - kubeDeploy: func(_ context.Context, isCreate bool, kubeConf kube.Config, hc helm.ReleaseConfig) error { - canUpdate := func(rel *release.Release) bool { - curLabels, ok := rel.Config[labelsConfKey].(map[string]any) - if !ok { - return false - } - newLabels, ok := hc.Values[labelsConfKey].(map[string]string) - if !ok { - return false - } - - isManagedByEntropy := curLabels[labelOrchestrator] == orchestratorLabelValue - isSameDeployment := curLabels[labelDeployment] == newLabels[labelDeployment] - - return isManagedByEntropy && isSameDeployment - } - - helmCl := helm.NewClient(&helm.Config{Kubernetes: kubeConf}) - _, errHelm := helmCl.Upsert(&hc, canUpdate) - return errHelm + Actions: []module.ActionDesc{ + { + Name: module.CreateAction, + Description: "Creates a new dagger", + }, + { + Name: module.UpdateAction, + Description: "Updates an existing dagger", }, - kubeGetPod: func(ctx context.Context, conf kube.Config, ns string, labels map[string]string) ([]kube.Pod, error) { - kubeCl, err := kube.NewClient(ctx, conf) - if err != nil { - return nil, errors.ErrInternal.WithMsgf("failed to create new kube client on firehose driver kube get pod").WithCausef("%s", err.Error()) - } - return kubeCl.GetPodDetails(ctx, ns, labels, func(pod v1.Pod) bool { - // allow pods that are in running state and are not marked for deletion - return pod.Status.Phase == v1.PodRunning && pod.DeletionTimestamp == nil - }) + { + Name: StopAction, + Description: "Suspends a running dagger", }, - kubeGetCRD: func(ctx context.Context, conf kube.Config, ns string, name string) (kube.FlinkDeploymentStatus, error) { - kubeCl, err := kube.NewClient(ctx, conf) - if err != nil { - return kube.FlinkDeploymentStatus{}, errors.ErrInternal.WithMsgf("failed to create new kube client on firehose driver kube get pod").WithCausef("%s", err.Error()) - } - crd, err := kubeCl.GetCRDDetails(ctx, ns, name) - if err != nil { - return kube.FlinkDeploymentStatus{}, err - } - return parseFlinkCRDStatus(crd.Object) + { + Name: StartAction, + Description: "Starts a suspended dagger", }, - kubeProxyService: func(ctx context.Context, conf kube.Config, namespace, scheme, serviceName, port, path string) (json.RawMessage, error) { - kubeCl, err := kube.NewClient(ctx, conf) - if err != nil { - return nil, errors.ErrInternal.WithMsgf("failed to create new kube client on dagger driver kube proxy service").WithCausef("%s", err.Error()) - } - return kubeCl.ProxyService(ctx, namespace, scheme, serviceName, port, path, map[string]string{}) + { + Name: ResetAction, + Description: "Resets the offset of a dagger", }, - consumerReset: consumerReset, - }, nil - }, + }, + DriverFactory: func(confJSON json.RawMessage) (module.Driver, error) { + conf := defaultDriverConf // clone the default value + if err := json.Unmarshal(confJSON, &conf); err != nil { + return nil, err + } else if err := validator.TaggedStruct(conf); err != nil { + return nil, err + } + + return &daggerDriver{ + conf: conf, + timeNow: time.Now, + kubeDeploy: func(_ context.Context, isCreate bool, kubeConf kube.Config, hc helm.ReleaseConfig) error { + canUpdate := func(rel *release.Release) bool { + curLabels, ok := rel.Config[labelsConfKey].(map[string]any) + if !ok { + return false + } + newLabels, ok := hc.Values[labelsConfKey].(map[string]string) + if !ok { + return false + } + + isManagedByEntropy := curLabels[labelOrchestrator] == orchestratorLabelValue + isSameDeployment := curLabels[labelDeployment] == newLabels[labelDeployment] + + return isManagedByEntropy && isSameDeployment + } + + helmCl := helm.NewClient(&helm.Config{Kubernetes: kubeConf}) + _, errHelm := helmCl.Upsert(&hc, canUpdate) + return errHelm + }, + kubeGetPod: func(ctx context.Context, conf kube.Config, ns string, labels map[string]string) ([]kube.Pod, error) { + kubeCl, err := kube.NewClient(ctx, conf) + if err != nil { + return nil, errors.ErrInternal.WithMsgf("failed to create new kube client on firehose driver kube get pod").WithCausef("%s", err.Error()) + } + return kubeCl.GetPodDetails(ctx, ns, labels, func(pod v1.Pod) bool { + // allow pods that are in running state and are not marked for deletion + return pod.Status.Phase == v1.PodRunning && pod.DeletionTimestamp == nil + }) + }, + kubeGetCRD: func(ctx context.Context, conf kube.Config, ns string, name string) (kube.FlinkDeploymentStatus, error) { + kubeCl, err := kube.NewClient(ctx, conf) + if err != nil { + return kube.FlinkDeploymentStatus{}, errors.ErrInternal.WithMsgf("failed to create new kube client on firehose driver kube get pod").WithCausef("%s", err.Error()) + } + crd, err := kubeCl.GetCRDDetails(ctx, ns, name) + if err != nil { + return kube.FlinkDeploymentStatus{}, err + } + return parseFlinkCRDStatus(crd.Object) + }, + kubeProxyService: func(ctx context.Context, conf kube.Config, namespace, scheme, serviceName, port, path string) (json.RawMessage, error) { + kubeCl, err := kube.NewClient(ctx, conf) + if err != nil { + return nil, errors.ErrInternal.WithMsgf("failed to create new kube client on dagger driver kube proxy service").WithCausef("%s", err.Error()) + } + return kubeCl.ProxyService(ctx, namespace, scheme, serviceName, port, path, map[string]string{}) + }, + consumerReset: consumerReset, + getResource: getResource, + }, nil + }, + } } func parseFlinkCRDStatus(flinkDeployment map[string]interface{}) (kube.FlinkDeploymentStatus, error) { diff --git a/modules/kubernetes/module.go b/modules/kubernetes/module.go index ea28762e..b4c55822 100644 --- a/modules/kubernetes/module.go +++ b/modules/kubernetes/module.go @@ -20,8 +20,7 @@ var Module = module.Descriptor{ }, DriverFactory: func(conf json.RawMessage) (module.Driver, error) { kd := &kubeDriver{} - err := json.Unmarshal(conf, &kd) - if err != nil { + if err := json.Unmarshal(conf, kd); err != nil { return nil, errors.ErrInvalid.WithMsgf("failed to unmarshal module config: %v", err) } return kd, nil From 08b9446262517ab31bb64e02c04f79179468fca3 Mon Sep 17 00:00:00 2001 From: Deepansh Date: Fri, 31 Jul 2026 15:20:26 +0530 Subject: [PATCH 5/9] skip .claude files --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index a460164f..977afc62 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ *.dll *.so *.dylib +.claude # Test binary, built with `go test -c` *.test From 1c70275adefadd4cb184d2197827d33380ab682f Mon Sep 17 00:00:00 2001 From: "shruti.tiwari" Date: Fri, 7 Aug 2026 16:35:07 +0530 Subject: [PATCH 6/9] Added changes for Kafka ACLs in firehose --- cli/serve.go | 9 +- docs/modules/firehose.md | 36 +- modules/firehose/config.go | 47 +++ modules/firehose/driver.go | 28 ++ modules/firehose/driver_plan.go | 38 ++- modules/firehose/kafka_security.go | 424 ++++++++++++++++++++++++ modules/firehose/kafka_security_test.go | 266 +++++++++++++++ modules/firehose/module.go | 184 +++++----- modules/firehose/module_test.go | 4 +- modules/firehose/schema/config.json | 46 +++ 10 files changed, 977 insertions(+), 105 deletions(-) create mode 100644 modules/firehose/kafka_security.go create mode 100644 modules/firehose/kafka_security_test.go diff --git a/cli/serve.go b/cli/serve.go index d815145f..d545bf71 100644 --- a/cli/serve.go +++ b/cli/serve.go @@ -72,8 +72,6 @@ func StartServer(ctx context.Context, cfg Config, migrate, spawnWorker bool) err } store := setupStorage(cfg.PGConnStr, cfg.Syncer, cfg.Service) - resourceService := core.New(store, moduleService, time.Now, cfg.Syncer.SyncBackoffInterval, cfg.Syncer.MaxRetries, cfg.Telemetry.ServiceName) - moduleService := module.NewService(setupRegistry(store), store) // TODO: Securely load this value from an environment variable or secrets @@ -134,12 +132,13 @@ func (l moduleConfigLookup) ModuleConfigs(ctx context.Context, moduleURN string) func setupRegistry(store *postgres.Store) module.Registry { supported := []module.Descriptor{ kubernetes.Module, - firehose.Module, job.Module, kafka.Module, flink.Module, - // dagger resolves ACL kafka streams by fetching their resource internally - // (no dependency), so it needs a resource getter backed by the store. + // dagger and firehose resolve ACL kafka streams by fetching their + // resource internally (no dependency), so they need a resource getter + // backed by the store. + firehose.Module(store.GetByURN), dagger.Module(store.GetByURN), } diff --git a/docs/modules/firehose.md b/docs/modules/firehose.md index b870e4df..639f5e3c 100644 --- a/docs/modules/firehose.md +++ b/docs/modules/firehose.md @@ -34,4 +34,38 @@ type moduleConfig struct { | `ChartVersion` | `string` Chart version you want to use. | | `Firehose` | `struct` Holds firehose configuration. | -Detailed JSONSchema for config can be referenced [here](https://github.com/goto/entropy/blob/main/modules/firehose/schema/config.json). \ No newline at end of file +Detailed JSONSchema for config can be referenced [here](https://github.com/goto/entropy/blob/main/modules/firehose/schema/config.json). + +## Kafka ACL (SASL/SSL) source streams + +A firehose reading from a secured stream names its kafka resource through `stream_name` +(or the `SOURCE_KAFKA_NAME` env variable). The stream's security profile — the same +`security` block the kafka module exposes on its output — is resolved during Plan, in +this order: + +1. an inline `stream_security` entry (prefetched by Dex), +2. a declared kafka dependency keyed by the stream name, +3. the kafka resource fetched internally by URN, when `stream_security_enabled` is set + (or `SOURCE_KAFKA_SECURITY_ENABLED=true` is passed as an env variable — it is stripped + before the config reaches the running firehose). + +From the resolved profile the module injects the `SOURCE_KAFKA_CONSUMER_CONFIG_*` env +variables (security protocol, SASL mechanism, JAAS config, truststore location/password) +and records the secret volumes in `acl_mounts`, which is rendered as the `acl_mounts` +chart value alongside `service_account`. Credentials are never inlined: username, password +and truststore password are referenced through the `literalfile` config provider pointing +at the mounted secrets. Plaintext firehoses are untouched — no injected config, no mounts, +no chart value changes. + +The provider/callback classes and the default service account are deployment level +settings under the module's driver config: + +```json +{ + "kafka_security": { + "config_provider_class": "com.example.kafka.configproviders.LiteralFileConfigProvider", + "sasl_login_callback_handler_class": "com.example.kafka.security.PodLoginCallbackHandler", + "service_account": "aegis-kafka" + } +} +``` \ No newline at end of file diff --git a/modules/firehose/config.go b/modules/firehose/config.go index 5417de70..b361f71f 100644 --- a/modules/firehose/config.go +++ b/modules/firehose/config.go @@ -8,6 +8,7 @@ import ( "github.com/goto/entropy/core/resource" "github.com/goto/entropy/modules" + kafkamod "github.com/goto/entropy/modules/kafka" "github.com/goto/entropy/pkg/errors" "github.com/goto/entropy/pkg/validator" ) @@ -67,6 +68,52 @@ type Config struct { ChartValues *ChartValues `json:"chart_values,omitempty"` InitContainer InitContainer `json:"init_container,omitempty"` Autoscaler *Autoscaler `json:"autoscaler,omitempty"` + + // Team owns the firehose. It selects the credential reference from a + // stream's PLAIN/SCRAM ACL list. + Team string `json:"team,omitempty"` + + // StreamName is the name of the kafka resource backing SOURCE_KAFKA_BROKERS. + // It is the key used to resolve the stream's security profile and the stable + // directory name of its mounted secrets. Falls back to the SOURCE_KAFKA_NAME + // env variable when unset. + StreamName string `json:"stream_name,omitempty"` + + // StreamSecurityEnabled makes the driver resolve StreamName's kafka resource + // internally (by URN, without a declared dependency) to read its security + // profile. + StreamSecurityEnabled bool `json:"stream_security_enabled,omitempty"` + + // StreamSecurity holds the kafka security profile fetched by Dex, keyed by + // stream name. References only — never inline secret values. + StreamSecurity map[string]*kafkamod.SecurityProfile `json:"stream_security,omitempty"` + + // ACLMounts is the set of secret / projected-token volume mounts required by + // an ACL (SASL_SSL/OAUTHBEARER, SSL, PLAIN/SCRAM) source stream. It is + // computed by applyStreamSecurity from the resolved stream security profile. + ACLMounts []ACLMount `json:"acl_mounts,omitempty"` + + // ServiceAccount, when set, becomes the pod's service account. It is the + // OAuth identity authorized for ACL streams. Empty preserves the chart's + // default service account. + ServiceAccount string `json:"service_account,omitempty"` +} + +// ACLMount describes a single pod volume+mount for an ACL stream. +// Type is either "secret" or "projected". +type ACLMount struct { + Name string `json:"name"` + MountPath string `json:"mountPath"` + SecretName string `json:"secretName,omitempty"` + Type string `json:"type"` +} + +// streamName is the kafka resource name backing this firehose, if any. +func (cfg *Config) streamName() string { + if cfg.StreamName != "" { + return cfg.StreamName + } + return cfg.EnvVariables[keySourceKafkaName] } type Telegraf struct { diff --git a/modules/firehose/driver.go b/modules/firehose/driver.go index 4b14da8b..45f63034 100644 --- a/modules/firehose/driver.go +++ b/modules/firehose/driver.go @@ -90,8 +90,13 @@ type firehoseDriver struct { kubeGetPod kubeGetPodFn kubeGetDeployment kubeGetDeploymentFn consumerReset consumerResetFn + getResource ResourceGetter } +// ResourceGetter fetches a resource by URN. It lets the driver resolve an ACL +// kafka stream internally, without declaring it as a dependency. +type ResourceGetter func(ctx context.Context, urn string) (*resource.Resource, error) + type ( kubeDeployFn func(ctx context.Context, isCreate bool, conf kube.Config, hc helm.ReleaseConfig) error kubeGetPodFn func(ctx context.Context, conf kube.Config, ns string, labels map[string]string) ([]kube.Pod, error) @@ -147,6 +152,10 @@ type driverConf struct { KubeDeployTimeout int `json:"kube_deploy_timeout_seconds"` Autoscaler FirehoseAutoscaler `json:"autoscaler,omitempty"` + + // KafkaSecurity holds the deployment level settings used when wiring an ACL + // (SASL/SSL) source stream. + KafkaSecurity KafkaSecurity `json:"kafka_security,omitempty"` } type FirehoseAutoscaler struct { @@ -396,6 +405,25 @@ func (fd *firehoseDriver) getHelmRelease(res resource.Resource, conf Config, "mountSecrets": mountSecrets, } + // ACL (SASL/SSL) source support. Both keys are omitted for plaintext + // firehoses, which keeps their rendered chart values unchanged. + if len(conf.ACLMounts) > 0 { + aclMounts := make([]map[string]any, 0, len(conf.ACLMounts)) + for _, m := range conf.ACLMounts { + aclMounts = append(aclMounts, map[string]any{ + "name": m.Name, + "mountPath": m.MountPath, + "secretName": m.SecretName, + "type": m.Type, + }) + } + rc.Values["acl_mounts"] = aclMounts + } + + if conf.ServiceAccount != "" { + rc.Values["service_account"] = conf.ServiceAccount + } + if conf.Autoscaler != nil { rc.Values["autoscaler"], err = conf.Autoscaler.GetHelmValues(conf) if err != nil { diff --git a/modules/firehose/driver_plan.go b/modules/firehose/driver_plan.go index 3803c0f6..4fb13a38 100644 --- a/modules/firehose/driver_plan.go +++ b/modules/firehose/driver_plan.go @@ -21,23 +21,23 @@ var suffixRegex = regexp.MustCompile(`^([A-Za-z0-9-]+)-([0-9]+)$`) var errCauseInvalidNamespaceUpdate = "cannot update kube namespace of a running firehose" -func (fd *firehoseDriver) Plan(_ context.Context, exr module.ExpandedResource, act module.ActionRequest) (*resource.Resource, error) { +func (fd *firehoseDriver) Plan(ctx context.Context, exr module.ExpandedResource, act module.ActionRequest) (*resource.Resource, error) { switch act.Name { case module.CreateAction: - return fd.planCreate(exr, act) + return fd.planCreate(ctx, exr, act) case ResetAction: - return fd.planReset(exr, act) + return fd.planReset(ctx, exr, act) case ResetV2Action: - return fd.planResetV2(exr, act) + return fd.planResetV2(ctx, exr, act) default: - return fd.planChange(exr, act) + return fd.planChange(ctx, exr, act) } } -func (fd *firehoseDriver) planChange(exr module.ExpandedResource, act module.ActionRequest) (*resource.Resource, error) { +func (fd *firehoseDriver) planChange(ctx context.Context, exr module.ExpandedResource, act module.ActionRequest) (*resource.Resource, error) { curConf, err := readConfig(exr.Resource, exr.Resource.Spec.Configs, fd.conf) if err != nil { return nil, err @@ -84,6 +84,12 @@ func (fd *firehoseDriver) planChange(exr module.ExpandedResource, act module.Act } } + // resolve the ACL source stream (SASL/SSL consumer config + pod mounts). + // No-op for plaintext sources. + if err := fd.applyStreamSecurity(ctx, exr, newConf); err != nil { + return nil, errors.ErrInvalid.WithMsgf("failed to resolve source stream").WithCausef("%s", err.Error()) + } + curConf = newConf case ScaleAction: @@ -144,12 +150,18 @@ func (fd *firehoseDriver) planChange(exr module.ExpandedResource, act module.Act return &exr.Resource, nil } -func (fd *firehoseDriver) planCreate(exr module.ExpandedResource, act module.ActionRequest) (*resource.Resource, error) { +func (fd *firehoseDriver) planCreate(ctx context.Context, exr module.ExpandedResource, act module.ActionRequest) (*resource.Resource, error) { conf, err := readConfig(exr.Resource, act.Params, fd.conf) if err != nil { return nil, err } + // resolve the ACL source stream (SASL/SSL consumer config + pod mounts). + // No-op for plaintext sources. + if err := fd.applyStreamSecurity(ctx, exr, conf); err != nil { + return nil, errors.ErrInvalid.WithMsgf("failed to resolve source stream").WithCausef("%s", err.Error()) + } + chartVals, err := mergeChartValues(&fd.conf.ChartValues, conf.ChartValues) if err != nil { return nil, err @@ -193,7 +205,7 @@ func (fd *firehoseDriver) planCreate(exr module.ExpandedResource, act module.Act return &exr.Resource, nil } -func (fd *firehoseDriver) planResetV2(exr module.ExpandedResource, act module.ActionRequest) (*resource.Resource, error) { +func (fd *firehoseDriver) planResetV2(ctx context.Context, exr module.ExpandedResource, act module.ActionRequest) (*resource.Resource, error) { resetValue, err := kafka.ParseResetV2Params(act.Params) if err != nil { return nil, err @@ -206,6 +218,10 @@ func (fd *firehoseDriver) planResetV2(exr module.ExpandedResource, act module.Ac return nil, err } + if err := fd.applyStreamSecurity(ctx, exr, curConf); err != nil { + return nil, errors.ErrInvalid.WithMsgf("failed to resolve source stream").WithCausef("%s", err.Error()) + } + curConf.ResetOffset = resetValue exr.Resource.Spec.Configs = modules.MustJSON(curConf) @@ -225,7 +241,7 @@ func (fd *firehoseDriver) planResetV2(exr module.ExpandedResource, act module.Ac return &exr.Resource, nil } -func (fd *firehoseDriver) planReset(exr module.ExpandedResource, act module.ActionRequest) (*resource.Resource, error) { +func (fd *firehoseDriver) planReset(ctx context.Context, exr module.ExpandedResource, act module.ActionRequest) (*resource.Resource, error) { resetValue, err := kafka.ParseResetParams(act.Params) if err != nil { return nil, err @@ -238,6 +254,10 @@ func (fd *firehoseDriver) planReset(exr module.ExpandedResource, act module.Acti return nil, err } + if err := fd.applyStreamSecurity(ctx, exr, curConf); err != nil { + return nil, errors.ErrInvalid.WithMsgf("failed to resolve source stream").WithCausef("%s", err.Error()) + } + curConf.ResetOffset = resetValue curConf.EnvVariables[SourceKafkaConsumerAutoOffsetReset] = resetValue curConf.EnvVariables[confKeyConsumerID], err = getNewConsumerGroupID(curConf.EnvVariables[confKeyConsumerID]) diff --git a/modules/firehose/kafka_security.go b/modules/firehose/kafka_security.go new file mode 100644 index 00000000..5e0d684b --- /dev/null +++ b/modules/firehose/kafka_security.go @@ -0,0 +1,424 @@ +package firehose + +import ( + "context" + "encoding/json" + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/goto/entropy/core/module" + "github.com/goto/entropy/core/resource" + kafkamod "github.com/goto/entropy/modules/kafka" +) + +// SASL/SSL consumer config keys. Firehose passes every +// SOURCE_KAFKA_CONSUMER_CONFIG_* env variable straight to the kafka consumer, +// so unlike dagger — where these live inside the STREAMS json — they are flat +// env variables here. +const ( + keyConsumerSecurityProtocol = "SOURCE_KAFKA_CONSUMER_CONFIG_SECURITY_PROTOCOL" + keyConsumerSaslMechanism = "SOURCE_KAFKA_CONSUMER_CONFIG_SASL_MECHANISM" + keyConsumerSaslJaasConfig = "SOURCE_KAFKA_CONSUMER_CONFIG_SASL_JAAS_CONFIG" + keyConsumerSaslLoginCallbackHandler = "SOURCE_KAFKA_CONSUMER_CONFIG_SASL_LOGIN_CALLBACK_HANDLER_CLASS" + keyConsumerSSLProtocol = "SOURCE_KAFKA_CONSUMER_CONFIG_SSL_PROTOCOL" + keyConsumerSSLTruststoreType = "SOURCE_KAFKA_CONSUMER_CONFIG_SSL_TRUSTSTORE_TYPE" + keyConsumerSSLTruststoreLocation = "SOURCE_KAFKA_CONSUMER_CONFIG_SSL_TRUSTSTORE_LOCATION" + keyConsumerSSLTruststorePassword = "SOURCE_KAFKA_CONSUMER_CONFIG_SSL_TRUSTSTORE_PASSWORD" + keyConsumerConfigProviders = "SOURCE_KAFKA_CONSUMER_CONFIG_CONFIG_PROVIDERS" + keyConsumerConfigProvidersLiteralClass = "SOURCE_KAFKA_CONSUMER_CONFIG_CONFIG_PROVIDERS_LITERALFILE_CLASS" +) + +// transient inputs Dex may send as env variables. They describe the stream to +// resolve and are never forwarded to the running firehose. +const ( + keySourceKafkaName = "SOURCE_KAFKA_NAME" + keySourceKafkaSecurityEnabled = "SOURCE_KAFKA_SECURITY_ENABLED" +) + +// managedSecurityKeys are owned by this module: they are wiped and rebuilt on +// every plan of a firehose that names a kafka stream, so a stream that loses +// its ACLs does not leave stale credentials behind. +var managedSecurityKeys = []string{ + keyConsumerSecurityProtocol, + keyConsumerSaslMechanism, + keyConsumerSaslJaasConfig, + keyConsumerSaslLoginCallbackHandler, + keyConsumerSSLProtocol, + keyConsumerSSLTruststoreType, + keyConsumerSSLTruststoreLocation, + keyConsumerSSLTruststorePassword, + keyConsumerConfigProviders, + keyConsumerConfigProvidersLiteralClass, +} + +// GTF Kafka security constants, same values the dagger module injects. +const ( + oauthConsumerSaslJaasConfig = "org.apache.kafka.common.security.oauthbearer.OAuthBearerLoginModule required;" + scramLoginModule = "org.apache.kafka.common.security.scram.ScramLoginModule" + plainLoginModule = "org.apache.kafka.common.security.plain.PlainLoginModule" + literalFileConfigProviderName = "literalfile" + + // defaults for the classes shipped by the platform kafka-security library. + // Override per deployment via driver config `kafka_security` when the + // firehose image packages them under different names. + defaultOauthSaslLoginCallbackHandlerClass = "io.gtflabs.kafka.security.oauthbearer.kubernetes.PodLoginCallbackHandler" + defaultLiteralFileConfigProviderClass = "com.gtf.dagger.kafka.configproviders.LiteralFileConfigProvider" +) + +const ( + securityProtocolSASLSSL = "SASL_SSL" + securityProtocolSASLPlaintext = "SASL_PLAINTEXT" + securityProtocolSSL = "SSL" + saslMechanismOauthbearer = "OAUTHBEARER" + saslMechanismPlain = "PLAIN" + saslMechanismScram = "SCRAM-SHA-512" + truststoreTypePKCS12 = "PKCS12" +) + +// mount path templates, kept consistent between the injected consumer config +// and the pod volume mounts, and identical to the dagger module's layout. +const ( + kafkaTokenVolumeName = "kafka-token" + kafkaTokenMountPath = "/var/run/secrets/kafka/serviceaccount" + certsMountPathFmt = "/var/secrets/%s/certs" + passwordsMountPathFmt = "/var/secrets/%s/passwords" + credentialsMountPathFmt = "/var/secrets/%s/credentials" +) + +var invalidVolumeNameChars = regexp.MustCompile(`[^a-zA-Z0-9]+`) + +// KafkaSecurity carries the deployment level knobs for ACL streams. +type KafkaSecurity struct { + // ConfigProviderClass reads secret material off the mounted volumes for the + // ${literalfile:...} references in the injected consumer config. + ConfigProviderClass string `json:"config_provider_class,omitempty"` + + // SaslLoginCallbackHandlerClass is the OAUTHBEARER login callback handler + // that exchanges the projected service-account token for a kafka token. + SaslLoginCallbackHandlerClass string `json:"sasl_login_callback_handler_class,omitempty"` + + // ServiceAccount is the OAuth identity authorized for ACL streams. Used + // when the resource config does not set one. Empty preserves the chart's + // default service account. + ServiceAccount string `json:"service_account,omitempty"` +} + +func (k KafkaSecurity) withDefaults() KafkaSecurity { + if k.ConfigProviderClass == "" { + k.ConfigProviderClass = defaultLiteralFileConfigProviderClass + } + if k.SaslLoginCallbackHandlerClass == "" { + k.SaslLoginCallbackHandlerClass = defaultOauthSaslLoginCallbackHandlerClass + } + return k +} + +func isOauthbearerStream(sp *kafkamod.SecurityProfile) bool { + return sp != nil && + sp.SecurityProtocol == securityProtocolSASLSSL && + sp.SaslMechanism == saslMechanismOauthbearer && + sp.SSLCertSecret != "" && + sp.SSLTruststorePasswordDetails != nil +} + +func isPlainOrScramStream(sp *kafkamod.SecurityProfile) bool { + if sp == nil { + return false + } + protoOK := sp.SecurityProtocol == securityProtocolSASLPlaintext || sp.SecurityProtocol == securityProtocolSASLSSL + mechOK := sp.SaslMechanism == saslMechanismPlain || sp.SaslMechanism == saslMechanismScram + return protoOK && mechOK +} + +func isTLSStream(sp *kafkamod.SecurityProfile) bool { + return sp != nil && sp.SecurityProtocol == securityProtocolSSL +} + +// hasSecurityProfile reports whether the profile requires any SASL/SSL wiring. +func hasSecurityProfile(sp *kafkamod.SecurityProfile) bool { + return sp != nil && sp.SecurityProtocol != "" && !strings.EqualFold(sp.SecurityProtocol, "PLAINTEXT") +} + +func truststoreExtension(truststoreType string) string { + if strings.EqualFold(truststoreType, truststoreTypePKCS12) { + return "p12" + } + return "jks" +} + +// buildSecurityConfigs builds the SOURCE_KAFKA_CONSUMER_CONFIG_* env variables +// for the source stream, branching on its security profile. streamName is the +// stable per-stream directory name used both here and in the volume mounts. +// Returns nil for plaintext streams so env variables stay unchanged. +// +// Unlike dagger, the mount descriptors (SSL_CERT_SECRET and +// SSL_TRUSTSTORE_PASSWORD_DETAILS) are not injected: firehose hands these keys +// to the kafka consumer verbatim, so the secret references travel through +// Config.ACLMounts (the chart values) instead. +func buildSecurityConfigs(streamName string, sp *kafkamod.SecurityProfile, team string, sec KafkaSecurity) map[string]string { + if !hasSecurityProfile(sp) { + return nil + } + sec = sec.withDefaults() + + cfg := map[string]string{} + cfg[keyConsumerSecurityProtocol] = sp.SecurityProtocol + if sp.SaslMechanism != "" { + cfg[keyConsumerSaslMechanism] = sp.SaslMechanism + } + + // SSL material is shared by the TLS and OAUTHBEARER paths. + if isTLSStream(sp) || isOauthbearerStream(sp) { + if sp.SSLProtocol != "" { + cfg[keyConsumerSSLProtocol] = sp.SSLProtocol + } + if sp.SSLTruststoreType != "" { + cfg[keyConsumerSSLTruststoreType] = sp.SSLTruststoreType + } + if sp.SSLCertSecret != "" { + cfg[keyConsumerSSLTruststoreLocation] = fmt.Sprintf( + "/var/secrets/%s/certs/truststore.%s", streamName, truststoreExtension(sp.SSLTruststoreType)) + } + if sp.SSLTruststorePasswordDetails != nil { + cfg[keyConsumerSSLTruststorePassword] = fmt.Sprintf( + "${literalfile:/var/secrets/%s/passwords/%s:literal-value}", + streamName, sp.SSLTruststorePasswordDetails.Key) + cfg[keyConsumerConfigProviders] = literalFileConfigProviderName + cfg[keyConsumerConfigProvidersLiteralClass] = sec.ConfigProviderClass + } + } + + if isOauthbearerStream(sp) { + cfg[keyConsumerSaslLoginCallbackHandler] = sec.SaslLoginCallbackHandlerClass + cfg[keyConsumerSaslJaasConfig] = oauthConsumerSaslJaasConfig + } + + if isPlainOrScramStream(sp) { + cfg[keyConsumerSaslJaasConfig] = buildSASLJaasConfig(streamName, sp, team) + // credentials are referenced via the literalfile provider (never inlined). + if _, ok := sp.ACLs[team]; ok { + cfg[keyConsumerConfigProviders] = literalFileConfigProviderName + cfg[keyConsumerConfigProvidersLiteralClass] = sec.ConfigProviderClass + } + } + + return cfg +} + +// buildSASLJaasConfig builds the JAAS config string for PLAIN/SCRAM mechanisms. +// Credentials are referenced through the literalfile config provider pointing +// at the mounted secret, never inlined. +func buildSASLJaasConfig(streamName string, sp *kafkamod.SecurityProfile, team string) string { + loginModule := scramLoginModule + if sp.SaslMechanism == saslMechanismPlain { + loginModule = plainLoginModule + } + + cred, ok := sp.ACLs[team] + if !ok || cred.SecretName == "" { + return fmt.Sprintf("%s required;", loginModule) + } + + userRef := fmt.Sprintf("${literalfile:/var/secrets/%s/credentials/%s:literal-value}", streamName, cred.UsernameKey) + passRef := fmt.Sprintf("${literalfile:/var/secrets/%s/credentials/%s:literal-value}", streamName, cred.PasswordKey) + return fmt.Sprintf("%s required username=%q password=%q;", loginModule, userRef, passRef) +} + +// sanitizeVolumeName renders a k8s-safe (<=63 char, lowercase alnum/dash) volume name. +func sanitizeVolumeName(name string) string { + sanitized := invalidVolumeNameChars.ReplaceAllString(name, "-") + sanitized = strings.ToLower(strings.Trim(sanitized, "-")) + if len(sanitized) > 63 { + sanitized = strings.Trim(sanitized[:63], "-") + } + return sanitized +} + +// buildACLMounts derives the pod volume mounts required by the source stream's +// security profile. Returns nil when the stream needs no ACL mounts so the pod +// spec is unchanged for plaintext firehoses. +func buildACLMounts(streamName string, sp *kafkamod.SecurityProfile, team string) []ACLMount { + if !hasSecurityProfile(sp) { + return nil + } + + var mounts []ACLMount + + if isOauthbearerStream(sp) { + mounts = append(mounts, ACLMount{ + Name: sanitizeVolumeName(streamName + "-" + sp.SSLCertSecret), + MountPath: fmt.Sprintf(certsMountPathFmt, streamName), + SecretName: sp.SSLCertSecret, + Type: "secret", + }) + mounts = append(mounts, ACLMount{ + Name: sanitizeVolumeName(streamName + "-" + sp.SSLTruststorePasswordDetails.SecretName), + MountPath: fmt.Sprintf(passwordsMountPathFmt, streamName), + SecretName: sp.SSLTruststorePasswordDetails.SecretName, + Type: "secret", + }) + } else if isTLSStream(sp) && sp.SSLCertSecret != "" { + mounts = append(mounts, ACLMount{ + Name: sanitizeVolumeName(streamName + "-" + sp.SSLCertSecret), + MountPath: fmt.Sprintf(certsMountPathFmt, streamName), + SecretName: sp.SSLCertSecret, + Type: "secret", + }) + if sp.SSLTruststorePasswordDetails != nil && sp.SSLTruststorePasswordDetails.SecretName != "" { + mounts = append(mounts, ACLMount{ + Name: sanitizeVolumeName(streamName + "-" + sp.SSLTruststorePasswordDetails.SecretName), + MountPath: fmt.Sprintf(passwordsMountPathFmt, streamName), + SecretName: sp.SSLTruststorePasswordDetails.SecretName, + Type: "secret", + }) + } + } + + // PLAIN/SCRAM: mount the team's referenced credential secret so the + // literalfile provider can read username/password without inlining them. + if isPlainOrScramStream(sp) { + if cred, ok := sp.ACLs[team]; ok && cred.SecretName != "" { + mounts = append(mounts, ACLMount{ + Name: sanitizeVolumeName(streamName + "-" + cred.SecretName), + MountPath: fmt.Sprintf(credentialsMountPathFmt, streamName), + SecretName: cred.SecretName, + Type: "secret", + }) + } + } + + if len(mounts) == 0 { + return nil + } + + // single shared projected kafka service-account token. + mounts = append(mounts, ACLMount{ + Name: kafkaTokenVolumeName, + MountPath: kafkaTokenMountPath, + Type: "projected", + }) + + return mounts +} + +// applyStreamSecurity resolves the source stream's kafka security profile, +// injects the SASL/SSL consumer config into the env variables, and records the +// pod ACL mounts on conf. It is a no-op (leaves conf untouched) for firehoses +// that do not name a kafka stream, and clears the wiring for streams that no +// longer carry a security profile. +func (fd *firehoseDriver) applyStreamSecurity(ctx context.Context, exr module.ExpandedResource, conf *Config) error { + // the flag may arrive as an env variable (Dex); it is transient and must + // never reach the running firehose. + if val, ok := conf.EnvVariables[keySourceKafkaSecurityEnabled]; ok { + enabled, err := strconv.ParseBool(strings.TrimSpace(val)) + if err != nil { + return fmt.Errorf("invalid %s value %q: %w", keySourceKafkaSecurityEnabled, val, err) + } + conf.StreamSecurityEnabled = conf.StreamSecurityEnabled || enabled + delete(conf.EnvVariables, keySourceKafkaSecurityEnabled) + } + + streamName := conf.streamName() + if streamName == "" { + return nil + } + + if conf.EnvVariables == nil { + conf.EnvVariables = map[string]string{} + } + + // this module owns the SASL/SSL keys for a stream-backed firehose: drop + // whatever a previous plan injected before re-resolving. + for _, key := range managedSecurityKeys { + delete(conf.EnvVariables, key) + } + conf.ACLMounts = nil + + security, err := fd.resolveStreamSecurity(ctx, exr, conf, streamName) + if err != nil { + return err + } + if !hasSecurityProfile(security) { + return nil + } + + for key, val := range buildSecurityConfigs(streamName, security, conf.Team, fd.conf.KafkaSecurity) { + conf.EnvVariables[key] = val + } + conf.ACLMounts = buildACLMounts(streamName, security, conf.Team) + + if conf.ServiceAccount == "" { + conf.ServiceAccount = fd.conf.KafkaSecurity.ServiceAccount + } + + return nil +} + +// resolveStreamSecurity resolves the source stream's kafka security profile. +// +// Resolution order: an inline conf.StreamSecurity entry, then a declared kafka +// dependency (raw-Entropy path), then — when the resource carries the +// stream_security_enabled flag (the Dex product path) — the kafka resource +// fetched internally by URN via fd.getResource, with no dependency. Firehoses +// with none of these (plaintext) are left untouched. +func (fd *firehoseDriver) resolveStreamSecurity(ctx context.Context, exr module.ExpandedResource, + conf *Config, streamName string, +) (*kafkamod.SecurityProfile, error) { + // 1. inline profile, if Dex prefetched one. + if security := conf.StreamSecurity[streamName]; security != nil { + return security, nil + } + + // 2. declared kafka dependency (raw-Entropy path): also carries the URL. + if dep, ok := exr.Dependencies[streamName]; ok && dep.Kind == kafkamod.Module.Kind { + var out kafkamod.Output + if err := json.Unmarshal(dep.Output, &out); err != nil { + return nil, fmt.Errorf("invalid kafka dependency output for stream %q: %w", streamName, err) + } + setKafkaBrokers(conf, out.URL) + return out.Security, nil + } + + // 3. flag set (Dex product path): fetch the kafka resource internally by URN + // and read its security profile — no dependency declared. + if conf.StreamSecurityEnabled { + out, err := fd.fetchKafkaOutput(ctx, exr.Resource.Project, streamName) + if err != nil { + return nil, err + } + setKafkaBrokers(conf, out.URL) + return out.Security, nil + } + + return nil, nil +} + +// fetchKafkaOutput fetches the kafka stream's resource by URN and decodes its +// Output (url + security profile). streamName is the kafka resource name. +func (fd *firehoseDriver) fetchKafkaOutput(ctx context.Context, project, streamName string) (kafkamod.Output, error) { + var out kafkamod.Output + if fd.getResource == nil { + return out, fmt.Errorf("cannot resolve kafka stream %q: resource getter not configured", streamName) + } + + urn := resource.GenerateURN(kafkamod.Module.Kind, project, streamName) + res, err := fd.getResource(ctx, urn) + if err != nil { + return out, fmt.Errorf("failed to fetch kafka stream %q (%s): %w", streamName, urn, err) + } + if err := json.Unmarshal(res.State.Output, &out); err != nil { + return out, fmt.Errorf("invalid kafka output for stream %q: %w", streamName, err) + } + return out, nil +} + +// setKafkaBrokers fills SOURCE_KAFKA_BROKERS from the resolved stream URL, +// leaving an explicitly configured value untouched. +func setKafkaBrokers(conf *Config, url string) { + if url == "" || conf.EnvVariables[confKeyKafkaBrokers] != "" { + return + } + conf.EnvVariables[confKeyKafkaBrokers] = url +} diff --git a/modules/firehose/kafka_security_test.go b/modules/firehose/kafka_security_test.go new file mode 100644 index 00000000..f6ed7525 --- /dev/null +++ b/modules/firehose/kafka_security_test.go @@ -0,0 +1,266 @@ +package firehose + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/goto/entropy/core/module" + "github.com/goto/entropy/core/resource" + kafkamod "github.com/goto/entropy/modules/kafka" +) + +const pocStream = "al-gp-id-s-central-kf" + +func oauthbearerProfile() *kafkamod.SecurityProfile { + return &kafkamod.SecurityProfile{ + SecurityProtocol: "SASL_SSL", + SaslMechanism: "OAUTHBEARER", + SSLProtocol: "SSL", + SSLTruststoreType: "PKCS12", + SSLCertSecret: "kafka-central-cert", + SSLTruststorePasswordDetails: &kafkamod.SecretKeyRef{ + SecretName: "scp-kafka-ssl-secrets", + Key: "truststore_password", + }, + } +} + +// the injected consumer config matches the reference fixture. Unlike dagger, +// the mount descriptors are not part of it — they travel through ACLMounts. +func TestBuildSecurityConfigs_OAUTHBEARER_MatchesFixture(t *testing.T) { + got := buildSecurityConfigs(pocStream, oauthbearerProfile(), "team-x", KafkaSecurity{}) + + want := map[string]string{ + "SOURCE_KAFKA_CONSUMER_CONFIG_CONFIG_PROVIDERS": "literalfile", + "SOURCE_KAFKA_CONSUMER_CONFIG_CONFIG_PROVIDERS_LITERALFILE_CLASS": defaultLiteralFileConfigProviderClass, + "SOURCE_KAFKA_CONSUMER_CONFIG_SASL_JAAS_CONFIG": "org.apache.kafka.common.security.oauthbearer.OAuthBearerLoginModule required;", + "SOURCE_KAFKA_CONSUMER_CONFIG_SASL_LOGIN_CALLBACK_HANDLER_CLASS": defaultOauthSaslLoginCallbackHandlerClass, + "SOURCE_KAFKA_CONSUMER_CONFIG_SASL_MECHANISM": "OAUTHBEARER", + "SOURCE_KAFKA_CONSUMER_CONFIG_SECURITY_PROTOCOL": "SASL_SSL", + "SOURCE_KAFKA_CONSUMER_CONFIG_SSL_PROTOCOL": "SSL", + "SOURCE_KAFKA_CONSUMER_CONFIG_SSL_TRUSTSTORE_LOCATION": "/var/secrets/al-gp-id-s-central-kf/certs/truststore.p12", + "SOURCE_KAFKA_CONSUMER_CONFIG_SSL_TRUSTSTORE_PASSWORD": "${literalfile:/var/secrets/al-gp-id-s-central-kf/passwords/truststore_password:literal-value}", + "SOURCE_KAFKA_CONSUMER_CONFIG_SSL_TRUSTSTORE_TYPE": "PKCS12", + } + + assert.Equal(t, want, got) +} + +// the provider / callback handler classes are overridable per deployment. +func TestBuildSecurityConfigs_OverridesClasses(t *testing.T) { + got := buildSecurityConfigs(pocStream, oauthbearerProfile(), "team-x", KafkaSecurity{ + ConfigProviderClass: "com.example.LiteralFileConfigProvider", + SaslLoginCallbackHandlerClass: "com.example.PodLoginCallbackHandler", + }) + + assert.Equal(t, "com.example.LiteralFileConfigProvider", got[keyConsumerConfigProvidersLiteralClass]) + assert.Equal(t, "com.example.PodLoginCallbackHandler", got[keyConsumerSaslLoginCallbackHandler]) +} + +func TestBuildACLMounts_OAUTHBEARER_MatchesFixture(t *testing.T) { + mounts := buildACLMounts(pocStream, oauthbearerProfile(), "team-x") + + require.Len(t, mounts, 3) + assert.Equal(t, ACLMount{ + Name: "al-gp-id-s-central-kf-kafka-central-cert", + MountPath: "/var/secrets/al-gp-id-s-central-kf/certs", + SecretName: "kafka-central-cert", + Type: "secret", + }, mounts[0]) + assert.Equal(t, ACLMount{ + Name: "al-gp-id-s-central-kf-scp-kafka-ssl-secrets", + MountPath: "/var/secrets/al-gp-id-s-central-kf/passwords", + SecretName: "scp-kafka-ssl-secrets", + Type: "secret", + }, mounts[1]) + assert.Equal(t, ACLMount{ + Name: "kafka-token", + MountPath: "/var/run/secrets/kafka/serviceaccount", + Type: "projected", + }, mounts[2]) +} + +// a plaintext stream produces no consumer config and no mounts. +func TestPlaintextStream_NoSecurityWiring(t *testing.T) { + assert.Nil(t, buildSecurityConfigs(pocStream, nil, "team-x", KafkaSecurity{})) + assert.Nil(t, buildSecurityConfigs(pocStream, &kafkamod.SecurityProfile{}, "team-x", KafkaSecurity{})) + assert.Nil(t, buildSecurityConfigs(pocStream, &kafkamod.SecurityProfile{SecurityProtocol: "PLAINTEXT"}, "team-x", KafkaSecurity{})) + + assert.Nil(t, buildACLMounts(pocStream, nil, "team-x")) + assert.Nil(t, buildACLMounts(pocStream, &kafkamod.SecurityProfile{SecurityProtocol: "PLAINTEXT"}, "team-x")) +} + +// brokers are populated from the resolved stream URL when not set, and the +// consumer config is injected into the env variables. +func TestApplyStreamSecurity_PopulatesBrokersAndConfig(t *testing.T) { + out := kafkamod.Output{URL: "broker-1:9098,broker-2:9098", Security: oauthbearerProfile()} + outJSON, err := json.Marshal(out) + require.NoError(t, err) + + exr := module.ExpandedResource{ + Dependencies: map[string]module.ResolvedDependency{ + pocStream: {Kind: kafkamod.Module.Kind, Output: outJSON}, + }, + } + conf := &Config{ + Team: "team-x", + StreamName: pocStream, + EnvVariables: map[string]string{}, + } + + require.NoError(t, (&firehoseDriver{}).applyStreamSecurity(context.Background(), exr, conf)) + + assert.Equal(t, "broker-1:9098,broker-2:9098", conf.EnvVariables[confKeyKafkaBrokers]) + assert.Equal(t, "SASL_SSL", conf.EnvVariables[keyConsumerSecurityProtocol]) + assert.Len(t, conf.ACLMounts, 3) +} + +// product (Dex) path: the security profile is inlined on conf.StreamSecurity +// with NO kafka dependency present, and the ACL wiring still fires. +func TestApplyStreamSecurity_InlineProfile_NoDependency(t *testing.T) { + conf := &Config{ + Team: "team-x", + StreamName: pocStream, + EnvVariables: map[string]string{}, + StreamSecurity: map[string]*kafkamod.SecurityProfile{ + pocStream: oauthbearerProfile(), + }, + } + + require.NoError(t, (&firehoseDriver{}).applyStreamSecurity(context.Background(), module.ExpandedResource{}, conf)) + + assert.Equal(t, "SASL_SSL", conf.EnvVariables[keyConsumerSecurityProtocol]) + assert.Equal(t, "OAUTHBEARER", conf.EnvVariables[keyConsumerSaslMechanism]) + assert.Len(t, conf.ACLMounts, 3) +} + +// the flag (sent as an env variable by Dex) makes the driver fetch the kafka +// resource by URN, and is stripped from the env variables afterwards. +func TestApplyStreamSecurity_FlagFetchesInternally(t *testing.T) { + out := kafkamod.Output{URL: "127.0.0.1:9098", Security: oauthbearerProfile()} + outJSON, err := json.Marshal(out) + require.NoError(t, err) + + var gotURN string + fd := &firehoseDriver{ + getResource: func(_ context.Context, urn string) (*resource.Resource, error) { + gotURN = urn + return &resource.Resource{State: resource.State{Output: outJSON}}, nil + }, + conf: driverConf{KafkaSecurity: KafkaSecurity{ServiceAccount: "aegis-kafka"}}, + } + + exr := module.ExpandedResource{Resource: resource.Resource{Project: "al-dp-id-s"}} + conf := &Config{ + Team: "team-x", + EnvVariables: map[string]string{ + keySourceKafkaName: pocStream, + keySourceKafkaSecurityEnabled: "true", + }, + } + + require.NoError(t, fd.applyStreamSecurity(context.Background(), exr, conf)) + + assert.Equal(t, resource.GenerateURN(kafkamod.Module.Kind, "al-dp-id-s", pocStream), gotURN) + assert.Equal(t, "127.0.0.1:9098", conf.EnvVariables[confKeyKafkaBrokers]) + assert.Equal(t, "SASL_SSL", conf.EnvVariables[keyConsumerSecurityProtocol]) + assert.Equal(t, "aegis-kafka", conf.ServiceAccount) + + // the transient flag must never reach the running firehose. + assert.NotContains(t, conf.EnvVariables, keySourceKafkaSecurityEnabled) +} + +// a plaintext firehose (no stream name, no stream_security, no dependency) is +// left untouched — env variables, mounts and service account unchanged. +func TestApplyStreamSecurity_PlaintextFirehose_NoWiring(t *testing.T) { + conf := &Config{ + Team: "team-x", + EnvVariables: map[string]string{ + confKeyKafkaBrokers: "localhost:9092", + confKeyKafkaTopic: "foo-log", + }, + } + + require.NoError(t, (&firehoseDriver{}).applyStreamSecurity(context.Background(), module.ExpandedResource{}, conf)) + + assert.Equal(t, map[string]string{ + confKeyKafkaBrokers: "localhost:9092", + confKeyKafkaTopic: "foo-log", + }, conf.EnvVariables) + assert.Nil(t, conf.ACLMounts) + assert.Empty(t, conf.ServiceAccount) +} + +// an explicit brokers value is not overwritten by the resolved stream URL. +func TestApplyStreamSecurity_KeepsExplicitBrokers(t *testing.T) { + out := kafkamod.Output{URL: "resolved:9098"} + outJSON, err := json.Marshal(out) + require.NoError(t, err) + + exr := module.ExpandedResource{ + Dependencies: map[string]module.ResolvedDependency{ + pocStream: {Kind: kafkamod.Module.Kind, Output: outJSON}, + }, + } + conf := &Config{ + StreamName: pocStream, + EnvVariables: map[string]string{confKeyKafkaBrokers: "explicit:9092"}, + } + + require.NoError(t, (&firehoseDriver{}).applyStreamSecurity(context.Background(), exr, conf)) + assert.Equal(t, "explicit:9092", conf.EnvVariables[confKeyKafkaBrokers]) +} + +// a stream that loses its security profile has the previously injected keys +// and mounts cleared instead of left behind. +func TestApplyStreamSecurity_ClearsStaleWiring(t *testing.T) { + conf := &Config{ + StreamName: pocStream, + EnvVariables: map[string]string{ + keyConsumerSecurityProtocol: "SASL_SSL", + keyConsumerSaslMechanism: "OAUTHBEARER", + confKeyKafkaTopic: "foo-log", + }, + ACLMounts: []ACLMount{{Name: "stale", MountPath: "/var/secrets/stale", Type: "secret"}}, + } + + require.NoError(t, (&firehoseDriver{}).applyStreamSecurity(context.Background(), module.ExpandedResource{}, conf)) + + assert.Equal(t, map[string]string{confKeyKafkaTopic: "foo-log"}, conf.EnvVariables) + assert.Nil(t, conf.ACLMounts) +} + +// PLAIN/SCRAM: the JAAS config references credentials through the literalfile +// provider — never inlining the secret values. +func TestBuildSecurityConfigs_PlainScram_NoInlinedSecrets(t *testing.T) { + sp := &kafkamod.SecurityProfile{ + SecurityProtocol: "SASL_SSL", + SaslMechanism: "SCRAM-SHA-512", + ACLs: map[string]kafkamod.ACLCredentialRef{ + "team-x": {SecretName: "team-x-creds", UsernameKey: "username", PasswordKey: "password"}, + }, + } + + got := buildSecurityConfigs(pocStream, sp, "team-x", KafkaSecurity{}) + jaas := got[keyConsumerSaslJaasConfig] + + assert.Contains(t, jaas, "ScramLoginModule") + assert.Contains(t, jaas, "${literalfile:/var/secrets/al-gp-id-s-central-kf/credentials/username:literal-value}") + assert.Contains(t, jaas, "${literalfile:/var/secrets/al-gp-id-s-central-kf/credentials/password:literal-value}") + assert.NotContains(t, jaas, "team-x-creds") + assert.Equal(t, "literalfile", got[keyConsumerConfigProviders]) + + mounts := buildACLMounts(pocStream, sp, "team-x") + require.Len(t, mounts, 2) + assert.Equal(t, ACLMount{ + Name: "al-gp-id-s-central-kf-team-x-creds", + MountPath: "/var/secrets/al-gp-id-s-central-kf/credentials", + SecretName: "team-x-creds", + Type: "secret", + }, mounts[0]) + assert.Equal(t, "projected", mounts[1].Type) +} diff --git a/modules/firehose/module.go b/modules/firehose/module.go index e01a11d8..7169ee4e 100644 --- a/modules/firehose/module.go +++ b/modules/firehose/module.go @@ -33,100 +33,106 @@ const ( var mu sync.Mutex -var Module = module.Descriptor{ - Kind: "firehose", - Dependencies: map[string]string{ - keyKubeDependency: kubernetes.Module.Kind, - }, - Actions: []module.ActionDesc{ - { - Name: module.CreateAction, - Description: "Creates a new firehose", +// Module builds the firehose module descriptor. getResource lets the driver +// fetch a referenced kafka stream's resource internally (by URN) to resolve its +// security profile, without declaring it as a dependency. +func Module(getResource ResourceGetter) module.Descriptor { + return module.Descriptor{ + Kind: "firehose", + Dependencies: map[string]string{ + keyKubeDependency: kubernetes.Module.Kind, }, - { - Name: module.UpdateAction, - Description: "Update all configurations of firehose", - }, - { - Name: ResetAction, - Description: "Stop firehose, reset consumer group, restart", - }, - { - Name: ResetV2Action, - Description: "Stop firehose, reset consumer group, restart with datetime option", - }, - { - Name: StopAction, - Description: "Stop all replicas of this firehose.", - }, - { - Name: StartAction, - Description: "Start the firehose if it is currently stopped.", - }, - { - Name: ScaleAction, - Description: "Scale the number of replicas to given number.", - }, - { - Name: UpgradeAction, - Description: "Upgrade firehose version", + Actions: []module.ActionDesc{ + { + Name: module.CreateAction, + Description: "Creates a new firehose", + }, + { + Name: module.UpdateAction, + Description: "Update all configurations of firehose", + }, + { + Name: ResetAction, + Description: "Stop firehose, reset consumer group, restart", + }, + { + Name: ResetV2Action, + Description: "Stop firehose, reset consumer group, restart with datetime option", + }, + { + Name: StopAction, + Description: "Stop all replicas of this firehose.", + }, + { + Name: StartAction, + Description: "Start the firehose if it is currently stopped.", + }, + { + Name: ScaleAction, + Description: "Scale the number of replicas to given number.", + }, + { + Name: UpgradeAction, + Description: "Upgrade firehose version", + }, }, - }, - DriverFactory: func(confJSON json.RawMessage) (module.Driver, error) { - mu.Lock() - defer mu.Unlock() - - conf := defaultDriverConf // clone the default value - if err := json.Unmarshal(confJSON, &conf); err != nil { - return nil, err - } else if err := validator.TaggedStruct(conf); err != nil { - return nil, err - } + DriverFactory: func(confJSON json.RawMessage) (module.Driver, error) { + mu.Lock() + defer mu.Unlock() + + conf := defaultDriverConf // clone the default value + if err := json.Unmarshal(confJSON, &conf); err != nil { + return nil, err + } else if err := validator.TaggedStruct(conf); err != nil { + return nil, err + } - return &firehoseDriver{ - conf: conf, - timeNow: time.Now, - kubeDeploy: func(_ context.Context, isCreate bool, kubeConf kube.Config, hc helm.ReleaseConfig) error { - canUpdate := func(rel *release.Release) bool { - curLabels, ok := rel.Config[labelsConfKey].(map[string]any) - if !ok { - return false - } - newLabels, ok := hc.Values[labelsConfKey].(map[string]string) - if !ok { - return false + return &firehoseDriver{ + conf: conf, + timeNow: time.Now, + kubeDeploy: func(_ context.Context, isCreate bool, kubeConf kube.Config, hc helm.ReleaseConfig) error { + canUpdate := func(rel *release.Release) bool { + curLabels, ok := rel.Config[labelsConfKey].(map[string]any) + if !ok { + return false + } + newLabels, ok := hc.Values[labelsConfKey].(map[string]string) + if !ok { + return false + } + + isManagedByEntropy := curLabels[labelOrchestrator] == orchestratorLabelValue + isSameDeployment := curLabels[labelDeployment] == newLabels[labelDeployment] + + return isManagedByEntropy && isSameDeployment } - isManagedByEntropy := curLabels[labelOrchestrator] == orchestratorLabelValue - isSameDeployment := curLabels[labelDeployment] == newLabels[labelDeployment] - - return isManagedByEntropy && isSameDeployment - } - - helmCl := helm.NewClient(&helm.Config{Kubernetes: kubeConf}) - _, errHelm := helmCl.Upsert(&hc, canUpdate) - return errHelm - }, - kubeGetPod: func(ctx context.Context, conf kube.Config, ns string, labels map[string]string) ([]kube.Pod, error) { - kubeCl, err := kube.NewClient(ctx, conf) - if err != nil { - return nil, errors.ErrInternal.WithMsgf("failed to create new kube client on firehose driver kube get pod").WithCausef("%s", err.Error()) - } - return kubeCl.GetPodDetails(ctx, ns, labels, func(pod v1.Pod) bool { - // allow pods that are in running state and are not marked for deletion - return pod.Status.Phase == v1.PodRunning && pod.DeletionTimestamp == nil - }) - }, - kubeGetDeployment: func(ctx context.Context, conf kube.Config, ns, name string) (kube.Deployment, error) { - kubeCl, err := kube.NewClient(ctx, conf) - if err != nil { - return kube.Deployment{}, errors.ErrInternal.WithMsgf("failed to create new kube client on firehose driver kube get deployment").WithCausef("%s", err.Error()) - } - return kubeCl.GetDeploymentDetails(ctx, ns, name) - }, - consumerReset: consumerReset, - }, nil - }, + helmCl := helm.NewClient(&helm.Config{Kubernetes: kubeConf}) + _, errHelm := helmCl.Upsert(&hc, canUpdate) + return errHelm + }, + kubeGetPod: func(ctx context.Context, conf kube.Config, ns string, labels map[string]string) ([]kube.Pod, error) { + kubeCl, err := kube.NewClient(ctx, conf) + if err != nil { + return nil, errors.ErrInternal.WithMsgf("failed to create new kube client on firehose driver kube get pod").WithCausef("%s", err.Error()) + } + return kubeCl.GetPodDetails(ctx, ns, labels, func(pod v1.Pod) bool { + // allow pods that are in running state and are not marked for deletion + return pod.Status.Phase == v1.PodRunning && pod.DeletionTimestamp == nil + }) + }, + kubeGetDeployment: func(ctx context.Context, conf kube.Config, ns, name string) (kube.Deployment, error) { + kubeCl, err := kube.NewClient(ctx, conf) + if err != nil { + return kube.Deployment{}, errors.ErrInternal.WithMsgf("failed to create new kube client on firehose driver kube get deployment").WithCausef("%s", err.Error()) + } + return kubeCl.GetDeploymentDetails(ctx, ns, name) + }, + consumerReset: consumerReset, + getResource: getResource, + }, nil + }, + } } func consumerReset(ctx context.Context, conf Config, out kubernetes.Output, resetTo string, offsetResetDelaySeconds int) error { diff --git a/modules/firehose/module_test.go b/modules/firehose/module_test.go index 9e57a670..96c16206 100644 --- a/modules/firehose/module_test.go +++ b/modules/firehose/module_test.go @@ -8,6 +8,8 @@ import ( ) func BenchmarkDriverFactory(b *testing.B) { + desc := Module(nil) + b.SetParallelism(10000) b.RunParallel(func(pb *testing.PB) { for pb.Next() { @@ -18,7 +20,7 @@ func BenchmarkDriverFactory(b *testing.B) { config := json.RawMessage(configFile) - _, _ = Module.DriverFactory(config) + _, _ = desc.DriverFactory(config) } }) } diff --git a/modules/firehose/schema/config.json b/modules/firehose/schema/config.json index 8ce5cdfd..ebb9b24e 100644 --- a/modules/firehose/schema/config.json +++ b/modules/firehose/schema/config.json @@ -19,6 +19,52 @@ "deployment_id": { "type": "string" }, + "team": { + "type": "string" + }, + "stream_name": { + "type": "string" + }, + "stream_security_enabled": { + "type": "boolean" + }, + "service_account": { + "type": "string" + }, + "stream_security": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": true, + "properties": { + "security_protocol": { "type": "string" }, + "sasl_mechanism": { "type": "string" }, + "ssl_protocol": { "type": "string" }, + "ssl_truststore_type": { "type": "string" }, + "ssl_cert_secret": { "type": "string" }, + "ssl_truststore_password_details": { + "type": "object", + "additionalProperties": true, + "properties": { + "secretName": { "type": "string" }, + "key": { "type": "string" } + } + }, + "acls": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": true, + "properties": { + "secretName": { "type": "string" }, + "usernameKey": { "type": "string" }, + "passwordKey": { "type": "string" } + } + } + } + } + } + }, "env_variables": { "type": "object", "additionalProperties": true, From 81ab58e6d0a3798be31f147f78be00685f5f88a2 Mon Sep 17 00:00:00 2001 From: "shruti.tiwari" Date: Tue, 11 Aug 2026 12:39:20 +0530 Subject: [PATCH 7/9] Updated the acl wirings --- docs/modules/firehose.md | 48 ++- modules/firehose/config.go | 18 +- modules/firehose/driver.go | 34 +- modules/firehose/kafka_security.go | 344 +++++++++--------- modules/firehose/kafka_security_test.go | 175 ++++----- modules/firehose/schema/config.json | 27 +- .../resource/firehose_acl_resource.json | 32 ++ .../resource/kafka_acl_resource.json | 36 ++ 8 files changed, 431 insertions(+), 283 deletions(-) create mode 100644 test/testbench/test_data/resource/firehose_acl_resource.json create mode 100644 test/testbench/test_data/resource/kafka_acl_resource.json diff --git a/docs/modules/firehose.md b/docs/modules/firehose.md index 639f5e3c..e20424f4 100644 --- a/docs/modules/firehose.md +++ b/docs/modules/firehose.md @@ -49,23 +49,47 @@ this order: (or `SOURCE_KAFKA_SECURITY_ENABLED=true` is passed as an env variable — it is stripped before the config reaches the running firehose). -From the resolved profile the module injects the `SOURCE_KAFKA_CONSUMER_CONFIG_*` env -variables (security protocol, SASL mechanism, JAAS config, truststore location/password) -and records the secret volumes in `acl_mounts`, which is rendered as the `acl_mounts` -chart value alongside `service_account`. Credentials are never inlined: username, password -and truststore password are referenced through the `literalfile` config provider pointing -at the mounted secrets. Plaintext firehoses are untouched — no injected config, no mounts, -no chart value changes. +The wiring mirrors odin's firehose adapter, so a migrated firehose renders the same pod +spec it does today. From the resolved profile the module injects the +`SOURCE_KAFKA_CONSUMER_CONFIG_*` env variables — security protocol, SASL mechanism, SSL +protocol, truststore type, and for a stream carrying certs a fixed +`SSL_TRUSTSTORE_LOCATION` of `/etc/secret/truststore.p12` (`.jks` for JKS) plus the +`SSL_TRUSTSTORE_FILENAME` the chart uses to select that key out of the secret. OAUTHBEARER +additionally gets the `OAuthBearerLoginModule` JAAS string and the pod login callback +handler class. + +No secret value ever passes through entropy. The material is described as references in the +`acl` config, rendered as the `kafka_security` chart value alongside `service_account`: + +| field | what the chart does with it | +| :--- | :--- | +| `ssl_config_credential` + `truststore_filename` | mounts that existing secret at `/etc/secret`, selecting the filename as both key and path | +| `truststore_password` (`secretName` + `key`) | renders `SOURCE_KAFKA_CONSUMER_CONFIG_SSL_TRUSTSTORE_PASSWORD` as a `secretKeyRef` env var | +| `jaas_config_credential` | mounts the PLAIN/SCRAM `jaas.conf` secret at `/etc/secret/kafka` | +| `kafka_token_enabled` | adds the projected service-account token (`audience: kafka`) at `/var/run/secrets/kafka/serviceaccount` | + +PLAIN/SCRAM streams never inline credentials or a JAAS string: they read a mounted +`jaas.conf`, and the module appends +`-Djava.security.auth.login.config=/etc/secret/kafka/jaas.conf` to `_JAVA_OPTIONS` (the +rest of that variable is left alone). The secret is the profile's `acls[team].secretName` +when set, otherwise odin's `--jaas` convention. + +Plaintext firehoses are untouched — no injected config, no `kafka_security` value, no +chart value changes. -The provider/callback classes and the default service account are deployment level -settings under the module's driver config: +The callback handler class and default service account are deployment level settings under +the module's driver config: ```json { "kafka_security": { - "config_provider_class": "com.example.kafka.configproviders.LiteralFileConfigProvider", - "sasl_login_callback_handler_class": "com.example.kafka.security.PodLoginCallbackHandler", + "sasl_login_callback_handler_class": "io.gtflabs.kafka.security.oauthbearer.kubernetes.PodLoginCallbackHandler", "service_account": "aegis-kafka" } } -``` \ No newline at end of file +``` + +> The published `firehose` chart (0.2.0) does not yet render any of this: its `mountSecrets` +> builds a *new* secret from inline values, and it has no `serviceAccountName`, projected +> volume or `secretKeyRef` support. Those four template blocks — ports of odin's +> `manifests/firehose.yaml` — are needed before an ACL firehose can run. \ No newline at end of file diff --git a/modules/firehose/config.go b/modules/firehose/config.go index b361f71f..884576e9 100644 --- a/modules/firehose/config.go +++ b/modules/firehose/config.go @@ -88,10 +88,11 @@ type Config struct { // stream name. References only — never inline secret values. StreamSecurity map[string]*kafkamod.SecurityProfile `json:"stream_security,omitempty"` - // ACLMounts is the set of secret / projected-token volume mounts required by - // an ACL (SASL_SSL/OAUTHBEARER, SSL, PLAIN/SCRAM) source stream. It is - // computed by applyStreamSecurity from the resolved stream security profile. - ACLMounts []ACLMount `json:"acl_mounts,omitempty"` + // ACL describes the secret material an ACL (SASL_SSL/OAUTHBEARER, SSL, + // PLAIN/SCRAM) source stream needs mounted. It is computed by + // applyStreamSecurity from the resolved stream security profile and holds + // references only — never secret values. + ACL *ACLConfig `json:"acl,omitempty"` // ServiceAccount, when set, becomes the pod's service account. It is the // OAuth identity authorized for ACL streams. Empty preserves the chart's @@ -99,15 +100,6 @@ type Config struct { ServiceAccount string `json:"service_account,omitempty"` } -// ACLMount describes a single pod volume+mount for an ACL stream. -// Type is either "secret" or "projected". -type ACLMount struct { - Name string `json:"name"` - MountPath string `json:"mountPath"` - SecretName string `json:"secretName,omitempty"` - Type string `json:"type"` -} - // streamName is the kafka resource name backing this firehose, if any. func (cfg *Config) streamName() string { if cfg.StreamName != "" { diff --git a/modules/firehose/driver.go b/modules/firehose/driver.go index 45f63034..62f8a144 100644 --- a/modules/firehose/driver.go +++ b/modules/firehose/driver.go @@ -405,19 +405,29 @@ func (fd *firehoseDriver) getHelmRelease(res resource.Resource, conf Config, "mountSecrets": mountSecrets, } - // ACL (SASL/SSL) source support. Both keys are omitted for plaintext - // firehoses, which keeps their rendered chart values unchanged. - if len(conf.ACLMounts) > 0 { - aclMounts := make([]map[string]any, 0, len(conf.ACLMounts)) - for _, m := range conf.ACLMounts { - aclMounts = append(aclMounts, map[string]any{ - "name": m.Name, - "mountPath": m.MountPath, - "secretName": m.SecretName, - "type": m.Type, - }) + // ACL (SASL/SSL) source support, mirroring odin's firehose manifest: the + // chart mounts the referenced secrets and renders the truststore password as + // a secretKeyRef. Both keys are omitted for plaintext firehoses, which keeps + // their rendered chart values unchanged. + if conf.ACL != nil { + aclValues := map[string]any{} + if conf.ACL.SSLConfigCredential != "" { + aclValues["ssl_config_credential"] = conf.ACL.SSLConfigCredential + aclValues["truststore_filename"] = conf.ACL.TruststoreFilename } - rc.Values["acl_mounts"] = aclMounts + if conf.ACL.TruststorePassword != nil { + aclValues["truststore_password"] = map[string]any{ + "secretName": conf.ACL.TruststorePassword.SecretName, + "key": conf.ACL.TruststorePassword.Key, + } + } + if conf.ACL.JaasConfigCredential != "" { + aclValues["jaas_config_credential"] = conf.ACL.JaasConfigCredential + } + if conf.ACL.KafkaTokenEnabled { + aclValues["kafka_token_enabled"] = true + } + rc.Values["kafka_security"] = aclValues } if conf.ServiceAccount != "" { diff --git a/modules/firehose/kafka_security.go b/modules/firehose/kafka_security.go index 5e0d684b..a1d3033d 100644 --- a/modules/firehose/kafka_security.go +++ b/modules/firehose/kafka_security.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "regexp" "strconv" "strings" @@ -15,19 +14,32 @@ import ( // SASL/SSL consumer config keys. Firehose passes every // SOURCE_KAFKA_CONSUMER_CONFIG_* env variable straight to the kafka consumer, -// so unlike dagger — where these live inside the STREAMS json — they are flat -// env variables here. +// so they are flat env variables here. The key set and the values mirror odin's +// firehose adapter (app/firehose/adapter.js on gtf-master), which is the +// behaviour being migrated onto entropy. const ( - keyConsumerSecurityProtocol = "SOURCE_KAFKA_CONSUMER_CONFIG_SECURITY_PROTOCOL" - keyConsumerSaslMechanism = "SOURCE_KAFKA_CONSUMER_CONFIG_SASL_MECHANISM" - keyConsumerSaslJaasConfig = "SOURCE_KAFKA_CONSUMER_CONFIG_SASL_JAAS_CONFIG" - keyConsumerSaslLoginCallbackHandler = "SOURCE_KAFKA_CONSUMER_CONFIG_SASL_LOGIN_CALLBACK_HANDLER_CLASS" - keyConsumerSSLProtocol = "SOURCE_KAFKA_CONSUMER_CONFIG_SSL_PROTOCOL" - keyConsumerSSLTruststoreType = "SOURCE_KAFKA_CONSUMER_CONFIG_SSL_TRUSTSTORE_TYPE" - keyConsumerSSLTruststoreLocation = "SOURCE_KAFKA_CONSUMER_CONFIG_SSL_TRUSTSTORE_LOCATION" - keyConsumerSSLTruststorePassword = "SOURCE_KAFKA_CONSUMER_CONFIG_SSL_TRUSTSTORE_PASSWORD" - keyConsumerConfigProviders = "SOURCE_KAFKA_CONSUMER_CONFIG_CONFIG_PROVIDERS" - keyConsumerConfigProvidersLiteralClass = "SOURCE_KAFKA_CONSUMER_CONFIG_CONFIG_PROVIDERS_LITERALFILE_CLASS" + keyConsumerSecurityProtocol = "SOURCE_KAFKA_CONSUMER_CONFIG_SECURITY_PROTOCOL" + keyConsumerSaslMechanism = "SOURCE_KAFKA_CONSUMER_CONFIG_SASL_MECHANISM" + keyConsumerSaslJaasConfig = "SOURCE_KAFKA_CONSUMER_CONFIG_SASL_JAAS_CONFIG" + keyConsumerSaslLoginCallbackHandler = "SOURCE_KAFKA_CONSUMER_CONFIG_SASL_LOGIN_CALLBACK_HANDLER_CLASS" + keyConsumerSSLProtocol = "SOURCE_KAFKA_CONSUMER_CONFIG_SSL_PROTOCOL" + keyConsumerSSLTruststoreType = "SOURCE_KAFKA_CONSUMER_CONFIG_SSL_TRUSTSTORE_TYPE" + keyConsumerSSLTruststoreLocation = "SOURCE_KAFKA_CONSUMER_CONFIG_SSL_TRUSTSTORE_LOCATION" + keyConsumerSSLTruststoreFilename = "SOURCE_KAFKA_CONSUMER_CONFIG_SSL_TRUSTSTORE_FILENAME" + + // keyJavaOptions carries the JAAS file location for SCRAM/PLAIN streams. + // It is user-owned, so only the JAAS option itself is added or removed. + keyJavaOptions = "_JAVA_OPTIONS" +) + +// legacy keys from the config-provider approach. They are no longer emitted but +// are still swept, so resources planned by an older build do not keep a +// dangling provider reference. +const ( + keyConsumerSSLTruststorePassword = "SOURCE_KAFKA_CONSUMER_CONFIG_SSL_TRUSTSTORE_PASSWORD" + keyConsumerConfigProviders = "SOURCE_KAFKA_CONSUMER_CONFIG_CONFIG_PROVIDERS" + keyConsumerConfigProviderClassPrefix = "SOURCE_KAFKA_CONSUMER_CONFIG_CONFIG_PROVIDERS_" + keyConsumerConfigProviderClassSuffix = "_CLASS" ) // transient inputs Dex may send as env variables. They describe the stream to @@ -39,7 +51,7 @@ const ( // managedSecurityKeys are owned by this module: they are wiped and rebuilt on // every plan of a firehose that names a kafka stream, so a stream that loses -// its ACLs does not leave stale credentials behind. +// its ACLs does not leave stale configuration behind. var managedSecurityKeys = []string{ keyConsumerSecurityProtocol, keyConsumerSaslMechanism, @@ -48,23 +60,18 @@ var managedSecurityKeys = []string{ keyConsumerSSLProtocol, keyConsumerSSLTruststoreType, keyConsumerSSLTruststoreLocation, + keyConsumerSSLTruststoreFilename, keyConsumerSSLTruststorePassword, keyConsumerConfigProviders, - keyConsumerConfigProvidersLiteralClass, } -// GTF Kafka security constants, same values the dagger module injects. const ( - oauthConsumerSaslJaasConfig = "org.apache.kafka.common.security.oauthbearer.OAuthBearerLoginModule required;" - scramLoginModule = "org.apache.kafka.common.security.scram.ScramLoginModule" - plainLoginModule = "org.apache.kafka.common.security.plain.PlainLoginModule" - literalFileConfigProviderName = "literalfile" - - // defaults for the classes shipped by the platform kafka-security library. - // Override per deployment via driver config `kafka_security` when the - // firehose image packages them under different names. + oauthConsumerSaslJaasConfig = "org.apache.kafka.common.security.oauthbearer.OAuthBearerLoginModule required;" + + // the OAUTHBEARER login callback handler shipped by the platform kafka + // security library. Override per deployment when the firehose image + // packages it under a different name. defaultOauthSaslLoginCallbackHandlerClass = "io.gtflabs.kafka.security.oauthbearer.kubernetes.PodLoginCallbackHandler" - defaultLiteralFileConfigProviderClass = "com.gtf.dagger.kafka.configproviders.LiteralFileConfigProvider" ) const ( @@ -77,24 +84,22 @@ const ( truststoreTypePKCS12 = "PKCS12" ) -// mount path templates, kept consistent between the injected consumer config -// and the pod volume mounts, and identical to the dagger module's layout. +// Mount layout, matching odin's firehose manifest. The chart mounts the stream's +// cert secret at /etc/secret and the JAAS secret at /etc/secret/kafka; the +// projected kafka service-account token lands at kafkaTokenMountPath. const ( - kafkaTokenVolumeName = "kafka-token" - kafkaTokenMountPath = "/var/run/secrets/kafka/serviceaccount" - certsMountPathFmt = "/var/secrets/%s/certs" - passwordsMountPathFmt = "/var/secrets/%s/passwords" - credentialsMountPathFmt = "/var/secrets/%s/credentials" + secretMountPath = "/etc/secret" + jaasSecretMountPath = secretMountPath + "/kafka" + jaasConfigFileName = "jaas.conf" + jaasConfigJavaOpt = "-Djava.security.auth.login.config=" + jaasSecretMountPath + "/" + jaasConfigFileName + truststoreFileBase = "truststore" + jaasSecretSuffix = "jaas" + + kafkaTokenMountPath = "/var/run/secrets/kafka/serviceaccount" ) -var invalidVolumeNameChars = regexp.MustCompile(`[^a-zA-Z0-9]+`) - // KafkaSecurity carries the deployment level knobs for ACL streams. type KafkaSecurity struct { - // ConfigProviderClass reads secret material off the mounted volumes for the - // ${literalfile:...} references in the injected consumer config. - ConfigProviderClass string `json:"config_provider_class,omitempty"` - // SaslLoginCallbackHandlerClass is the OAUTHBEARER login callback handler // that exchanges the projected service-account token for a kafka token. SaslLoginCallbackHandlerClass string `json:"sasl_login_callback_handler_class,omitempty"` @@ -106,9 +111,6 @@ type KafkaSecurity struct { } func (k KafkaSecurity) withDefaults() KafkaSecurity { - if k.ConfigProviderClass == "" { - k.ConfigProviderClass = defaultLiteralFileConfigProviderClass - } if k.SaslLoginCallbackHandlerClass == "" { k.SaslLoginCallbackHandlerClass = defaultOauthSaslLoginCallbackHandlerClass } @@ -132,8 +134,11 @@ func isPlainOrScramStream(sp *kafkamod.SecurityProfile) bool { return protoOK && mechOK } -func isTLSStream(sp *kafkamod.SecurityProfile) bool { - return sp != nil && sp.SecurityProtocol == securityProtocolSSL +// usesSSLMaterial reports whether the stream presents a truststore. odin keys +// this off the security protocol containing "SSL", covering both SSL and +// SASL_SSL. +func usesSSLMaterial(sp *kafkamod.SecurityProfile) bool { + return sp != nil && strings.Contains(sp.SecurityProtocol, securityProtocolSSL) } // hasSecurityProfile reports whether the profile requires any SASL/SSL wiring. @@ -141,23 +146,23 @@ func hasSecurityProfile(sp *kafkamod.SecurityProfile) bool { return sp != nil && sp.SecurityProtocol != "" && !strings.EqualFold(sp.SecurityProtocol, "PLAINTEXT") } -func truststoreExtension(truststoreType string) string { +// truststoreFileName is the file name the truststore is projected as, and also +// the key it is read from inside the cert secret. +func truststoreFileName(truststoreType string) string { if strings.EqualFold(truststoreType, truststoreTypePKCS12) { - return "p12" + return truststoreFileBase + ".p12" } - return "jks" + return truststoreFileBase + ".jks" } // buildSecurityConfigs builds the SOURCE_KAFKA_CONSUMER_CONFIG_* env variables -// for the source stream, branching on its security profile. streamName is the -// stable per-stream directory name used both here and in the volume mounts. -// Returns nil for plaintext streams so env variables stay unchanged. +// for the source stream. Returns nil for plaintext streams so env variables +// stay unchanged. // -// Unlike dagger, the mount descriptors (SSL_CERT_SECRET and -// SSL_TRUSTSTORE_PASSWORD_DETAILS) are not injected: firehose hands these keys -// to the kafka consumer verbatim, so the secret references travel through -// Config.ACLMounts (the chart values) instead. -func buildSecurityConfigs(streamName string, sp *kafkamod.SecurityProfile, team string, sec KafkaSecurity) map[string]string { +// No secret value is ever placed here: the truststore password reaches the +// container as a secretKeyRef env var and the SCRAM credentials as a mounted +// jaas.conf, both described by the ACLConfig chart values. +func buildSecurityConfigs(sp *kafkamod.SecurityProfile, sec KafkaSecurity) map[string]string { if !hasSecurityProfile(sp) { return nil } @@ -169,8 +174,7 @@ func buildSecurityConfigs(streamName string, sp *kafkamod.SecurityProfile, team cfg[keyConsumerSaslMechanism] = sp.SaslMechanism } - // SSL material is shared by the TLS and OAUTHBEARER paths. - if isTLSStream(sp) || isOauthbearerStream(sp) { + if usesSSLMaterial(sp) { if sp.SSLProtocol != "" { cfg[keyConsumerSSLProtocol] = sp.SSLProtocol } @@ -178,136 +182,106 @@ func buildSecurityConfigs(streamName string, sp *kafkamod.SecurityProfile, team cfg[keyConsumerSSLTruststoreType] = sp.SSLTruststoreType } if sp.SSLCertSecret != "" { - cfg[keyConsumerSSLTruststoreLocation] = fmt.Sprintf( - "/var/secrets/%s/certs/truststore.%s", streamName, truststoreExtension(sp.SSLTruststoreType)) - } - if sp.SSLTruststorePasswordDetails != nil { - cfg[keyConsumerSSLTruststorePassword] = fmt.Sprintf( - "${literalfile:/var/secrets/%s/passwords/%s:literal-value}", - streamName, sp.SSLTruststorePasswordDetails.Key) - cfg[keyConsumerConfigProviders] = literalFileConfigProviderName - cfg[keyConsumerConfigProvidersLiteralClass] = sec.ConfigProviderClass + fileName := truststoreFileName(sp.SSLTruststoreType) + cfg[keyConsumerSSLTruststoreLocation] = secretMountPath + "/" + fileName + // the chart selects this key out of the cert secret and projects it + // under the same name. + cfg[keyConsumerSSLTruststoreFilename] = fileName } } - if isOauthbearerStream(sp) { - cfg[keyConsumerSaslLoginCallbackHandler] = sec.SaslLoginCallbackHandlerClass + // OAUTHBEARER authenticates with the projected service-account token, so the + // JAAS config is a fixed module string rather than credentials. + if sp.SaslMechanism == saslMechanismOauthbearer { cfg[keyConsumerSaslJaasConfig] = oauthConsumerSaslJaasConfig - } - - if isPlainOrScramStream(sp) { - cfg[keyConsumerSaslJaasConfig] = buildSASLJaasConfig(streamName, sp, team) - // credentials are referenced via the literalfile provider (never inlined). - if _, ok := sp.ACLs[team]; ok { - cfg[keyConsumerConfigProviders] = literalFileConfigProviderName - cfg[keyConsumerConfigProvidersLiteralClass] = sec.ConfigProviderClass - } + cfg[keyConsumerSaslLoginCallbackHandler] = sec.SaslLoginCallbackHandlerClass } return cfg } -// buildSASLJaasConfig builds the JAAS config string for PLAIN/SCRAM mechanisms. -// Credentials are referenced through the literalfile config provider pointing -// at the mounted secret, never inlined. -func buildSASLJaasConfig(streamName string, sp *kafkamod.SecurityProfile, team string) string { - loginModule := scramLoginModule - if sp.SaslMechanism == saslMechanismPlain { - loginModule = plainLoginModule - } - - cred, ok := sp.ACLs[team] - if !ok || cred.SecretName == "" { - return fmt.Sprintf("%s required;", loginModule) - } - - userRef := fmt.Sprintf("${literalfile:/var/secrets/%s/credentials/%s:literal-value}", streamName, cred.UsernameKey) - passRef := fmt.Sprintf("${literalfile:/var/secrets/%s/credentials/%s:literal-value}", streamName, cred.PasswordKey) - return fmt.Sprintf("%s required username=%q password=%q;", loginModule, userRef, passRef) +// ACLConfig is the chart-facing description of a stream's security material. +// Every field is a reference to a secret that already exists in the target +// namespace — no secret value passes through entropy. +type ACLConfig struct { + // SSLConfigCredential is the secret holding the truststore. The chart mounts + // it at /etc/secret, selecting TruststoreFilename as both key and path. + SSLConfigCredential string `json:"ssl_config_credential,omitempty"` + TruststoreFilename string `json:"truststore_filename,omitempty"` + + // TruststorePassword is rendered by the chart as a secretKeyRef env var for + // SOURCE_KAFKA_CONSUMER_CONFIG_SSL_TRUSTSTORE_PASSWORD. + TruststorePassword *SecretKeyRef `json:"truststore_password,omitempty"` + + // JaasConfigCredential is the secret holding jaas.conf for PLAIN/SCRAM + // streams, mounted at /etc/secret/kafka. + JaasConfigCredential string `json:"jaas_config_credential,omitempty"` + + // KafkaTokenEnabled requests the projected kafka service-account token + // (audience "kafka") that OAUTHBEARER authenticates with. + KafkaTokenEnabled bool `json:"kafka_token_enabled,omitempty"` } -// sanitizeVolumeName renders a k8s-safe (<=63 char, lowercase alnum/dash) volume name. -func sanitizeVolumeName(name string) string { - sanitized := invalidVolumeNameChars.ReplaceAllString(name, "-") - sanitized = strings.ToLower(strings.Trim(sanitized, "-")) - if len(sanitized) > 63 { - sanitized = strings.Trim(sanitized[:63], "-") - } - return sanitized +// SecretKeyRef references a single key inside an existing secret. +type SecretKeyRef struct { + SecretName string `json:"secretName"` + Key string `json:"key"` } -// buildACLMounts derives the pod volume mounts required by the source stream's -// security profile. Returns nil when the stream needs no ACL mounts so the pod -// spec is unchanged for plaintext firehoses. -func buildACLMounts(streamName string, sp *kafkamod.SecurityProfile, team string) []ACLMount { +// buildACLConfig derives the chart values for the stream's security material. +// Returns nil when the stream needs none, so the rendered release is unchanged +// for plaintext firehoses. +func buildACLConfig(streamName string, sp *kafkamod.SecurityProfile, team string) *ACLConfig { if !hasSecurityProfile(sp) { return nil } - var mounts []ACLMount - - if isOauthbearerStream(sp) { - mounts = append(mounts, ACLMount{ - Name: sanitizeVolumeName(streamName + "-" + sp.SSLCertSecret), - MountPath: fmt.Sprintf(certsMountPathFmt, streamName), - SecretName: sp.SSLCertSecret, - Type: "secret", - }) - mounts = append(mounts, ACLMount{ - Name: sanitizeVolumeName(streamName + "-" + sp.SSLTruststorePasswordDetails.SecretName), - MountPath: fmt.Sprintf(passwordsMountPathFmt, streamName), - SecretName: sp.SSLTruststorePasswordDetails.SecretName, - Type: "secret", - }) - } else if isTLSStream(sp) && sp.SSLCertSecret != "" { - mounts = append(mounts, ACLMount{ - Name: sanitizeVolumeName(streamName + "-" + sp.SSLCertSecret), - MountPath: fmt.Sprintf(certsMountPathFmt, streamName), - SecretName: sp.SSLCertSecret, - Type: "secret", - }) + acl := &ACLConfig{} + + if usesSSLMaterial(sp) && sp.SSLCertSecret != "" { + acl.SSLConfigCredential = sp.SSLCertSecret + acl.TruststoreFilename = truststoreFileName(sp.SSLTruststoreType) if sp.SSLTruststorePasswordDetails != nil && sp.SSLTruststorePasswordDetails.SecretName != "" { - mounts = append(mounts, ACLMount{ - Name: sanitizeVolumeName(streamName + "-" + sp.SSLTruststorePasswordDetails.SecretName), - MountPath: fmt.Sprintf(passwordsMountPathFmt, streamName), + acl.TruststorePassword = &SecretKeyRef{ SecretName: sp.SSLTruststorePasswordDetails.SecretName, - Type: "secret", - }) + Key: sp.SSLTruststorePasswordDetails.Key, + } } } - // PLAIN/SCRAM: mount the team's referenced credential secret so the - // literalfile provider can read username/password without inlining them. + if sp.SaslMechanism == saslMechanismOauthbearer { + acl.KafkaTokenEnabled = true + } + + // PLAIN/SCRAM read their credentials from a jaas.conf in a secret. The + // profile names it explicitly when known; otherwise fall back to odin's + // --jaas convention. if isPlainOrScramStream(sp) { - if cred, ok := sp.ACLs[team]; ok && cred.SecretName != "" { - mounts = append(mounts, ACLMount{ - Name: sanitizeVolumeName(streamName + "-" + cred.SecretName), - MountPath: fmt.Sprintf(credentialsMountPathFmt, streamName), - SecretName: cred.SecretName, - Type: "secret", - }) + if secretName := jaasSecretName(streamName, sp, team); secretName != "" { + acl.JaasConfigCredential = secretName } } - if len(mounts) == 0 { + if *acl == (ACLConfig{}) { return nil } + return acl +} - // single shared projected kafka service-account token. - mounts = append(mounts, ACLMount{ - Name: kafkaTokenVolumeName, - MountPath: kafkaTokenMountPath, - Type: "projected", - }) - - return mounts +func jaasSecretName(streamName string, sp *kafkamod.SecurityProfile, team string) string { + if cred, ok := sp.ACLs[team]; ok && cred.SecretName != "" { + return cred.SecretName + } + if team == "" || streamName == "" { + return "" + } + return strings.ReplaceAll(strings.Join([]string{team, streamName, jaasSecretSuffix}, "-"), "_", "-") } // applyStreamSecurity resolves the source stream's kafka security profile, -// injects the SASL/SSL consumer config into the env variables, and records the -// pod ACL mounts on conf. It is a no-op (leaves conf untouched) for firehoses -// that do not name a kafka stream, and clears the wiring for streams that no -// longer carry a security profile. +// injects the consumer config into the env variables, and records the chart's +// ACL values on conf. It is a no-op for firehoses that do not name a kafka +// stream, and clears the wiring for streams that no longer carry a profile. func (fd *firehoseDriver) applyStreamSecurity(ctx context.Context, exr module.ExpandedResource, conf *Config) error { // the flag may arrive as an env variable (Dex); it is transient and must // never reach the running firehose. @@ -328,13 +302,8 @@ func (fd *firehoseDriver) applyStreamSecurity(ctx context.Context, exr module.Ex if conf.EnvVariables == nil { conf.EnvVariables = map[string]string{} } - - // this module owns the SASL/SSL keys for a stream-backed firehose: drop - // whatever a previous plan injected before re-resolving. - for _, key := range managedSecurityKeys { - delete(conf.EnvVariables, key) - } - conf.ACLMounts = nil + clearManagedSecurityConfigs(conf.EnvVariables) + conf.ACL = nil security, err := fd.resolveStreamSecurity(ctx, exr, conf, streamName) if err != nil { @@ -344,10 +313,15 @@ func (fd *firehoseDriver) applyStreamSecurity(ctx context.Context, exr module.Ex return nil } - for key, val := range buildSecurityConfigs(streamName, security, conf.Team, fd.conf.KafkaSecurity) { + for key, val := range buildSecurityConfigs(security, fd.conf.KafkaSecurity) { conf.EnvVariables[key] = val } - conf.ACLMounts = buildACLMounts(streamName, security, conf.Team) + conf.ACL = buildACLConfig(streamName, security, conf.Team) + + // PLAIN/SCRAM point the JVM at the mounted jaas.conf. + if conf.ACL != nil && conf.ACL.JaasConfigCredential != "" { + conf.EnvVariables[keyJavaOptions] = withJaasJavaOption(conf.EnvVariables[keyJavaOptions]) + } if conf.ServiceAccount == "" { conf.ServiceAccount = fd.conf.KafkaSecurity.ServiceAccount @@ -356,6 +330,50 @@ func (fd *firehoseDriver) applyStreamSecurity(ctx context.Context, exr module.Ex return nil } +// clearManagedSecurityConfigs drops everything a previous plan injected: this +// module owns the SASL/SSL keys for a stream-backed firehose. +func clearManagedSecurityConfigs(env map[string]string) { + for _, key := range managedSecurityKeys { + delete(env, key) + } + // provider class keys are named after the provider, so sweep by shape. + for key := range env { + if strings.HasPrefix(key, keyConsumerConfigProviderClassPrefix) && + strings.HasSuffix(key, keyConsumerConfigProviderClassSuffix) { + delete(env, key) + } + } + if opts := withoutJaasJavaOption(env[keyJavaOptions]); opts != "" { + env[keyJavaOptions] = opts + } else if _, ok := env[keyJavaOptions]; ok { + env[keyJavaOptions] = "" + } +} + +// withJaasJavaOption appends the JAAS location option, keeping the rest of the +// user's _JAVA_OPTIONS and never duplicating the option. +func withJaasJavaOption(opts string) string { + opts = withoutJaasJavaOption(opts) + if opts == "" { + return jaasConfigJavaOpt + } + return opts + " " + jaasConfigJavaOpt +} + +func withoutJaasJavaOption(opts string) string { + if !strings.Contains(opts, jaasConfigJavaOpt) { + return opts + } + fields := strings.Fields(opts) + kept := fields[:0] + for _, f := range fields { + if f != jaasConfigJavaOpt { + kept = append(kept, f) + } + } + return strings.Join(kept, " ") +} + // resolveStreamSecurity resolves the source stream's kafka security profile. // // Resolution order: an inline conf.StreamSecurity entry, then a declared kafka diff --git a/modules/firehose/kafka_security_test.go b/modules/firehose/kafka_security_test.go index f6ed7525..c620bfeb 100644 --- a/modules/firehose/kafka_security_test.go +++ b/modules/firehose/kafka_security_test.go @@ -29,69 +29,66 @@ func oauthbearerProfile() *kafkamod.SecurityProfile { } } -// the injected consumer config matches the reference fixture. Unlike dagger, -// the mount descriptors are not part of it — they travel through ACLMounts. -func TestBuildSecurityConfigs_OAUTHBEARER_MatchesFixture(t *testing.T) { - got := buildSecurityConfigs(pocStream, oauthbearerProfile(), "team-x", KafkaSecurity{}) +// the injected consumer config matches odin's: fixed /etc/secret truststore +// location plus the filename the chart selects out of the cert secret, and no +// password (it arrives as a secretKeyRef env var). +func TestBuildSecurityConfigs_OAUTHBEARER_MatchesOdin(t *testing.T) { + got := buildSecurityConfigs(oauthbearerProfile(), KafkaSecurity{}) want := map[string]string{ - "SOURCE_KAFKA_CONSUMER_CONFIG_CONFIG_PROVIDERS": "literalfile", - "SOURCE_KAFKA_CONSUMER_CONFIG_CONFIG_PROVIDERS_LITERALFILE_CLASS": defaultLiteralFileConfigProviderClass, - "SOURCE_KAFKA_CONSUMER_CONFIG_SASL_JAAS_CONFIG": "org.apache.kafka.common.security.oauthbearer.OAuthBearerLoginModule required;", - "SOURCE_KAFKA_CONSUMER_CONFIG_SASL_LOGIN_CALLBACK_HANDLER_CLASS": defaultOauthSaslLoginCallbackHandlerClass, - "SOURCE_KAFKA_CONSUMER_CONFIG_SASL_MECHANISM": "OAUTHBEARER", - "SOURCE_KAFKA_CONSUMER_CONFIG_SECURITY_PROTOCOL": "SASL_SSL", - "SOURCE_KAFKA_CONSUMER_CONFIG_SSL_PROTOCOL": "SSL", - "SOURCE_KAFKA_CONSUMER_CONFIG_SSL_TRUSTSTORE_LOCATION": "/var/secrets/al-gp-id-s-central-kf/certs/truststore.p12", - "SOURCE_KAFKA_CONSUMER_CONFIG_SSL_TRUSTSTORE_PASSWORD": "${literalfile:/var/secrets/al-gp-id-s-central-kf/passwords/truststore_password:literal-value}", - "SOURCE_KAFKA_CONSUMER_CONFIG_SSL_TRUSTSTORE_TYPE": "PKCS12", + "SOURCE_KAFKA_CONSUMER_CONFIG_SECURITY_PROTOCOL": "SASL_SSL", + "SOURCE_KAFKA_CONSUMER_CONFIG_SASL_MECHANISM": "OAUTHBEARER", + "SOURCE_KAFKA_CONSUMER_CONFIG_SASL_JAAS_CONFIG": "org.apache.kafka.common.security.oauthbearer.OAuthBearerLoginModule required;", + "SOURCE_KAFKA_CONSUMER_CONFIG_SASL_LOGIN_CALLBACK_HANDLER_CLASS": defaultOauthSaslLoginCallbackHandlerClass, + "SOURCE_KAFKA_CONSUMER_CONFIG_SSL_PROTOCOL": "SSL", + "SOURCE_KAFKA_CONSUMER_CONFIG_SSL_TRUSTSTORE_TYPE": "PKCS12", + "SOURCE_KAFKA_CONSUMER_CONFIG_SSL_TRUSTSTORE_LOCATION": "/etc/secret/truststore.p12", + "SOURCE_KAFKA_CONSUMER_CONFIG_SSL_TRUSTSTORE_FILENAME": "truststore.p12", } assert.Equal(t, want, got) } -// the provider / callback handler classes are overridable per deployment. -func TestBuildSecurityConfigs_OverridesClasses(t *testing.T) { - got := buildSecurityConfigs(pocStream, oauthbearerProfile(), "team-x", KafkaSecurity{ - ConfigProviderClass: "com.example.LiteralFileConfigProvider", - SaslLoginCallbackHandlerClass: "com.example.PodLoginCallbackHandler", - }) +// no secret value is ever injected into the config. +func TestBuildSecurityConfigs_NeverInlinesSecrets(t *testing.T) { + got := buildSecurityConfigs(oauthbearerProfile(), KafkaSecurity{}) - assert.Equal(t, "com.example.LiteralFileConfigProvider", got[keyConsumerConfigProvidersLiteralClass]) - assert.Equal(t, "com.example.PodLoginCallbackHandler", got[keyConsumerSaslLoginCallbackHandler]) + for key, val := range got { + assert.NotContains(t, val, "scp-kafka-ssl-secrets", "secret name leaked into %s", key) + assert.NotContains(t, val, "truststore_password", "password key leaked into %s", key) + } +} + +// JKS streams get the .jks extension in both the location and the filename. +func TestBuildSecurityConfigs_JKSTruststore(t *testing.T) { + sp := oauthbearerProfile() + sp.SSLTruststoreType = "JKS" + + got := buildSecurityConfigs(sp, KafkaSecurity{}) + + assert.Equal(t, "/etc/secret/truststore.jks", got[keyConsumerSSLTruststoreLocation]) + assert.Equal(t, "truststore.jks", got[keyConsumerSSLTruststoreFilename]) } -func TestBuildACLMounts_OAUTHBEARER_MatchesFixture(t *testing.T) { - mounts := buildACLMounts(pocStream, oauthbearerProfile(), "team-x") - - require.Len(t, mounts, 3) - assert.Equal(t, ACLMount{ - Name: "al-gp-id-s-central-kf-kafka-central-cert", - MountPath: "/var/secrets/al-gp-id-s-central-kf/certs", - SecretName: "kafka-central-cert", - Type: "secret", - }, mounts[0]) - assert.Equal(t, ACLMount{ - Name: "al-gp-id-s-central-kf-scp-kafka-ssl-secrets", - MountPath: "/var/secrets/al-gp-id-s-central-kf/passwords", - SecretName: "scp-kafka-ssl-secrets", - Type: "secret", - }, mounts[1]) - assert.Equal(t, ACLMount{ - Name: "kafka-token", - MountPath: "/var/run/secrets/kafka/serviceaccount", - Type: "projected", - }, mounts[2]) +func TestBuildACLConfig_OAUTHBEARER(t *testing.T) { + acl := buildACLConfig(pocStream, oauthbearerProfile(), "team-x") + + require.NotNil(t, acl) + assert.Equal(t, "kafka-central-cert", acl.SSLConfigCredential) + assert.Equal(t, "truststore.p12", acl.TruststoreFilename) + assert.Equal(t, &SecretKeyRef{SecretName: "scp-kafka-ssl-secrets", Key: "truststore_password"}, acl.TruststorePassword) + assert.True(t, acl.KafkaTokenEnabled) + assert.Empty(t, acl.JaasConfigCredential) } -// a plaintext stream produces no consumer config and no mounts. +// a plaintext stream produces no consumer config and no ACL values. func TestPlaintextStream_NoSecurityWiring(t *testing.T) { - assert.Nil(t, buildSecurityConfigs(pocStream, nil, "team-x", KafkaSecurity{})) - assert.Nil(t, buildSecurityConfigs(pocStream, &kafkamod.SecurityProfile{}, "team-x", KafkaSecurity{})) - assert.Nil(t, buildSecurityConfigs(pocStream, &kafkamod.SecurityProfile{SecurityProtocol: "PLAINTEXT"}, "team-x", KafkaSecurity{})) + assert.Nil(t, buildSecurityConfigs(nil, KafkaSecurity{})) + assert.Nil(t, buildSecurityConfigs(&kafkamod.SecurityProfile{}, KafkaSecurity{})) + assert.Nil(t, buildSecurityConfigs(&kafkamod.SecurityProfile{SecurityProtocol: "PLAINTEXT"}, KafkaSecurity{})) - assert.Nil(t, buildACLMounts(pocStream, nil, "team-x")) - assert.Nil(t, buildACLMounts(pocStream, &kafkamod.SecurityProfile{SecurityProtocol: "PLAINTEXT"}, "team-x")) + assert.Nil(t, buildACLConfig(pocStream, nil, "team-x")) + assert.Nil(t, buildACLConfig(pocStream, &kafkamod.SecurityProfile{SecurityProtocol: "PLAINTEXT"}, "team-x")) } // brokers are populated from the resolved stream URL when not set, and the @@ -116,7 +113,8 @@ func TestApplyStreamSecurity_PopulatesBrokersAndConfig(t *testing.T) { assert.Equal(t, "broker-1:9098,broker-2:9098", conf.EnvVariables[confKeyKafkaBrokers]) assert.Equal(t, "SASL_SSL", conf.EnvVariables[keyConsumerSecurityProtocol]) - assert.Len(t, conf.ACLMounts, 3) + require.NotNil(t, conf.ACL) + assert.True(t, conf.ACL.KafkaTokenEnabled) } // product (Dex) path: the security profile is inlined on conf.StreamSecurity @@ -135,7 +133,8 @@ func TestApplyStreamSecurity_InlineProfile_NoDependency(t *testing.T) { assert.Equal(t, "SASL_SSL", conf.EnvVariables[keyConsumerSecurityProtocol]) assert.Equal(t, "OAUTHBEARER", conf.EnvVariables[keyConsumerSaslMechanism]) - assert.Len(t, conf.ACLMounts, 3) + require.NotNil(t, conf.ACL) + assert.Equal(t, "kafka-central-cert", conf.ACL.SSLConfigCredential) } // the flag (sent as an env variable by Dex) makes the driver fetch the kafka @@ -175,7 +174,7 @@ func TestApplyStreamSecurity_FlagFetchesInternally(t *testing.T) { } // a plaintext firehose (no stream name, no stream_security, no dependency) is -// left untouched — env variables, mounts and service account unchanged. +// left untouched — env variables, ACL values and service account unchanged. func TestApplyStreamSecurity_PlaintextFirehose_NoWiring(t *testing.T) { conf := &Config{ Team: "team-x", @@ -191,7 +190,7 @@ func TestApplyStreamSecurity_PlaintextFirehose_NoWiring(t *testing.T) { confKeyKafkaBrokers: "localhost:9092", confKeyKafkaTopic: "foo-log", }, conf.EnvVariables) - assert.Nil(t, conf.ACLMounts) + assert.Nil(t, conf.ACL) assert.Empty(t, conf.ServiceAccount) } @@ -215,52 +214,64 @@ func TestApplyStreamSecurity_KeepsExplicitBrokers(t *testing.T) { assert.Equal(t, "explicit:9092", conf.EnvVariables[confKeyKafkaBrokers]) } -// a stream that loses its security profile has the previously injected keys -// and mounts cleared instead of left behind. +// a stream that loses its security profile has the previously injected keys, +// ACL values and JAAS java option cleared instead of left behind — including +// the config-provider keys written by an older build. func TestApplyStreamSecurity_ClearsStaleWiring(t *testing.T) { conf := &Config{ StreamName: pocStream, EnvVariables: map[string]string{ keyConsumerSecurityProtocol: "SASL_SSL", - keyConsumerSaslMechanism: "OAUTHBEARER", - confKeyKafkaTopic: "foo-log", + keyConsumerSaslMechanism: "SCRAM-SHA-512", + keyConsumerConfigProviders: "literalfile", + "SOURCE_KAFKA_CONSUMER_CONFIG_CONFIG_PROVIDERS_LITERALFILE_CLASS": "com.gtf.dagger.kafka.configproviders.LiteralFileConfigProvider", + keyJavaOptions: "-Xmx1250m " + jaasConfigJavaOpt, + confKeyKafkaTopic: "foo-log", }, - ACLMounts: []ACLMount{{Name: "stale", MountPath: "/var/secrets/stale", Type: "secret"}}, + ACL: &ACLConfig{SSLConfigCredential: "stale"}, } require.NoError(t, (&firehoseDriver{}).applyStreamSecurity(context.Background(), module.ExpandedResource{}, conf)) - assert.Equal(t, map[string]string{confKeyKafkaTopic: "foo-log"}, conf.EnvVariables) - assert.Nil(t, conf.ACLMounts) + assert.Equal(t, map[string]string{ + keyJavaOptions: "-Xmx1250m", + confKeyKafkaTopic: "foo-log", + }, conf.EnvVariables) + assert.Nil(t, conf.ACL) } -// PLAIN/SCRAM: the JAAS config references credentials through the literalfile -// provider — never inlining the secret values. -func TestBuildSecurityConfigs_PlainScram_NoInlinedSecrets(t *testing.T) { +// SCRAM streams read credentials from a mounted jaas.conf: no JAAS config env +// variable, a jaas secret to mount, and the JVM option pointing at it. +func TestApplyStreamSecurity_ScramUsesJaasFile(t *testing.T) { sp := &kafkamod.SecurityProfile{ - SecurityProtocol: "SASL_SSL", + SecurityProtocol: "SASL_PLAINTEXT", SaslMechanism: "SCRAM-SHA-512", ACLs: map[string]kafkamod.ACLCredentialRef{ "team-x": {SecretName: "team-x-creds", UsernameKey: "username", PasswordKey: "password"}, }, } + conf := &Config{ + Team: "team-x", + StreamName: pocStream, + EnvVariables: map[string]string{keyJavaOptions: "-Xmx1250m"}, + StreamSecurity: map[string]*kafkamod.SecurityProfile{pocStream: sp}, + } + + require.NoError(t, (&firehoseDriver{}).applyStreamSecurity(context.Background(), module.ExpandedResource{}, conf)) + + assert.NotContains(t, conf.EnvVariables, keyConsumerSaslJaasConfig) + assert.Equal(t, "-Xmx1250m "+jaasConfigJavaOpt, conf.EnvVariables[keyJavaOptions]) + require.NotNil(t, conf.ACL) + assert.Equal(t, "team-x-creds", conf.ACL.JaasConfigCredential) + assert.False(t, conf.ACL.KafkaTokenEnabled) +} + +// without an explicit credential secret, the jaas secret falls back to odin's +// --jaas convention, with underscores normalised to dashes. +func TestJaasSecretName_OdinConvention(t *testing.T) { + sp := &kafkamod.SecurityProfile{SecurityProtocol: "SASL_PLAINTEXT", SaslMechanism: "SCRAM-SHA-512"} - got := buildSecurityConfigs(pocStream, sp, "team-x", KafkaSecurity{}) - jaas := got[keyConsumerSaslJaasConfig] - - assert.Contains(t, jaas, "ScramLoginModule") - assert.Contains(t, jaas, "${literalfile:/var/secrets/al-gp-id-s-central-kf/credentials/username:literal-value}") - assert.Contains(t, jaas, "${literalfile:/var/secrets/al-gp-id-s-central-kf/credentials/password:literal-value}") - assert.NotContains(t, jaas, "team-x-creds") - assert.Equal(t, "literalfile", got[keyConsumerConfigProviders]) - - mounts := buildACLMounts(pocStream, sp, "team-x") - require.Len(t, mounts, 2) - assert.Equal(t, ACLMount{ - Name: "al-gp-id-s-central-kf-team-x-creds", - MountPath: "/var/secrets/al-gp-id-s-central-kf/credentials", - SecretName: "team-x-creds", - Type: "secret", - }, mounts[0]) - assert.Equal(t, "projected", mounts[1].Type) + assert.Equal(t, "team-x-al-gp-id-s-central-kf-jaas", jaasSecretName(pocStream, sp, "team-x")) + assert.Equal(t, "team-x-my-stream-jaas", jaasSecretName("my_stream", sp, "team_x")) + assert.Empty(t, jaasSecretName(pocStream, sp, "")) } diff --git a/modules/firehose/schema/config.json b/modules/firehose/schema/config.json index ebb9b24e..14075250 100644 --- a/modules/firehose/schema/config.json +++ b/modules/firehose/schema/config.json @@ -6,6 +6,32 @@ "replicas", "env_variables" ], + "anyOf": [ + { + "properties": { + "env_variables": { + "required": [ + "SOURCE_KAFKA_BROKERS" + ] + } + } + }, + { + "$comment": "brokers may be omitted when the kafka stream is named: they are resolved from the stream's output during plan.", + "required": [ + "stream_name" + ] + }, + { + "properties": { + "env_variables": { + "required": [ + "SOURCE_KAFKA_NAME" + ] + } + } + } + ], "properties": { "stop_time": { "type": "string", @@ -71,7 +97,6 @@ "required": [ "SINK_TYPE", "INPUT_SCHEMA_PROTO_CLASS", - "SOURCE_KAFKA_BROKERS", "SOURCE_KAFKA_TOPIC" ], "properties": { diff --git a/test/testbench/test_data/resource/firehose_acl_resource.json b/test/testbench/test_data/resource/firehose_acl_resource.json new file mode 100644 index 00000000..bd86ae89 --- /dev/null +++ b/test/testbench/test_data/resource/firehose_acl_resource.json @@ -0,0 +1,32 @@ +{ + "kind": "firehose", + "name": "test-firehose-acl", + "project": "test-project-0", + "labels": { + "description": "test firehose consuming a SASL_SSL kafka stream" + }, + "spec": { + "configs": { + "chart_values": { + "image_pull_policy": "IfNotPresent" + }, + "env_variables": { + "SINK_TYPE": "LOG", + "INPUT_SCHEMA_PROTO_CLASS": "com.tests.TestMessage", + "SOURCE_KAFKA_TOPIC": "test-topic" + }, + "replicas": 1, + "namespace": "default", + "team": "test-team", + "stream_name": "test-kafka-acl", + "stream_security_enabled": true, + "service_account": "aegis-kafka" + }, + "dependencies": [ + { + "key": "kube_cluster", + "value": "orn:entropy:kubernetes:test-project-0:test-kube" + } + ] + } +} diff --git a/test/testbench/test_data/resource/kafka_acl_resource.json b/test/testbench/test_data/resource/kafka_acl_resource.json new file mode 100644 index 00000000..7405e8e8 --- /dev/null +++ b/test/testbench/test_data/resource/kafka_acl_resource.json @@ -0,0 +1,36 @@ +{ + "kind": "kafka", + "name": "test-kafka-acl", + "project": "test-project-0", + "labels": { + "description": "test kafka stream secured with SASL_SSL / OAUTHBEARER" + }, + "spec": { + "configs": { + "advertise_mode": { + "host": "9098", + "address": "9098" + }, + "brokers": [ + { + "name": "test-project-test-kafka-acl-01", + "host": "test-project-test-kafka-acl-01", + "address": "127.0.0.1" + } + ], + "type": "source", + "security": { + "security_protocol": "SASL_SSL", + "sasl_mechanism": "OAUTHBEARER", + "ssl_protocol": "SSL", + "ssl_truststore_type": "PKCS12", + "ssl_cert_secret": "kafka-central-cert", + "ssl_truststore_password_details": { + "secretName": "scp-kafka-ssl-secrets", + "key": "truststore_password" + } + } + }, + "dependencies": [] + } +} From f7ac8cf34837c4de6698a6c6843880e800dc58ea Mon Sep 17 00:00:00 2001 From: "shruti.tiwari" Date: Wed, 12 Aug 2026 15:21:44 +0530 Subject: [PATCH 8/9] Updated the cert path --- docs/modules/firehose.md | 15 ++++++++------- modules/firehose/kafka_security.go | 8 ++++++-- modules/firehose/kafka_security_test.go | 4 ++-- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/docs/modules/firehose.md b/docs/modules/firehose.md index e20424f4..6d6b71cf 100644 --- a/docs/modules/firehose.md +++ b/docs/modules/firehose.md @@ -53,8 +53,10 @@ The wiring mirrors odin's firehose adapter, so a migrated firehose renders the s spec it does today. From the resolved profile the module injects the `SOURCE_KAFKA_CONSUMER_CONFIG_*` env variables — security protocol, SASL mechanism, SSL protocol, truststore type, and for a stream carrying certs a fixed -`SSL_TRUSTSTORE_LOCATION` of `/etc/secret/truststore.p12` (`.jks` for JKS) plus the -`SSL_TRUSTSTORE_FILENAME` the chart uses to select that key out of the secret. OAUTHBEARER +`SSL_TRUSTSTORE_LOCATION` of `/etc/secret/kafka-cert/truststore.p12` (`.jks` for JKS) plus +the `SSL_TRUSTSTORE_FILENAME` the chart uses to select that key out of the secret. The cert +gets its own subdirectory rather than odin's bare `/etc/secret`, because the chart already +mounts its sink-credential secret there. OAUTHBEARER additionally gets the `OAuthBearerLoginModule` JAAS string and the pod login callback handler class. @@ -63,7 +65,7 @@ No secret value ever passes through entropy. The material is described as refere | field | what the chart does with it | | :--- | :--- | -| `ssl_config_credential` + `truststore_filename` | mounts that existing secret at `/etc/secret`, selecting the filename as both key and path | +| `ssl_config_credential` + `truststore_filename` | mounts that existing secret at `/etc/secret/kafka-cert`, selecting the filename as both key and path | | `truststore_password` (`secretName` + `key`) | renders `SOURCE_KAFKA_CONSUMER_CONFIG_SSL_TRUSTSTORE_PASSWORD` as a `secretKeyRef` env var | | `jaas_config_credential` | mounts the PLAIN/SCRAM `jaas.conf` secret at `/etc/secret/kafka` | | `kafka_token_enabled` | adds the projected service-account token (`audience: kafka`) at `/var/run/secrets/kafka/serviceaccount` | @@ -89,7 +91,6 @@ the module's driver config: } ``` -> The published `firehose` chart (0.2.0) does not yet render any of this: its `mountSecrets` -> builds a *new* secret from inline values, and it has no `serviceAccountName`, projected -> volume or `secretKeyRef` support. Those four template blocks — ports of odin's -> `manifests/firehose.yaml` — are needed before an ACL firehose can run. \ No newline at end of file +> Requires firehose chart `0.2.1` or later (`goto/charts`, `stable/firehose`). Chart `0.2.0` +> renders none of this — it has no `serviceAccountName`, projected volume or `secretKeyRef` +> support — so an ACL firehose on it starts without its secrets and fails to authenticate. \ No newline at end of file diff --git a/modules/firehose/kafka_security.go b/modules/firehose/kafka_security.go index a1d3033d..594e2975 100644 --- a/modules/firehose/kafka_security.go +++ b/modules/firehose/kafka_security.go @@ -88,7 +88,11 @@ const ( // cert secret at /etc/secret and the JAAS secret at /etc/secret/kafka; the // projected kafka service-account token lands at kafkaTokenMountPath. const ( - secretMountPath = "/etc/secret" + secretMountPath = "/etc/secret" + // the cert secret gets its own subdirectory rather than odin's bare + // /etc/secret: the firehose chart already mounts its own secret there for + // sink credentials, and two volumes cannot share a mount path. + certMountPath = secretMountPath + "/kafka-cert" jaasSecretMountPath = secretMountPath + "/kafka" jaasConfigFileName = "jaas.conf" jaasConfigJavaOpt = "-Djava.security.auth.login.config=" + jaasSecretMountPath + "/" + jaasConfigFileName @@ -183,7 +187,7 @@ func buildSecurityConfigs(sp *kafkamod.SecurityProfile, sec KafkaSecurity) map[s } if sp.SSLCertSecret != "" { fileName := truststoreFileName(sp.SSLTruststoreType) - cfg[keyConsumerSSLTruststoreLocation] = secretMountPath + "/" + fileName + cfg[keyConsumerSSLTruststoreLocation] = certMountPath + "/" + fileName // the chart selects this key out of the cert secret and projects it // under the same name. cfg[keyConsumerSSLTruststoreFilename] = fileName diff --git a/modules/firehose/kafka_security_test.go b/modules/firehose/kafka_security_test.go index c620bfeb..ef73bfd2 100644 --- a/modules/firehose/kafka_security_test.go +++ b/modules/firehose/kafka_security_test.go @@ -42,7 +42,7 @@ func TestBuildSecurityConfigs_OAUTHBEARER_MatchesOdin(t *testing.T) { "SOURCE_KAFKA_CONSUMER_CONFIG_SASL_LOGIN_CALLBACK_HANDLER_CLASS": defaultOauthSaslLoginCallbackHandlerClass, "SOURCE_KAFKA_CONSUMER_CONFIG_SSL_PROTOCOL": "SSL", "SOURCE_KAFKA_CONSUMER_CONFIG_SSL_TRUSTSTORE_TYPE": "PKCS12", - "SOURCE_KAFKA_CONSUMER_CONFIG_SSL_TRUSTSTORE_LOCATION": "/etc/secret/truststore.p12", + "SOURCE_KAFKA_CONSUMER_CONFIG_SSL_TRUSTSTORE_LOCATION": "/etc/secret/kafka-cert/truststore.p12", "SOURCE_KAFKA_CONSUMER_CONFIG_SSL_TRUSTSTORE_FILENAME": "truststore.p12", } @@ -66,7 +66,7 @@ func TestBuildSecurityConfigs_JKSTruststore(t *testing.T) { got := buildSecurityConfigs(sp, KafkaSecurity{}) - assert.Equal(t, "/etc/secret/truststore.jks", got[keyConsumerSSLTruststoreLocation]) + assert.Equal(t, "/etc/secret/kafka-cert/truststore.jks", got[keyConsumerSSLTruststoreLocation]) assert.Equal(t, "truststore.jks", got[keyConsumerSSLTruststoreFilename]) } From 69ecf4d142f928d6f70af51a0f1aced706efb13e Mon Sep 17 00:00:00 2001 From: "shruti.tiwari" Date: Thu, 13 Aug 2026 14:20:30 +0530 Subject: [PATCH 9/9] Removed the unnecessary fallbacks for firehose --- docs/modules/firehose.md | 23 +++++-- modules/firehose/config.go | 21 ++---- modules/firehose/kafka_security.go | 43 +++++------- modules/firehose/kafka_security_test.go | 90 +++++++++++++++++++------ modules/firehose/schema/config.json | 9 --- 5 files changed, 109 insertions(+), 77 deletions(-) diff --git a/docs/modules/firehose.md b/docs/modules/firehose.md index 6d6b71cf..4ca0142b 100644 --- a/docs/modules/firehose.md +++ b/docs/modules/firehose.md @@ -38,16 +38,19 @@ Detailed JSONSchema for config can be referenced [here](https://github.com/goto/ ## Kafka ACL (SASL/SSL) source streams -A firehose reading from a secured stream names its kafka resource through `stream_name` -(or the `SOURCE_KAFKA_NAME` env variable). The stream's security profile — the same -`security` block the kafka module exposes on its output — is resolved during Plan, in -this order: +A firehose names its kafka resource through `stream_name`, and flags a secured one with +`stream_security_enabled`. The stream's security profile — the same `security` block the +kafka module exposes on its output — is then resolved during Plan, in this order: 1. an inline `stream_security` entry (prefetched by Dex), 2. a declared kafka dependency keyed by the stream name, -3. the kafka resource fetched internally by URN, when `stream_security_enabled` is set - (or `SOURCE_KAFKA_SECURITY_ENABLED=true` is passed as an env variable — it is stripped - before the config reaches the running firehose). +3. the kafka resource fetched internally by URN + (`orn:entropy:kafka::`), with no dependency declared. + +The flag exists so plaintext firehoses cost no lookup: the caller already knows whether a +profile exists, because Dex reads the same resource for the broker address. `stream_name` +is sent for plaintext streams too, so a stream that *loses* its ACLs gets the wiring a +previous plan left behind cleared. The wiring mirrors odin's firehose adapter, so a migrated firehose renders the same pod spec it does today. From the resolved profile the module injects the @@ -79,6 +82,12 @@ when set, otherwise odin's `--jaas` convention. Plaintext firehoses are untouched — no injected config, no `kafka_security` value, no chart value changes. +Naming a stream also relaxes the schema's `SOURCE_KAFKA_BROKERS` requirement: when the +stream is resolved, brokers are filled from its `url` unless the payload set them. Plan +fails if neither supplied them, rather than deploying a firehose with no brokers — note +that a stream named without the flag resolves nothing, so brokers must be explicit. Dex +sends the address itself in every case, so its value always wins. + The callback handler class and default service account are deployment level settings under the module's driver config: diff --git a/modules/firehose/config.go b/modules/firehose/config.go index 884576e9..0e761cf7 100644 --- a/modules/firehose/config.go +++ b/modules/firehose/config.go @@ -74,14 +74,15 @@ type Config struct { Team string `json:"team,omitempty"` // StreamName is the name of the kafka resource backing SOURCE_KAFKA_BROKERS. - // It is the key used to resolve the stream's security profile and the stable - // directory name of its mounted secrets. Falls back to the SOURCE_KAFKA_NAME - // env variable when unset. + // It is the key used to resolve the stream's security profile. Setting it + // also makes this module the owner of the SASL/SSL env variables, which are + // rebuilt from the stream on every plan. StreamName string `json:"stream_name,omitempty"` - // StreamSecurityEnabled makes the driver resolve StreamName's kafka resource - // internally (by URN, without a declared dependency) to read its security - // profile. + // StreamSecurityEnabled says the named stream carries a security profile, so + // the driver resolves its kafka resource internally (by URN, without a + // declared dependency). The caller already knows this — Dex reads the same + // resource for the broker address — so a plaintext firehose costs no lookup. StreamSecurityEnabled bool `json:"stream_security_enabled,omitempty"` // StreamSecurity holds the kafka security profile fetched by Dex, keyed by @@ -100,14 +101,6 @@ type Config struct { ServiceAccount string `json:"service_account,omitempty"` } -// streamName is the kafka resource name backing this firehose, if any. -func (cfg *Config) streamName() string { - if cfg.StreamName != "" { - return cfg.StreamName - } - return cfg.EnvVariables[keySourceKafkaName] -} - type Telegraf struct { Enabled bool `json:"enabled,omitempty"` Image map[string]any `json:"image,omitempty"` diff --git a/modules/firehose/kafka_security.go b/modules/firehose/kafka_security.go index 594e2975..dac58f74 100644 --- a/modules/firehose/kafka_security.go +++ b/modules/firehose/kafka_security.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "strconv" "strings" "github.com/goto/entropy/core/module" @@ -42,13 +41,6 @@ const ( keyConsumerConfigProviderClassSuffix = "_CLASS" ) -// transient inputs Dex may send as env variables. They describe the stream to -// resolve and are never forwarded to the running firehose. -const ( - keySourceKafkaName = "SOURCE_KAFKA_NAME" - keySourceKafkaSecurityEnabled = "SOURCE_KAFKA_SECURITY_ENABLED" -) - // managedSecurityKeys are owned by this module: they are wiped and rebuilt on // every plan of a firehose that names a kafka stream, so a stream that loses // its ACLs does not leave stale configuration behind. @@ -287,18 +279,7 @@ func jaasSecretName(streamName string, sp *kafkamod.SecurityProfile, team string // ACL values on conf. It is a no-op for firehoses that do not name a kafka // stream, and clears the wiring for streams that no longer carry a profile. func (fd *firehoseDriver) applyStreamSecurity(ctx context.Context, exr module.ExpandedResource, conf *Config) error { - // the flag may arrive as an env variable (Dex); it is transient and must - // never reach the running firehose. - if val, ok := conf.EnvVariables[keySourceKafkaSecurityEnabled]; ok { - enabled, err := strconv.ParseBool(strings.TrimSpace(val)) - if err != nil { - return fmt.Errorf("invalid %s value %q: %w", keySourceKafkaSecurityEnabled, val, err) - } - conf.StreamSecurityEnabled = conf.StreamSecurityEnabled || enabled - delete(conf.EnvVariables, keySourceKafkaSecurityEnabled) - } - - streamName := conf.streamName() + streamName := conf.StreamName if streamName == "" { return nil } @@ -313,6 +294,17 @@ func (fd *firehoseDriver) applyStreamSecurity(ctx context.Context, exr module.Ex if err != nil { return err } + + // naming a stream relaxes the schema's SOURCE_KAFKA_BROKERS requirement, + // because the brokers are meant to come from the stream. If neither the + // payload nor the resolution supplied them, fail here rather than deploy a + // firehose that cannot reach any broker. + if conf.EnvVariables[confKeyKafkaBrokers] == "" { + return fmt.Errorf( + "%s is not set and kafka stream %q resolved to no url: set it explicitly", + confKeyKafkaBrokers, streamName) + } + if !hasSecurityProfile(security) { return nil } @@ -382,9 +374,9 @@ func withoutJaasJavaOption(opts string) string { // // Resolution order: an inline conf.StreamSecurity entry, then a declared kafka // dependency (raw-Entropy path), then — when the resource carries the -// stream_security_enabled flag (the Dex product path) — the kafka resource -// fetched internally by URN via fd.getResource, with no dependency. Firehoses -// with none of these (plaintext) are left untouched. +// stream_security_enabled flag — the kafka resource fetched internally by URN +// via fd.getResource, with no dependency declared. Streams with none of these +// are plaintext. func (fd *firehoseDriver) resolveStreamSecurity(ctx context.Context, exr module.ExpandedResource, conf *Config, streamName string, ) (*kafkamod.SecurityProfile, error) { @@ -403,8 +395,9 @@ func (fd *firehoseDriver) resolveStreamSecurity(ctx context.Context, exr module. return out.Security, nil } - // 3. flag set (Dex product path): fetch the kafka resource internally by URN - // and read its security profile — no dependency declared. + // 3. the caller flagged a secured stream: fetch the kafka resource internally + // by URN and read its profile. Left unflagged the stream is plaintext, so + // nothing is fetched and the wiping above is all that happens. if conf.StreamSecurityEnabled { out, err := fd.fetchKafkaOutput(ctx, exr.Resource.Project, streamName) if err != nil { diff --git a/modules/firehose/kafka_security_test.go b/modules/firehose/kafka_security_test.go index ef73bfd2..fcf5a137 100644 --- a/modules/firehose/kafka_security_test.go +++ b/modules/firehose/kafka_security_test.go @@ -121,9 +121,10 @@ func TestApplyStreamSecurity_PopulatesBrokersAndConfig(t *testing.T) { // with NO kafka dependency present, and the ACL wiring still fires. func TestApplyStreamSecurity_InlineProfile_NoDependency(t *testing.T) { conf := &Config{ - Team: "team-x", - StreamName: pocStream, - EnvVariables: map[string]string{}, + Team: "team-x", + StreamName: pocStream, + // an inline profile carries no url, so brokers come from the payload. + EnvVariables: map[string]string{confKeyKafkaBrokers: "broker-1:9098"}, StreamSecurity: map[string]*kafkamod.SecurityProfile{ pocStream: oauthbearerProfile(), }, @@ -137,8 +138,8 @@ func TestApplyStreamSecurity_InlineProfile_NoDependency(t *testing.T) { assert.Equal(t, "kafka-central-cert", conf.ACL.SSLConfigCredential) } -// the flag (sent as an env variable by Dex) makes the driver fetch the kafka -// resource by URN, and is stripped from the env variables afterwards. +// the flag makes the driver fetch the kafka resource by URN, with no dependency +// declared — the Dex product path. func TestApplyStreamSecurity_FlagFetchesInternally(t *testing.T) { out := kafkamod.Output{URL: "127.0.0.1:9098", Security: oauthbearerProfile()} outJSON, err := json.Marshal(out) @@ -155,11 +156,10 @@ func TestApplyStreamSecurity_FlagFetchesInternally(t *testing.T) { exr := module.ExpandedResource{Resource: resource.Resource{Project: "al-dp-id-s"}} conf := &Config{ - Team: "team-x", - EnvVariables: map[string]string{ - keySourceKafkaName: pocStream, - keySourceKafkaSecurityEnabled: "true", - }, + Team: "team-x", + StreamName: pocStream, + StreamSecurityEnabled: true, + EnvVariables: map[string]string{}, } require.NoError(t, fd.applyStreamSecurity(context.Background(), exr, conf)) @@ -168,9 +168,6 @@ func TestApplyStreamSecurity_FlagFetchesInternally(t *testing.T) { assert.Equal(t, "127.0.0.1:9098", conf.EnvVariables[confKeyKafkaBrokers]) assert.Equal(t, "SASL_SSL", conf.EnvVariables[keyConsumerSecurityProtocol]) assert.Equal(t, "aegis-kafka", conf.ServiceAccount) - - // the transient flag must never reach the running firehose. - assert.NotContains(t, conf.EnvVariables, keySourceKafkaSecurityEnabled) } // a plaintext firehose (no stream name, no stream_security, no dependency) is @@ -194,6 +191,40 @@ func TestApplyStreamSecurity_PlaintextFirehose_NoWiring(t *testing.T) { assert.Empty(t, conf.ServiceAccount) } +// naming a stream relaxes the schema's brokers requirement, so a stream that +// resolves to nothing must fail the plan rather than deploy without brokers. +func TestApplyStreamSecurity_MissingBrokersFails(t *testing.T) { + // the stream resolves, but carries no url and the payload has no brokers. + outJSON, err := json.Marshal(kafkamod.Output{}) + require.NoError(t, err) + fd := &firehoseDriver{ + getResource: func(context.Context, string) (*resource.Resource, error) { + return &resource.Resource{State: resource.State{Output: outJSON}}, nil + }, + } + conf := &Config{ + StreamName: pocStream, + StreamSecurityEnabled: true, + EnvVariables: map[string]string{confKeyKafkaTopic: "foo-log"}, + } + + err = fd.applyStreamSecurity(context.Background(), module.ExpandedResource{}, conf) + require.Error(t, err) + assert.Contains(t, err.Error(), confKeyKafkaBrokers) + + // same through a declared dependency whose output has no url. + depJSON, err := json.Marshal(kafkamod.Output{Security: oauthbearerProfile()}) + require.NoError(t, err) + exr := module.ExpandedResource{ + Dependencies: map[string]module.ResolvedDependency{ + pocStream: {Kind: kafkamod.Module.Kind, Output: depJSON}, + }, + } + conf = &Config{StreamName: pocStream, EnvVariables: map[string]string{}} + + require.Error(t, (&firehoseDriver{}).applyStreamSecurity(context.Background(), exr, conf)) +} + // an explicit brokers value is not overwritten by the resolved stream URL. func TestApplyStreamSecurity_KeepsExplicitBrokers(t *testing.T) { out := kafkamod.Output{URL: "resolved:9098"} @@ -219,23 +250,35 @@ func TestApplyStreamSecurity_KeepsExplicitBrokers(t *testing.T) { // the config-provider keys written by an older build. func TestApplyStreamSecurity_ClearsStaleWiring(t *testing.T) { conf := &Config{ - StreamName: pocStream, + StreamName: pocStream, + StreamSecurityEnabled: true, EnvVariables: map[string]string{ keyConsumerSecurityProtocol: "SASL_SSL", keyConsumerSaslMechanism: "SCRAM-SHA-512", keyConsumerConfigProviders: "literalfile", "SOURCE_KAFKA_CONSUMER_CONFIG_CONFIG_PROVIDERS_LITERALFILE_CLASS": "com.gtf.dagger.kafka.configproviders.LiteralFileConfigProvider", - keyJavaOptions: "-Xmx1250m " + jaasConfigJavaOpt, - confKeyKafkaTopic: "foo-log", + keyJavaOptions: "-Xmx1250m " + jaasConfigJavaOpt, + confKeyKafkaTopic: "foo-log", + confKeyKafkaBrokers: "broker-1:9092", }, ACL: &ACLConfig{SSLConfigCredential: "stale"}, } - require.NoError(t, (&firehoseDriver{}).applyStreamSecurity(context.Background(), module.ExpandedResource{}, conf)) + // the stream now resolves without a security profile. + outJSON, err := json.Marshal(kafkamod.Output{URL: "broker-1:9092"}) + require.NoError(t, err) + fd := &firehoseDriver{ + getResource: func(context.Context, string) (*resource.Resource, error) { + return &resource.Resource{State: resource.State{Output: outJSON}}, nil + }, + } + + require.NoError(t, fd.applyStreamSecurity(context.Background(), module.ExpandedResource{}, conf)) assert.Equal(t, map[string]string{ - keyJavaOptions: "-Xmx1250m", - confKeyKafkaTopic: "foo-log", + keyJavaOptions: "-Xmx1250m", + confKeyKafkaTopic: "foo-log", + confKeyKafkaBrokers: "broker-1:9092", }, conf.EnvVariables) assert.Nil(t, conf.ACL) } @@ -251,9 +294,12 @@ func TestApplyStreamSecurity_ScramUsesJaasFile(t *testing.T) { }, } conf := &Config{ - Team: "team-x", - StreamName: pocStream, - EnvVariables: map[string]string{keyJavaOptions: "-Xmx1250m"}, + Team: "team-x", + StreamName: pocStream, + EnvVariables: map[string]string{ + keyJavaOptions: "-Xmx1250m", + confKeyKafkaBrokers: "broker-1:9092", + }, StreamSecurity: map[string]*kafkamod.SecurityProfile{pocStream: sp}, } diff --git a/modules/firehose/schema/config.json b/modules/firehose/schema/config.json index 14075250..82e53233 100644 --- a/modules/firehose/schema/config.json +++ b/modules/firehose/schema/config.json @@ -21,15 +21,6 @@ "required": [ "stream_name" ] - }, - { - "properties": { - "env_variables": { - "required": [ - "SOURCE_KAFKA_NAME" - ] - } - } } ], "properties": {