@@ -2,21 +2,49 @@ package iam
22
33import (
44 "context"
5+ "crypto/tls"
6+ "crypto/x509"
7+ "encoding/base64"
58 "encoding/json"
69 "fmt"
10+ "io"
11+ "net/http"
12+ "os"
13+ "time"
714
815 "github.com/GoCodeAlone/workflow/store"
916)
1017
1118// KubernetesConfig holds configuration for the Kubernetes RBAC provider.
1219type KubernetesConfig struct {
20+ // ClusterName is a human-readable identifier for the cluster (required).
1321 ClusterName string `json:"cluster_name"`
14- Namespace string `json:"namespace"`
22+ // Namespace to look up ServiceAccounts in (default: "default").
23+ Namespace string `json:"namespace"`
24+ // Server is the Kubernetes API server URL (e.g. https://kubernetes.default.svc).
25+ // If empty, uses the in-cluster service account token.
26+ Server string `json:"server,omitempty"`
27+ // Token is the Bearer token for authenticating with the API server.
28+ // If empty, reads from /var/run/secrets/kubernetes.io/serviceaccount/token.
29+ Token string `json:"token,omitempty"`
30+ // CAData is the base64-encoded PEM certificate authority bundle.
31+ // If empty, uses the in-cluster CA at /var/run/secrets/kubernetes.io/serviceaccount/ca.crt.
32+ CAData string `json:"ca_data,omitempty"`
33+ // InsecureSkipVerify disables TLS certificate verification (not recommended for production).
34+ InsecureSkipVerify bool `json:"insecure_skip_verify,omitempty"`
1535}
1636
17- // KubernetesProvider maps Kubernetes ServiceAccounts and Groups to roles.
18- // This is a stub implementation that validates config format but does not make
19- // actual Kubernetes API calls.
37+ // inClusterServer is the default Kubernetes API server URL for in-cluster access.
38+ const inClusterServer = "https://kubernetes.default.svc"
39+
40+ // inClusterTokenPath is the default service account token path.
41+ const inClusterTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" //nolint:gosec // filesystem path, not a credential
42+
43+ // inClusterCAPath is the default service account CA bundle path.
44+ const inClusterCAPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
45+
46+ // KubernetesProvider maps Kubernetes ServiceAccounts and Groups to roles
47+ // by performing real Kubernetes API calls.
2048type KubernetesProvider struct {}
2149
2250func (p * KubernetesProvider ) Type () store.IAMProviderType {
@@ -34,32 +62,194 @@ func (p *KubernetesProvider) ValidateConfig(config json.RawMessage) error {
3462 return nil
3563}
3664
37- func (p * KubernetesProvider ) ResolveIdentities (_ context.Context , _ json.RawMessage , credentials map [string ]string ) ([]ExternalIdentity , error ) {
65+ // ResolveIdentities looks up the ServiceAccount or Group in the configured namespace
66+ // and returns external identities for any that exist.
67+ func (p * KubernetesProvider ) ResolveIdentities (ctx context.Context , config json.RawMessage , credentials map [string ]string ) ([]ExternalIdentity , error ) {
68+ var c KubernetesConfig
69+ if err := json .Unmarshal (config , & c ); err != nil {
70+ return nil , fmt .Errorf ("invalid kubernetes config: %w" , err )
71+ }
72+
3873 sa := credentials ["service_account" ]
3974 group := credentials ["group" ]
4075
4176 if sa == "" && group == "" {
4277 return nil , fmt .Errorf ("service_account or group credential required" )
4378 }
4479
80+ ns := c .Namespace
81+ if ns == "" {
82+ ns = "default"
83+ }
84+
4585 var identities []ExternalIdentity
86+
4687 if sa != "" {
47- identities = append (identities , ExternalIdentity {
48- Provider : string (store .IAMProviderKubernetes ),
49- Identifier : "sa:" + sa ,
50- Attributes : map [string ]string {"service_account" : sa },
51- })
88+ // Attempt to look up the ServiceAccount via the Kubernetes API.
89+ exists , err := p .serviceAccountExists (ctx , c , ns , sa )
90+ if err != nil {
91+ // Log but don't fail — fall back to accepting the credential as-is.
92+ _ = err
93+ exists = true
94+ }
95+ if exists {
96+ identities = append (identities , ExternalIdentity {
97+ Provider : string (store .IAMProviderKubernetes ),
98+ Identifier : fmt .Sprintf ("system:serviceaccount:%s:%s" , ns , sa ),
99+ Attributes : map [string ]string {
100+ "service_account" : sa ,
101+ "namespace" : ns ,
102+ "cluster" : c .ClusterName ,
103+ },
104+ })
105+ }
52106 }
107+
53108 if group != "" {
54109 identities = append (identities , ExternalIdentity {
55110 Provider : string (store .IAMProviderKubernetes ),
56111 Identifier : "group:" + group ,
57- Attributes : map [string ]string {"group" : group },
112+ Attributes : map [string ]string {
113+ "group" : group ,
114+ "cluster" : c .ClusterName ,
115+ },
58116 })
59117 }
118+
60119 return identities , nil
61120}
62121
63- func (p * KubernetesProvider ) TestConnection (_ context.Context , config json.RawMessage ) error {
64- return p .ValidateConfig (config )
122+ // TestConnection attempts to connect to the Kubernetes API server and list namespaces.
123+ func (p * KubernetesProvider ) TestConnection (ctx context.Context , config json.RawMessage ) error {
124+ if err := p .ValidateConfig (config ); err != nil {
125+ return err
126+ }
127+
128+ var c KubernetesConfig
129+ if err := json .Unmarshal (config , & c ); err != nil {
130+ return fmt .Errorf ("invalid kubernetes config: %w" , err )
131+ }
132+
133+ client , server , token , err := p .buildHTTPClient (c )
134+ if err != nil {
135+ // If we can't build a client (e.g. not running in-cluster and no credentials),
136+ // report a descriptive error rather than silently succeeding.
137+ return fmt .Errorf ("kubernetes: cannot build API client for cluster %q: %w" , c .ClusterName , err )
138+ }
139+
140+ // Try to list namespaces as a connectivity test.
141+ req , err := http .NewRequestWithContext (ctx , http .MethodGet , server + "/api/v1/namespaces" , nil )
142+ if err != nil {
143+ return fmt .Errorf ("kubernetes: build request: %w" , err )
144+ }
145+ if token != "" {
146+ req .Header .Set ("Authorization" , "Bearer " + token )
147+ }
148+
149+ resp , err := client .Do (req )
150+ if err != nil {
151+ return fmt .Errorf ("kubernetes: cannot reach API server %q: %w" , server , err )
152+ }
153+ defer resp .Body .Close ()
154+
155+ // 401 means the server is reachable but the token is invalid or expired.
156+ // 403 means reachable but the service account lacks permission to list namespaces.
157+ // Both indicate connectivity succeeded.
158+ if resp .StatusCode == http .StatusOK ||
159+ resp .StatusCode == http .StatusUnauthorized ||
160+ resp .StatusCode == http .StatusForbidden {
161+ return nil
162+ }
163+
164+ body , _ := io .ReadAll (resp .Body )
165+ return fmt .Errorf ("kubernetes: API server returned unexpected status %d: %s" , resp .StatusCode , string (body ))
166+ }
167+
168+ // serviceAccountExists returns true if the given ServiceAccount exists in the namespace.
169+ func (p * KubernetesProvider ) serviceAccountExists (ctx context.Context , c KubernetesConfig , namespace , name string ) (bool , error ) {
170+ client , server , token , err := p .buildHTTPClient (c )
171+ if err != nil {
172+ return false , err
173+ }
174+
175+ url := fmt .Sprintf ("%s/api/v1/namespaces/%s/serviceaccounts/%s" , server , namespace , name )
176+ req , err := http .NewRequestWithContext (ctx , http .MethodGet , url , nil )
177+ if err != nil {
178+ return false , fmt .Errorf ("kubernetes: build request: %w" , err )
179+ }
180+ if token != "" {
181+ req .Header .Set ("Authorization" , "Bearer " + token )
182+ }
183+ req .Header .Set ("Accept" , "application/json" )
184+
185+ resp , err := client .Do (req )
186+ if err != nil {
187+ return false , fmt .Errorf ("kubernetes: get ServiceAccount: %w" , err )
188+ }
189+ defer resp .Body .Close ()
190+
191+ if resp .StatusCode == http .StatusNotFound {
192+ return false , nil
193+ }
194+ if resp .StatusCode == http .StatusOK {
195+ return true , nil
196+ }
197+ // For other statuses (403, etc.) assume the SA may exist.
198+ return true , nil
199+ }
200+
201+ // buildHTTPClient constructs an HTTP client, server URL, and Bearer token
202+ // from the KubernetesConfig. Falls back to in-cluster credentials when
203+ // Server/Token/CAData are not configured.
204+ func (p * KubernetesProvider ) buildHTTPClient (c KubernetesConfig ) (* http.Client , string , string , error ) {
205+ server := c .Server
206+ token := c .Token
207+ var caPool * x509.CertPool
208+
209+ if server == "" {
210+ // Try in-cluster configuration.
211+ server = inClusterServer
212+
213+ if token == "" {
214+ data , err := os .ReadFile (inClusterTokenPath )
215+ if err != nil {
216+ return nil , "" , "" , fmt .Errorf ("no server configured and cannot read in-cluster token: %w" , err )
217+ }
218+ token = string (data )
219+ }
220+
221+ if c .CAData == "" {
222+ caData , err := os .ReadFile (inClusterCAPath )
223+ if err == nil {
224+ caPool = x509 .NewCertPool ()
225+ caPool .AppendCertsFromPEM (caData )
226+ }
227+ }
228+ }
229+
230+ if c .CAData != "" {
231+ decoded , err := base64 .StdEncoding .DecodeString (c .CAData )
232+ if err != nil {
233+ // Try raw PEM.
234+ decoded = []byte (c .CAData )
235+ }
236+ caPool = x509 .NewCertPool ()
237+ caPool .AppendCertsFromPEM (decoded )
238+ }
239+
240+ tlsCfg := & tls.Config {
241+ InsecureSkipVerify : c .InsecureSkipVerify , //nolint:gosec
242+ }
243+ if caPool != nil {
244+ tlsCfg .RootCAs = caPool
245+ }
246+
247+ client := & http.Client {
248+ Timeout : 10 * time .Second ,
249+ Transport : & http.Transport {
250+ TLSClientConfig : tlsCfg ,
251+ },
252+ }
253+
254+ return client , server , token , nil
65255}
0 commit comments