|
| 1 | +package clusteraccess |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "fmt" |
| 6 | + "time" |
| 7 | + |
| 8 | + authenticationv1 "k8s.io/api/authentication/v1" |
| 9 | + corev1 "k8s.io/api/core/v1" |
| 10 | + rbacv1 "k8s.io/api/rbac/v1" |
| 11 | + apierrors "k8s.io/apimachinery/pkg/api/errors" |
| 12 | + "k8s.io/client-go/rest" |
| 13 | + "k8s.io/client-go/tools/clientcmd" |
| 14 | + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" |
| 15 | + "k8s.io/utils/ptr" |
| 16 | + "sigs.k8s.io/controller-runtime/pkg/client" |
| 17 | + |
| 18 | + "github.com/openmcp-project/controller-utils/pkg/pairs" |
| 19 | + "github.com/openmcp-project/controller-utils/pkg/resources" |
| 20 | +) |
| 21 | + |
| 22 | +type Label = pairs.Pair[string, string] |
| 23 | + |
| 24 | +// GetTokenBasedAccess is a convenience function that wraps the flow of ensuring namespace, serviceaccount, (cluster)role(binding), and creating the token. |
| 25 | +// It returns a kubeconfig, the token with expiration timestamp, and an error if any of the steps fail. |
| 26 | +// The name will be used for all resources except the namespace (serviceaccount, (cluster)role, (cluster)rolebinding), with anything role-related additionally being prefixed with rolePrefix. |
| 27 | +// The namespace holds the serviceaccount and, if namespaceScoped is true, the role and rolebinding. |
| 28 | +// If namespaceScoped is false, clusterrole and clusterrolebinding are used. |
| 29 | +func GetTokenBasedAccess(ctx context.Context, c client.Client, restCfg *rest.Config, name, namespace string, namespaceScoped bool, rolePrefix string, rules []rbacv1.PolicyRule, expectedLabels ...Label) ([]byte, *ServiceAccountToken, error) { |
| 30 | + if namespace == "" { |
| 31 | + return nil, nil, fmt.Errorf("no namespace provided for ServiceAccount") |
| 32 | + } |
| 33 | + |
| 34 | + _, err := EnsureNamespace(ctx, c, namespace, expectedLabels...) |
| 35 | + if err != nil { |
| 36 | + return nil, nil, err |
| 37 | + } |
| 38 | + |
| 39 | + sa, err := EnsureServiceAccount(ctx, c, name, namespace, expectedLabels...) |
| 40 | + if err != nil { |
| 41 | + return nil, nil, err |
| 42 | + } |
| 43 | + |
| 44 | + subjects := []rbacv1.Subject{{Kind: rbacv1.ServiceAccountKind, Name: name, Namespace: namespace}} |
| 45 | + if namespaceScoped { |
| 46 | + _, _, err = EnsureRoleAndBinding(ctx, c, rolePrefix+name, namespace, subjects, rules, expectedLabels...) |
| 47 | + if err != nil { |
| 48 | + return nil, nil, err |
| 49 | + } |
| 50 | + } else { |
| 51 | + _, _, err = EnsureClusterRoleAndBinding(ctx, c, rolePrefix+name, subjects, rules, expectedLabels...) |
| 52 | + if err != nil { |
| 53 | + return nil, nil, err |
| 54 | + } |
| 55 | + } |
| 56 | + |
| 57 | + sat, err := CreateTokenForServiceAccount(ctx, c, sa, nil) |
| 58 | + if err != nil { |
| 59 | + return nil, nil, err |
| 60 | + } |
| 61 | + |
| 62 | + kcfg, err := CreateTokenKubeconfig(name, restCfg.Host, restCfg.CAData, sat.Token) |
| 63 | + if err != nil { |
| 64 | + return nil, nil, err |
| 65 | + } |
| 66 | + |
| 67 | + return kcfg, sat, nil |
| 68 | +} |
| 69 | + |
| 70 | +// EnsureNamespace ensures that the specified Namespace exists. |
| 71 | +// If it doesn't exist, it is created with the expected labels. |
| 72 | +// If it exists, but does not have the expected labels, a ResourceNotManagedError is returned. |
| 73 | +// The namespace is returned. |
| 74 | +func EnsureNamespace(ctx context.Context, c client.Client, nsName string, expectedLabels ...Label) (*corev1.Namespace, error) { |
| 75 | + ns := &corev1.Namespace{} |
| 76 | + ns.SetName(nsName) |
| 77 | + found := true |
| 78 | + if err := c.Get(ctx, client.ObjectKeyFromObject(ns), ns); err != nil { |
| 79 | + if !apierrors.IsNotFound(err) { |
| 80 | + return nil, fmt.Errorf("error getting Namespace '%s': %w", ns.Name, err) |
| 81 | + } |
| 82 | + found = false |
| 83 | + } |
| 84 | + if found { |
| 85 | + if err := FailIfNotManaged(ns, expectedLabels...); err != nil { |
| 86 | + return nil, err |
| 87 | + } |
| 88 | + // a namespace does not have any spec, so we don't have to do anything, if it was found |
| 89 | + return ns, nil |
| 90 | + } |
| 91 | + ns.SetLabels(pairs.PairsToMap(expectedLabels)) |
| 92 | + if err := c.Create(ctx, ns); err != nil { |
| 93 | + return nil, fmt.Errorf("error creating Namespace '%s': %w", ns.Name, err) |
| 94 | + } |
| 95 | + |
| 96 | + return ns, nil |
| 97 | +} |
| 98 | + |
| 99 | +// EnsureServiceAccount ensures that the specified ServiceAccount exists. |
| 100 | +// If it doesn't exist, it is created with the expected labels (the namespace has to exist). |
| 101 | +// If it exists, but does not have the expected labels, a ResourceNotManagedError is returned. |
| 102 | +// The ServiceAccount is returned. |
| 103 | +func EnsureServiceAccount(ctx context.Context, c client.Client, saName, saNamespace string, expectedLabels ...Label) (*corev1.ServiceAccount, error) { |
| 104 | + sa := &corev1.ServiceAccount{} |
| 105 | + sa.SetName(saName) |
| 106 | + sa.SetNamespace(saNamespace) |
| 107 | + found := true |
| 108 | + if err := c.Get(ctx, client.ObjectKeyFromObject(sa), sa); err != nil { |
| 109 | + if !apierrors.IsNotFound(err) { |
| 110 | + return nil, fmt.Errorf("error getting ServiceAccount '%s/%s': %w", sa.Namespace, sa.Name, err) |
| 111 | + } |
| 112 | + found = false |
| 113 | + } |
| 114 | + if found { |
| 115 | + if err := FailIfNotManaged(sa, expectedLabels...); err != nil { |
| 116 | + return nil, err |
| 117 | + } |
| 118 | + // a serviceaccount does not have any relevant spec, so we don't have to do anything, if it was found |
| 119 | + return sa, nil |
| 120 | + } |
| 121 | + sa.SetLabels(pairs.PairsToMap(expectedLabels)) |
| 122 | + if err := c.Create(ctx, sa); err != nil { |
| 123 | + return nil, fmt.Errorf("error creating ServiceAccount '%s': %w", sa.Name, err) |
| 124 | + } |
| 125 | + |
| 126 | + return sa, nil |
| 127 | +} |
| 128 | + |
| 129 | +// EnsureClusterRoleAndBinding combines EnsureClusterRole and EnsureClusterRoleBinding. |
| 130 | +// The name is used for both the ClusterRole and ClusterRoleBinding. |
| 131 | +func EnsureClusterRoleAndBinding(ctx context.Context, c client.Client, name string, subjects []rbacv1.Subject, rules []rbacv1.PolicyRule, expectedLabels ...Label) (*rbacv1.ClusterRoleBinding, *rbacv1.ClusterRole, error) { |
| 132 | + cr, err := EnsureClusterRole(ctx, c, name, rules, expectedLabels...) |
| 133 | + if err != nil { |
| 134 | + return nil, nil, err |
| 135 | + } |
| 136 | + crb, err := EnsureClusterRoleBinding(ctx, c, name, cr.Name, subjects, expectedLabels...) |
| 137 | + if err != nil { |
| 138 | + return nil, cr, err |
| 139 | + } |
| 140 | + return crb, cr, nil |
| 141 | +} |
| 142 | + |
| 143 | +// EnsureClusterRole ensures that the specified ClusterRole exists with the specified rules. |
| 144 | +// If it doesn't exist, it is created with the expected labels. |
| 145 | +// If it exists, but does not have the expected labels, a ResourceNotManagedError is returned. |
| 146 | +// The ClusterRole is returned. |
| 147 | +func EnsureClusterRole(ctx context.Context, c client.Client, name string, rules []rbacv1.PolicyRule, expectedLabels ...Label) (*rbacv1.ClusterRole, error) { |
| 148 | + crm := resources.NewClusterRoleMutator(name, rules) |
| 149 | + crm.MetadataMutator().WithLabels(pairs.PairsToMap(expectedLabels)) |
| 150 | + cr := crm.Empty() |
| 151 | + found := true |
| 152 | + if err := c.Get(ctx, client.ObjectKeyFromObject(cr), cr); err != nil { |
| 153 | + if !apierrors.IsNotFound(err) { |
| 154 | + return nil, fmt.Errorf("error getting ClusterRole '%s': %w", cr.Name, err) |
| 155 | + } |
| 156 | + found = false |
| 157 | + } |
| 158 | + if found { |
| 159 | + if err := FailIfNotManaged(cr, expectedLabels...); err != nil { |
| 160 | + return nil, err |
| 161 | + } |
| 162 | + } |
| 163 | + if err := resources.CreateOrUpdateResource(ctx, c, crm); err != nil { |
| 164 | + return nil, fmt.Errorf("error creating/updating ClusterRole '%s': %w", cr.Name, err) |
| 165 | + } |
| 166 | + return cr, nil |
| 167 | +} |
| 168 | + |
| 169 | +// EnsureClusterRoleBinding ensures that the specified ClusterRoleBinding exists with the specified subjects. |
| 170 | +// If it doesn't exist, it is created with the expected labels. |
| 171 | +// If it exists, but does not have the expected labels, a ResourceNotManagedError is returned. |
| 172 | +// The ClusterRoleBinding is returned. |
| 173 | +func EnsureClusterRoleBinding(ctx context.Context, c client.Client, name, clusterRoleName string, subjects []rbacv1.Subject, expectedLabels ...Label) (*rbacv1.ClusterRoleBinding, error) { |
| 174 | + crbm := resources.NewClusterRoleBindingMutator(name, subjects, resources.NewClusterRoleRef(clusterRoleName)) |
| 175 | + crbm.MetadataMutator().WithLabels(pairs.PairsToMap(expectedLabels)) |
| 176 | + crb := crbm.Empty() |
| 177 | + found := true |
| 178 | + if err := c.Get(ctx, client.ObjectKeyFromObject(crb), crb); err != nil { |
| 179 | + if !apierrors.IsNotFound(err) { |
| 180 | + return nil, fmt.Errorf("error getting ClusterRoleBinding '%s': %w", crb.Name, err) |
| 181 | + } |
| 182 | + found = false |
| 183 | + } |
| 184 | + if found { |
| 185 | + if err := FailIfNotManaged(crb, expectedLabels...); err != nil { |
| 186 | + return nil, err |
| 187 | + } |
| 188 | + } |
| 189 | + if err := resources.CreateOrUpdateResource(ctx, c, crbm); err != nil { |
| 190 | + return nil, fmt.Errorf("error creating/updating ClusterRole '%s': %w", crb.Name, err) |
| 191 | + } |
| 192 | + return crb, nil |
| 193 | +} |
| 194 | + |
| 195 | +// EnsureRoleAndBinding combines EnsureRole and EnsureRoleBinding. |
| 196 | +// The name is used for both the Role and RoleBinding. |
| 197 | +func EnsureRoleAndBinding(ctx context.Context, c client.Client, name, namespace string, subjects []rbacv1.Subject, rules []rbacv1.PolicyRule, expectedLabels ...Label) (*rbacv1.RoleBinding, *rbacv1.Role, error) { |
| 198 | + r, err := EnsureRole(ctx, c, name, namespace, rules, expectedLabels...) |
| 199 | + if err != nil { |
| 200 | + return nil, nil, err |
| 201 | + } |
| 202 | + rb, err := EnsureRoleBinding(ctx, c, name, namespace, r.Name, subjects, expectedLabels...) |
| 203 | + if err != nil { |
| 204 | + return nil, r, err |
| 205 | + } |
| 206 | + return rb, r, nil |
| 207 | +} |
| 208 | + |
| 209 | +// EnsureRole ensures that the specified Role exists with the specified rules. |
| 210 | +// If it doesn't exist, it is created with the expected labels. |
| 211 | +// If it exists, but does not have the expected labels, a ResourceNotManagedError is returned. |
| 212 | +// The Role is returned. |
| 213 | +func EnsureRole(ctx context.Context, c client.Client, name, namespace string, rules []rbacv1.PolicyRule, expectedLabels ...Label) (*rbacv1.Role, error) { |
| 214 | + rm := resources.NewRoleMutator(name, namespace, rules) |
| 215 | + rm.MetadataMutator().WithLabels(pairs.PairsToMap(expectedLabels)) |
| 216 | + r := rm.Empty() |
| 217 | + found := true |
| 218 | + if err := c.Get(ctx, client.ObjectKeyFromObject(r), r); err != nil { |
| 219 | + if !apierrors.IsNotFound(err) { |
| 220 | + return nil, fmt.Errorf("error getting Role '%s/%s': %w", r.Namespace, r.Name, err) |
| 221 | + } |
| 222 | + found = false |
| 223 | + } |
| 224 | + if found { |
| 225 | + if err := FailIfNotManaged(r, expectedLabels...); err != nil { |
| 226 | + return nil, err |
| 227 | + } |
| 228 | + } |
| 229 | + if err := resources.CreateOrUpdateResource(ctx, c, rm); err != nil { |
| 230 | + return nil, fmt.Errorf("error creating/updating Role '%s/%s': %w", r.Namespace, r.Name, err) |
| 231 | + } |
| 232 | + return r, nil |
| 233 | +} |
| 234 | + |
| 235 | +// EnsureRoleBinding ensures that the specified RoleBinding exists with the specified subjects. |
| 236 | +// If it doesn't exist, it is created with the expected labels. |
| 237 | +// If it exists, but does not have the expected labels, a ResourceNotManagedError is returned. |
| 238 | +// The RoleBinding is returned. |
| 239 | +func EnsureRoleBinding(ctx context.Context, c client.Client, name, namespace, roleName string, subjects []rbacv1.Subject, expectedLabels ...Label) (*rbacv1.RoleBinding, error) { |
| 240 | + rbm := resources.NewRoleBindingMutator(name, namespace, subjects, resources.NewRoleRef(roleName)) |
| 241 | + rbm.MetadataMutator().WithLabels(pairs.PairsToMap(expectedLabels)) |
| 242 | + rb := rbm.Empty() |
| 243 | + found := true |
| 244 | + if err := c.Get(ctx, client.ObjectKeyFromObject(rb), rb); err != nil { |
| 245 | + if !apierrors.IsNotFound(err) { |
| 246 | + return nil, fmt.Errorf("error getting RoleBinding '%s/%s': %w", rb.Namespace, rb.Name, err) |
| 247 | + } |
| 248 | + found = false |
| 249 | + } |
| 250 | + if found { |
| 251 | + if err := FailIfNotManaged(rb, expectedLabels...); err != nil { |
| 252 | + return nil, err |
| 253 | + } |
| 254 | + } |
| 255 | + if err := resources.CreateOrUpdateResource(ctx, c, rbm); err != nil { |
| 256 | + return nil, fmt.Errorf("error creating/updating RoleBinding '%s/%s': %w", rb.Namespace, rb.Name, err) |
| 257 | + } |
| 258 | + return rb, nil |
| 259 | +} |
| 260 | + |
| 261 | +// CreateTokenForServiceAccount generates a token for the given ServiceAccount. |
| 262 | +func CreateTokenForServiceAccount(ctx context.Context, c client.Client, sa *corev1.ServiceAccount, desiredDuration *time.Duration) (*ServiceAccountToken, error) { |
| 263 | + tr := &authenticationv1.TokenRequest{} |
| 264 | + if desiredDuration != nil { |
| 265 | + tr.Spec.ExpirationSeconds = ptr.To((int64)(desiredDuration.Seconds())) |
| 266 | + } |
| 267 | + |
| 268 | + sat := &ServiceAccountToken{ |
| 269 | + CreationTimestamp: time.Now(), |
| 270 | + } |
| 271 | + if err := c.SubResource("token").Create(ctx, sa, tr); err != nil { |
| 272 | + return nil, fmt.Errorf("error creating token for ServiceAccount '%s/%s': %w", sa.Namespace, sa.Name, err) |
| 273 | + } |
| 274 | + sat.Token = tr.Status.Token |
| 275 | + sat.ExpirationTimestamp = tr.Status.ExpirationTimestamp.Time |
| 276 | + |
| 277 | + return sat, nil |
| 278 | +} |
| 279 | + |
| 280 | +// ServiceAccountToken is a helper struct that bundles a ServiceAccount token together with its creation and expiration timestamps. |
| 281 | +type ServiceAccountToken struct { |
| 282 | + Token string |
| 283 | + CreationTimestamp time.Time |
| 284 | + ExpirationTimestamp time.Time |
| 285 | +} |
| 286 | + |
| 287 | +// CreateTokenKubeconfig generates a kubeconfig based on the given values. |
| 288 | +// The 'user' arg is used as key for the auth configuration and can be chosen freely. |
| 289 | +func CreateTokenKubeconfig(user, host string, caData []byte, token string) ([]byte, error) { |
| 290 | + id := "cluster" |
| 291 | + kcfg := clientcmdapi.Config{ |
| 292 | + APIVersion: "v1", |
| 293 | + Kind: "Config", |
| 294 | + Clusters: map[string]*clientcmdapi.Cluster{ |
| 295 | + id: { |
| 296 | + Server: host, |
| 297 | + CertificateAuthorityData: caData, |
| 298 | + }, |
| 299 | + }, |
| 300 | + Contexts: map[string]*clientcmdapi.Context{ |
| 301 | + id: { |
| 302 | + Cluster: id, |
| 303 | + AuthInfo: user, |
| 304 | + }, |
| 305 | + }, |
| 306 | + CurrentContext: id, |
| 307 | + AuthInfos: map[string]*clientcmdapi.AuthInfo{ |
| 308 | + user: { |
| 309 | + Token: token, |
| 310 | + }, |
| 311 | + }, |
| 312 | + } |
| 313 | + |
| 314 | + kcfgBytes, err := clientcmd.Write(kcfg) |
| 315 | + if err != nil { |
| 316 | + return nil, fmt.Errorf("error converting converting generated kubeconfig into yaml: %w", err) |
| 317 | + } |
| 318 | + return kcfgBytes, nil |
| 319 | +} |
| 320 | + |
| 321 | +// ComputeTokenRenewalTime computes the time for the renewal of a token, given its creation and expiration time. |
| 322 | +// Returns the zero time if either of the given times is zero. |
| 323 | +// The returned time is when 80% of the validity duration is reached. |
| 324 | +// If another percentage is desired, use ComputeTokenRenewalTimeWithRatio instead. |
| 325 | +func ComputeTokenRenewalTime(creationTime, expirationTime time.Time) time.Time { |
| 326 | + return ComputeTokenRenewalTimeWithRatio(creationTime, expirationTime, 0.8) |
| 327 | +} |
| 328 | + |
| 329 | +// ComputeTokenRenewalTime computes the time for the renewal of a token, given its creation and expiration time. |
| 330 | +// Returns the zero time if either of the given times is zero. |
| 331 | +// Ratio must be between 0 and 1. The returned time is when this percentage of the validity duration is reached. |
| 332 | +func ComputeTokenRenewalTimeWithRatio(creationTime, expirationTime time.Time, ratio float64) time.Time { |
| 333 | + if creationTime.IsZero() || expirationTime.IsZero() { |
| 334 | + return time.Time{} |
| 335 | + } |
| 336 | + // validity is how long the token was valid in the first place |
| 337 | + validity := expirationTime.Sub(creationTime) |
| 338 | + // renewalAfter is 80% of the validity |
| 339 | + renewalAfter := time.Duration(float64(validity) * ratio) |
| 340 | + // renewalAt is the point in time at which the token should be renewed |
| 341 | + renewalAt := creationTime.Add(renewalAfter) |
| 342 | + return renewalAt |
| 343 | +} |
0 commit comments