From 8171567c03319e7f33c77a1669ef5ece3d520571 Mon Sep 17 00:00:00 2001 From: Eneman Date: Thu, 29 Feb 2024 16:16:03 +0100 Subject: [PATCH] [ENH] :sparkles: add hability to create user --- README.md | 34 +- api/v1alpha1/s3user_types.go | 77 ++++ api/v1alpha1/zz_generated.deepcopy.go | 106 +++++ config/crd/bases/s3.onyxia.sh_s3users.yaml | 134 ++++++ config/crd/kustomization.yaml | 2 + config/rbac/role.yaml | 26 ++ .../samples/s3.onyxia.sh_v1alpha1_s3user.yaml | 18 + controllers/s3/factory/interface.go | 15 +- controllers/s3/factory/minioS3Client.go | 255 ++++++++++- controllers/s3/factory/mockedS3Client.go | 65 +++ controllers/user_controller.go | 422 ++++++++++++++++++ .../utils/password/password_generator.go | 165 +++++++ main.go | 16 + 13 files changed, 1308 insertions(+), 27 deletions(-) create mode 100644 api/v1alpha1/s3user_types.go create mode 100644 config/crd/bases/s3.onyxia.sh_s3users.yaml create mode 100644 config/samples/s3.onyxia.sh_v1alpha1_s3user.yaml create mode 100644 controllers/user_controller.go create mode 100644 controllers/utils/password/password_generator.go diff --git a/README.md b/README.md index 297444c..2da2802 100644 --- a/README.md +++ b/README.md @@ -21,14 +21,16 @@ At its heart, the operator revolves around CRDs that match S3 resources : - `buckets.s3.onyxia.sh` - `policies.s3.onyxia.sh` - `paths.s3.onyxia.sh` +- `users.s3.onyxia.sh` The custom resources based on these CRDs are a somewhat simplified projection of the real S3 resources. From the operator's point of view : - A `Bucket` CR matches a S3 bucket, and only has a name, a quota (actually two, [see Bucket example in *Usage* section below](#bucket)), and optionally, a set of paths - A `Policy` CR matches a "canned" policy (not a bucket policy, but a global one, that can be attached to a user), and has a name, and its actual content (IAM JSON) - A `Path` CR matches a set of paths inside of a policy. This is akin to the `paths` property of the `Bucket` CRD, except `Path` is not responsible for Bucket creation. +- A `S3User` CR matches a user in the s3 server, and has a name, a set of policy and a set of group. -Each custom resource based on these CRDs on Kubernetes is to be matched with a resource on the S3 instance. If the CR and the corresponding S3 resource diverge, the operator will create or update the S3 resource to bring it back to . +Each custom resource based on these CRDs on Kubernetes is to be matched with a resource on the S3 instance. If the CR and the corresponding S3 resource diverge, the operator will create or update the S3 resource to bring it back to. Two important caveats : @@ -86,7 +88,8 @@ The parameters are summarized in the table below : | `bucket-deletion` | false | - | no | Trigger bucket deletion on the S3 backend upon CR deletion. Will fail if bucket is not empty. | | `policy-deletion` | false | - | no | Trigger policy deletion on the S3 backend upon CR deletion | | `path-deletion` | false | - | no | Trigger path deletion on the S3 backend upon CR deletion. Limited to deleting the `.keep` files used by the operator. | - +| `s3User-deletion` | false | - | no | Trigger S3User deletion on the S3 backend upon CR deletion. | +| `override-existing-secret` | false | - | no | Update secret linked to s3User if already exist, else noop | ## Usage @@ -197,6 +200,31 @@ spec: ``` +### S3User example + +```yaml +apiVersion: s3.onyxia.sh/v1alpha1 +kind: S3User +metadata: + labels: + app.kubernetes.io/name: user + app.kubernetes.io/instance: user-sample + app.kubernetes.io/part-of: s3-operator + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/created-by: s3-operator + name: user-sample +spec: + accessKey: user-sample + policies: + - policy-example1 + - policy-example2 + groups: + - group-example1 + +``` + +Each S3user is linked to a kubernetes secret which have the same name that the S3User. The secret contains 2 keys: `accessKey` and `secretKey`. + ## Operator SDK generated guidelines
@@ -276,3 +304,5 @@ make manifests More information can be found via the [Kubebuilder Documentation](https://book.kubebuilder.io/introduction.html)
+ + diff --git a/api/v1alpha1/s3user_types.go b/api/v1alpha1/s3user_types.go new file mode 100644 index 0000000..6b5b709 --- /dev/null +++ b/api/v1alpha1/s3user_types.go @@ -0,0 +1,77 @@ +/* +Copyright 2023. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! +// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. + +// S3UserSpec defines the desired state of S3User +type S3UserSpec struct { + + // Name of the S3User + // +kubebuilder:validation:Required + AccessKey string `json:"accessKey"` + + // Groups associated to the S3User + // +kubebuilder:validation:Optional + Groups []string `json:"groups,omitempty"` + + // Policies associated to the S3User + // +kubebuilder:validation:Optional + Policies []string `json:"policies,omitempty"` +} + +// S3UserStatus defines the observed state of S3User +type S3UserStatus struct { + // Status management using Conditions. + // See also : https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#typical-status-properties + // +optional + // +patchMergeKey=type + // +patchStrategy=merge + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` +} + +//+kubebuilder:object:root=true +//+kubebuilder:subresource:status + +// S3User is the Schema for the S3Users API +type S3User struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec S3UserSpec `json:"spec,omitempty"` + Status S3UserStatus `json:"status,omitempty"` +} + +//+kubebuilder:object:root=true + +// S3UserList contains a list of S3User +type S3UserList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []S3User `json:"items"` +} + +func init() { + SchemeBuilder.Register(&S3User{}, &S3UserList{}) +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 938126c..d55c38c 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -339,3 +339,109 @@ func (in *Quota) DeepCopy() *Quota { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *S3User) DeepCopyInto(out *S3User) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new S3User. +func (in *S3User) DeepCopy() *S3User { + if in == nil { + return nil + } + out := new(S3User) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *S3User) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *S3UserList) DeepCopyInto(out *S3UserList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]S3User, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new S3UserList. +func (in *S3UserList) DeepCopy() *S3UserList { + if in == nil { + return nil + } + out := new(S3UserList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *S3UserList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *S3UserSpec) DeepCopyInto(out *S3UserSpec) { + *out = *in + if in.Groups != nil { + in, out := &in.Groups, &out.Groups + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Policies != nil { + in, out := &in.Policies, &out.Policies + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new S3UserSpec. +func (in *S3UserSpec) DeepCopy() *S3UserSpec { + if in == nil { + return nil + } + out := new(S3UserSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *S3UserStatus) DeepCopyInto(out *S3UserStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new S3UserStatus. +func (in *S3UserStatus) DeepCopy() *S3UserStatus { + if in == nil { + return nil + } + out := new(S3UserStatus) + in.DeepCopyInto(out) + return out +} \ No newline at end of file diff --git a/config/crd/bases/s3.onyxia.sh_s3users.yaml b/config/crd/bases/s3.onyxia.sh_s3users.yaml new file mode 100644 index 0000000..bf1bd81 --- /dev/null +++ b/config/crd/bases/s3.onyxia.sh_s3users.yaml @@ -0,0 +1,134 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.11.1 + creationTimestamp: null + name: s3users.s3.onyxia.sh +spec: + group: s3.onyxia.sh + names: + kind: S3User + listKind: S3UserList + plural: s3users + singular: s3user + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: S3User is the Schema for the S3Users API + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + description: S3UserSpec defines the desired state of S3User + properties: + accessKey: + description: Name of the S3User + type: string + groups: + description: Groups associated to the S3User + items: + type: string + type: array + policies: + description: Policies associated to the S3User + items: + type: string + type: array + required: + - accessKey + type: object + status: + description: S3UserStatus defines the observed state of S3User + properties: + conditions: + description: 'Status management using Conditions. See also : https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#typical-status-properties' + items: + description: "Condition contains details for one aspect of the current + state of this API Resource. --- This struct is intended for direct + use as an array at the field path .status.conditions. For example, + \n type FooStatus struct{ // Represents the observations of a + foo's current state. // Known .status.conditions.type are: \"Available\", + \"Progressing\", and \"Degraded\" // +patchMergeKey=type // +patchStrategy=merge + // +listType=map // +listMapKey=type Conditions []metav1.Condition + `json:\"conditions,omitempty\" patchStrategy:\"merge\" patchMergeKey:\"type\" + protobuf:\"bytes,1,rep,name=conditions\"` \n // other fields }" + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition + transitioned from one status to another. This should be when + the underlying condition changed. If that is not known, then + using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating + details about the transition. This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation + that the condition was set based upon. For instance, if .metadata.generation + is currently 12, but the .status.conditions[x].observedGeneration + is 9, the condition is out of date with respect to the current + state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: reason contains a programmatic identifier indicating + the reason for the condition's last transition. Producers + of specific condition types may define expected values and + meanings for this field, and whether the values are considered + a guaranteed API. The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + --- Many .condition.type values are consistent across resources + like Available, but because arbitrary conditions can be useful + (see .node.status.conditions), the ability to deconflict is + important. The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index a93ebae..c77409c 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -5,6 +5,8 @@ resources: - bases/s3.onyxia.sh_buckets.yaml - bases/s3.onyxia.sh_policies.yaml - bases/s3.onyxia.sh_paths.yaml +- bases/s3.onyxia.sh_s3users.yaml + #+kubebuilder:scaffold:crdkustomizeresource patchesStrategicMerge: diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 41cabe7..5190141 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -5,6 +5,32 @@ metadata: creationTimestamp: null name: manager-role rules: +- apiGroups: + - s3.onyxia.sh + resources: + - S3User + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - s3.onyxia.sh + resources: + - S3User/finalizers + verbs: + - update +- apiGroups: + - s3.onyxia.sh + resources: + - S3User/status + verbs: + - get + - patch + - update - apiGroups: - s3.onyxia.sh resources: diff --git a/config/samples/s3.onyxia.sh_v1alpha1_s3user.yaml b/config/samples/s3.onyxia.sh_v1alpha1_s3user.yaml new file mode 100644 index 0000000..c54b3b6 --- /dev/null +++ b/config/samples/s3.onyxia.sh_v1alpha1_s3user.yaml @@ -0,0 +1,18 @@ +apiVersion: s3.onyxia.sh/v1alpha1 +kind: S3User +metadata: + labels: + app.kubernetes.io/name: user + app.kubernetes.io/instance: user-sample + app.kubernetes.io/part-of: s3-operator + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/created-by: s3-operator + name: user-sample +spec: + name: user-sample + password: toto + policies: + - policy-example1 + - policy-example2 + groups: + - group-example1 diff --git a/controllers/s3/factory/interface.go b/controllers/s3/factory/interface.go index 5aa0cf1..c6262f3 100644 --- a/controllers/s3/factory/interface.go +++ b/controllers/s3/factory/interface.go @@ -23,9 +23,22 @@ type S3Client interface { SetQuota(name string, quota int64) error // see comment in [minioS3Client.go] regarding the absence of a PolicyExists method // PolicyExists(name string) (bool, error) + PolicyExist(name string) (bool, error) + DeletePolicy(name string) error GetPolicyInfo(name string) (*madmin.PolicyInfo, error) CreateOrUpdatePolicy(name string, content string) error - DeletePolicy(name string) error + UserExist(name string) (bool, error) + CheckUserCredentialsValid(name string, accessKey string, secretKey string) (bool, error) + AddServiceAccountForUser(name string, accessKey string, secretKey string) error + CreateUser(name string, password string) error + DeleteUser(name string) error + GetUserGroups(name string) ([]string, error) + GetUserPolicies(name string) ([]string, error) + AddPoliciesToUser(username string, policies []string) error + RemovePoliciesFromUser(username string, policies []string) error + GroupExist(name string) (bool, error) + AddGroupsToUser(username string, groups []string) error + RemoveGroupsFromUser(username string, groups []string) error } type S3Config struct { diff --git a/controllers/s3/factory/minioS3Client.go b/controllers/s3/factory/minioS3Client.go index 365b0cc..6709756 100644 --- a/controllers/s3/factory/minioS3Client.go +++ b/controllers/s3/factory/minioS3Client.go @@ -6,8 +6,11 @@ import ( "crypto/tls" "crypto/x509" "encoding/base64" + "fmt" "net/http" "os" + "slices" + "strings" "github.com/minio/madmin-go/v3" "github.com/minio/minio-go/v7" @@ -35,6 +38,33 @@ func newMinioS3Client(S3Config *S3Config) *MinioS3Client { // - https://pkg.go.dev/net/http#RoundTripper // - https://youngkin.github.io/post/gohttpsclientserver/#create-the-client // - https://forfuncsake.github.io/post/2017/08/trust-extra-ca-cert-in-go-app/ + // Appending content directly, from a base64-encoded, PEM format CA certificate + // Variant : if S3Config.CaBundlePath was a string[] + // for _, caCertificateFilePath := range S3Config.S3Config.CaBundlePaths { + // caCert, err := os.ReadFile(caCertificateFilePath) + // if err != nil { + // log.Fatalf("Error opening CA cert file %s, Error: %s", caCertificateFilePath, err) + // } + // rootCAs.AppendCertsFromPEM([]byte(caCert)) + // } + addTransportOptions(S3Config, minioOptions) + + minioClient, err := minio.New(S3Config.S3UrlEndpoint, minioOptions) + if err != nil { + s3Logger.Error(err, "an error occurred while creating a new minio client") + } + + adminClient, err := madmin.New(S3Config.S3UrlEndpoint, S3Config.AccessKey, S3Config.SecretKey, S3Config.UseSsl) + if err != nil { + s3Logger.Error(err, "an error occurred while creating a new minio admin client") + } + // Getting the custom root CA (if any) from the "regular" client's Transport + adminClient.SetCustomTransport(minioOptions.Transport) + + return &MinioS3Client{*S3Config, *minioClient, *adminClient} +} + +func addTransportOptions(S3Config *S3Config, minioOptions *minio.Options) { if len(S3Config.CaCertificatesBase64) > 0 { rootCAs, _ := x509.SystemCertPool() @@ -42,7 +72,6 @@ func newMinioS3Client(S3Config *S3Config) *MinioS3Client { rootCAs = x509.NewCertPool() } - // Appending content directly, from a base64-encoded, PEM format CA certificate for _, caCertificateBase64 := range S3Config.CaCertificatesBase64 { decodedCaCertificate, err := base64.StdEncoding.DecodeString(caCertificateBase64) if err != nil { @@ -70,35 +99,12 @@ func newMinioS3Client(S3Config *S3Config) *MinioS3Client { } rootCAs.AppendCertsFromPEM([]byte(caCert)) - // Variant : if S3Config.CaBundlePath was a string[] - // for _, caCertificateFilePath := range S3Config.S3Config.CaBundlePaths { - // caCert, err := os.ReadFile(caCertificateFilePath) - // if err != nil { - // log.Fatalf("Error opening CA cert file %s, Error: %s", caCertificateFilePath, err) - // } - // rootCAs.AppendCertsFromPEM([]byte(caCert)) - // } - minioOptions.Transport = &http.Transport{ TLSClientConfig: &tls.Config{ RootCAs: rootCAs, }, } } - - minioClient, err := minio.New(S3Config.S3UrlEndpoint, minioOptions) - if err != nil { - s3Logger.Error(err, "an error occurred while creating a new minio client") - } - - adminClient, err := madmin.New(S3Config.S3UrlEndpoint, S3Config.AccessKey, S3Config.SecretKey, S3Config.UseSsl) - if err != nil { - s3Logger.Error(err, "an error occurred while creating a new minio admin client") - } - // Getting the custom root CA (if any) from the "regular" client's Transport - adminClient.SetCustomTransport(minioOptions.Transport) - - return &MinioS3Client{*S3Config, *minioClient, *adminClient} } // ////////////////// @@ -225,7 +231,208 @@ func (minioS3Client *MinioS3Client) CreateOrUpdatePolicy(name string, content st return minioS3Client.adminClient.AddCannedPolicy(context.Background(), name, []byte(content)) } +func (minioS3Client *MinioS3Client) PolicyExist(name string) (bool, error) { + s3Logger.Info("checking policy existence", "policy", name) + policies, err := minioS3Client.adminClient.ListPolicies(context.Background(), name) + if err != nil { + return false, err + } + filteredPolicies := []string{} + for i := 0; i < len(policies); i++ { + if policies[i].Name == name { + filteredPolicies = append(filteredPolicies, name) + } + } + return len(filteredPolicies) > 0, nil +} + func (minioS3Client *MinioS3Client) DeletePolicy(name string) error { s3Logger.Info("delete policy", "policy", name) return minioS3Client.adminClient.RemoveCannedPolicy(context.Background(), name) } + +//////////////////// +// USER methods // +//////////////////// + +func (minioS3Client *MinioS3Client) CreateUser(name string, password string) error { + s3Logger.Info("Creating user", "user", name) + err := minioS3Client.adminClient.AddUser(context.Background(), name, password) + if err != nil { + s3Logger.Error(err, "Error while creating user", "user", name) + return err + } + return nil +} + +func (minioS3Client *MinioS3Client) AddServiceAccountForUser(name string, accessKey string, secretKey string) error { + s3Logger.Info("Adding service account for user", "user", name) + + opts := madmin.AddServiceAccountReq{ + AccessKey: accessKey, + SecretKey: secretKey, + Name: accessKey, + Description: "", + TargetUser: name, + } + + _, err := minioS3Client.adminClient.AddServiceAccount(context.Background(), opts) + if err != nil { + s3Logger.Error(err, "Error while creating service account for user", "user", name) + return err + } + + return nil + +} + +func (minioS3Client *MinioS3Client) UserExist(name string) (bool, error) { + s3Logger.Info("checking user existence", "user", name) + _, _err := minioS3Client.adminClient.GetUserInfo(context.Background(), name) + if _err != nil { + s3Logger.Info("received code", "user", minio.ToErrorResponse(_err)) + if minio.ToErrorResponse(_err).StatusCode == 0 { + return false, nil + } + return false, _err + } + return true, nil +} + +func (minioS3Client *MinioS3Client) DeleteUser(name string) error { + s3Logger.Info("delete user", "user", name) + return minioS3Client.adminClient.RemoveUser(context.Background(), name) +} + +func (minioS3Client *MinioS3Client) GetUserPolicies(name string) ([]string, error) { + s3Logger.Info("Get user policies", "user", name) + userInfo, err := minioS3Client.adminClient.GetUserInfo(context.Background(), name) + if err != nil { + s3Logger.Error(err, "Error when getting userInfo") + + return []string{}, err + } + return strings.Split(userInfo.PolicyName, ","), nil +} + +func (minioS3Client *MinioS3Client) CheckUserCredentialsValid(name string, accessKey string, secretKey string) (bool, error) { + s3Logger.Info("Check credential for user", "user", name) + minioTestClientOptions := &minio.Options{ + Creds: credentials.NewStaticV4(accessKey, secretKey, ""), + Region: minioS3Client.s3Config.Region, + Secure: minioS3Client.s3Config.UseSsl, + } + addTransportOptions(&minioS3Client.s3Config, minioTestClientOptions) + minioTestClient, err := minio.New(minioS3Client.s3Config.S3UrlEndpoint, minioTestClientOptions) + if err != nil { + s3Logger.Error(err, "An error occurred while creating a new minio test client") + } + + _, err = minioTestClient.ListBuckets(context.Background()) + if err != nil { + s3Logger.Error(err, "An error occurred while listing bucket") + return false, err + } + return true, nil +} + +func (minioS3Client *MinioS3Client) RemovePoliciesFromUser(username string, policies []string) error { + s3Logger.Info(fmt.Sprintf("Remove policy [%s] from user [%s]", policies, username)) + + opts := madmin.PolicyAssociationReq{ + Policies: policies, + User: username, + } + + _, err := minioS3Client.adminClient.DetachPolicy(context.Background(), opts) + + if err != nil { + return err + } + + return nil +} + +func (minioS3Client *MinioS3Client) AddPoliciesToUser(username string, policies []string) error { + s3Logger.Info("Adding policies to user", "user", username, "policies", policies) + opts := madmin.PolicyAssociationReq{ + User: username, + Policies: policies, + } + _, err := minioS3Client.adminClient.AttachPolicy(context.Background(), opts) + if err != nil { + return err + } + return nil +} + +func (minioS3Client *MinioS3Client) AddGroupsToUser(username string, groups []string) error { + s3Logger.Info("Adding groups to user", "user", username, "groups", groups) + for _, group := range groups { + opts := madmin.GroupAddRemove{ + Group: group, + Members: []string{username}, + } + err := minioS3Client.adminClient.UpdateGroupMembers(context.Background(), opts) + if err != nil { + s3Logger.Error(err, "Error when adding user to group", "user", username, "group", group) + return err + } + } + return nil +} + +func (minioS3Client *MinioS3Client) RemoveGroupsFromUser(username string, groups []string) error { + s3Logger.Info(fmt.Sprintf("Remove groups [%s] from user [%s]", groups, username)) + for _, group := range groups { + opts := madmin.GroupAddRemove{ + Group: group, + Members: []string{username}, + IsRemove: true, + } + err := minioS3Client.adminClient.UpdateGroupMembers(context.Background(), opts) + if err != nil { + s3Logger.Error(err, "Error when deleting user from group", "user", username, "group", group) + return err + } + } + return nil +} + +func (minioS3Client *MinioS3Client) GetUserGroups(name string) ([]string, error) { + s3Logger.Info("Get user groups", "user", name) + groups, err := minioS3Client.adminClient.ListGroups(context.Background()) + if err != nil { + return nil, err + } + userGroups := []string{} + for _, group := range groups { + groupDesc, err := minioS3Client.adminClient.GetGroupDescription(context.Background(), group) + if err != nil { + return nil, err + } + if slices.Contains(groupDesc.Members, name) { + userGroups = append(userGroups, group) + } + } + return userGroups, nil +} + +//////////////////// +// Group methods // +//////////////////// + +func (minioS3Client *MinioS3Client) GroupExist(name string) (bool, error) { + s3Logger.Info("checking group existence", "group", name) + groups, err := minioS3Client.adminClient.ListGroups(context.Background()) + if err != nil { + return false, err + } + filteredGroups := []string{} + for i := 0; i < len(groups); i++ { + if groups[i] == name { + filteredGroups = append(filteredGroups, name) + } + } + return len(filteredGroups) > 0, nil +} diff --git a/controllers/s3/factory/mockedS3Client.go b/controllers/s3/factory/mockedS3Client.go index dd93ea5..5ec2d47 100644 --- a/controllers/s3/factory/mockedS3Client.go +++ b/controllers/s3/factory/mockedS3Client.go @@ -56,11 +56,76 @@ func (mockedS3Provider *MockedS3Client) CreateOrUpdatePolicy(name string, conten return nil } +func (mockedS3Provider *MockedS3Client) CreateUser(name string, password string) error { + s3Logger.Info("create or update user", "user", name) + return nil +} + +func (mockedS3Provider *MockedS3Client) UserExist(name string) (bool, error) { + s3Logger.Info("checking user existence", "user", name) + return true, nil +} + +func (mockedS3Provider *MockedS3Client) AddServiceAccountForUser(name string, accessKey string, secretKey string) error { + s3Logger.Info("Adding service account for user", "user", name) + return nil +} + +func (mockedS3Provider *MockedS3Client) PolicyExist(name string) (bool, error) { + s3Logger.Info("checking policy existence", "policy", name) + return true, nil +} + +func (mockedS3Provider *MockedS3Client) GroupExist(name string) (bool, error) { + s3Logger.Info("checking group existence", "group", name) + return true, nil +} + +func (mockedS3Provider *MockedS3Client) AddPoliciesToUser(username string, policies []string) error { + s3Logger.Info("Adding policies to user", "user", username, "policies", policies) + return nil +} + +func (mockedS3Provider *MockedS3Client) AddGroupsToUser(username string, groups []string) error { + s3Logger.Info("Adding groups to user", "user", username, "groups", groups) + return nil +} + func (mockedS3Provider *MockedS3Client) DeletePolicy(name string) error { s3Logger.Info("delete policy", "policy", name) return nil } +func (mockedS3Provider *MockedS3Client) DeleteUser(name string) error { + s3Logger.Info("delete user", "user", name) + return nil +} + +func (mockedS3Provider *MockedS3Client) CheckUserCredentialsValid(name string, accessKey string, secretKey string) (bool, error) { + s3Logger.Info("checking credential for user", "user", name) + return true, nil +} + +func (mockedS3Provider *MockedS3Client) GetUserPolicies(name string) ([]string, error) { + s3Logger.Info("Getting user policies for user", "user", name) + return []string{}, nil +} + +func (mockedS3Provider *MockedS3Client) RemovePoliciesFromUser(username string, policies []string) error { + s3Logger.Info("Removing policies from user", "user", username) + return nil +} + +func (mockedS3Provider *MockedS3Client) GetUserGroups(name string) ([]string, error) { + s3Logger.Info("Getting user groups for user", "user", name) + return []string{}, nil +} + +func (mockedS3Provider *MockedS3Client) RemoveGroupsFromUser(name string, groups []string) error { + s3Logger.Info("Getting user groups for user", "user", name) + return nil +} + func newMockedS3Client() *MockedS3Client { return &MockedS3Client{} } diff --git a/controllers/user_controller.go b/controllers/user_controller.go new file mode 100644 index 0000000..703acb7 --- /dev/null +++ b/controllers/user_controller.go @@ -0,0 +1,422 @@ +/* +Copyright 2023. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controllers + +import ( + "context" + "fmt" + "slices" + "time" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + utilerrors "k8s.io/apimachinery/pkg/util/errors" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + s3v1alpha1 "github.com/InseeFrLab/s3-operator/api/v1alpha1" + "github.com/InseeFrLab/s3-operator/controllers/s3/factory" + utils "github.com/InseeFrLab/s3-operator/controllers/utils" + password "github.com/InseeFrLab/s3-operator/controllers/utils/password" + "github.com/go-logr/logr" +) + +// S3UserReconciler reconciles a S3User object +type S3UserReconciler struct { + client.Client + Scheme *runtime.Scheme + S3Client factory.S3Client + S3UserDeletion bool + OverrideExistingSecret bool +} + +const ( + userFinalizer = "s3.onyxia.sh/userFinalizer" +) + +//+kubebuilder:rbac:groups=s3.onyxia.sh,resources=S3User,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=s3.onyxia.sh,resources=S3User/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=s3.onyxia.sh,resources=S3User/finalizers,verbs=update + +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the cluster closer to the desired state. +// +// For more details, check Reconcile and its Result here: +// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.14.1/pkg/reconcile +func (r *S3UserReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + // Checking for userResource existence + userResource := &s3v1alpha1.S3User{} + err := r.Get(ctx, req.NamespacedName, userResource) + if err != nil { + if errors.IsNotFound(err) { + logger.Info(fmt.Sprintf("S3User CRD %s has been removed. NOOP", req.Name)) + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + + // Check if the userResource instance is marked to be deleted, which is + // indicated by the deletion timestamp being set. The object will be deleted. + + if userResource.GetDeletionTimestamp() != nil { + logger.Info("userResource have been marked for deletion") + return handleS3UserDeletion(ctx, userResource, r, logger, err) + } + + // Check user existence on the S3 server + found, err := r.S3Client.UserExist(userResource.Spec.AccessKey) + if err != nil { + logger.Error(err, "an error occurred while checking the existence of a user", "user", userResource.Name) + return r.setS3UserStatusConditionAndUpdate(ctx, userResource, "OperatorFailed", metav1.ConditionFalse, "S3UserExistenceCheckFailed", + fmt.Sprintf("Obtaining user[%s] info from S3 instance has failed", userResource.Name), err) + } + + // Add finalizer for this CR + if !controllerutil.ContainsFinalizer(userResource, userFinalizer) { + controllerutil.AddFinalizer(userResource, userFinalizer) + err = r.Update(ctx, userResource) + if err != nil { + logger.Error(err, "an error occurred when adding finalizer from user", "user", userResource.Name) + // return ctrl.Result{}, err + return r.setS3UserStatusConditionAndUpdate(ctx, userResource, "OperatorFailed", metav1.ConditionFalse, "S3UserFinalizerAddFailed", + fmt.Sprintf("An error occurred when attempting to add the finalizer from user [%s]", userResource.Name), err) + } + } + + // If the user does not exist, it is created based on the CR + if !found { + // S3User creation + // The user creation happened without any error + return handleS3UserCreation(ctx, userResource, r) + } + + // if the user exist, update from the CR + if found { + return handleReconcileS3User(ctx, err, r, userResource, logger) + } + + // The user reconciliation with its CR was succesful (or NOOP) + return r.setS3UserStatusConditionAndUpdate(ctx, userResource, "OperatorSucceeded", metav1.ConditionTrue, "S3UserUpdated", + fmt.Sprintf("The user [%s] was updated according to its matching custom resource", userResource.Name), nil) +} + +func handleReconcileS3User(ctx context.Context, err error, r *S3UserReconciler, userResource *s3v1alpha1.S3User, logger logr.Logger) (reconcile.Result, error) { + secret := &corev1.Secret{} + err = r.Get(ctx, types.NamespacedName{Name: userResource.Name, Namespace: userResource.Namespace}, secret) + if err != nil && errors.IsNotFound(err) { + logger.Info("Secret associated to user not found, user will be deleted and recreated", "user", userResource.Name) + err = r.S3Client.DeleteUser(userResource.Name) + if err != nil { + logger.Error(err, "Could not delete user on S3 server", "user", userResource.Name) + return r.setS3UserStatusConditionAndUpdate(ctx, userResource, "OperatorFailed", metav1.ConditionFalse, "S3UserDeletionFailed", + fmt.Sprintf("Deletion of S3user %s on S3 server has failed", userResource.Name), err) + } + return handleS3UserCreation(ctx, userResource, r) + } else if err != nil { + logger.Error(err, "Could not locate secret", "secret", userResource.Name) + return r.setS3UserStatusConditionAndUpdate(ctx, userResource, "OperatorFailed", metav1.ConditionFalse, "SecretNotFound", + fmt.Sprintf("Cannot locate k8s secrets [%s]", userResource.Name), err) + } + + secretKeyValid, err := r.S3Client.CheckUserCredentialsValid(userResource.Name, userResource.Spec.AccessKey, string(secret.Data["secretKey"])) + if err != nil { + logger.Error(err, "Something went wrong while checking user credential") + } + + if !secretKeyValid { + logger.Info("Secret for user is invalid") + err = r.S3Client.DeleteUser(userResource.Name) + if err != nil { + logger.Error(err, "Could not delete user on S3 server", "user", userResource.Name) + return r.setS3UserStatusConditionAndUpdate(ctx, userResource, "OperatorFailed", metav1.ConditionFalse, "S3UserDeletionFailed", + fmt.Sprintf("Deletion of S3user %s on S3 server has failed", userResource.Name), err) + } + return handleS3UserCreation(ctx, userResource, r) + } + + logger.Info("Check user policies is correct") + userPolicies, err := r.S3Client.GetUserPolicies(userResource.Spec.AccessKey) + policyToDelete := []string{} + policyToAdd := []string{} + for _, policy := range userPolicies { + policyFound := slices.Contains(userResource.Spec.Policies, policy) + if !policyFound { + logger.Info(fmt.Sprintf("S3User policy definition doesn't contains policy %s", policy)) + policyToDelete = append(policyToDelete, policy) + } + } + + for _, policy := range userResource.Spec.Policies { + policyFound := slices.Contains(userPolicies, policy) + if !policyFound { + logger.Info(fmt.Sprintf("S3User policy definition must contains policy %s", policy)) + policyToAdd = append(policyToAdd, policy) + } + } + + if len(policyToDelete) > 0 { + r.S3Client.RemovePoliciesFromUser(userResource.Spec.AccessKey, policyToDelete) + } + if len(policyToAdd) > 0 { + r.S3Client.AddPoliciesToUser(userResource.Spec.AccessKey, policyToAdd) + } + + logger.Info("Check user groups is correct") + userGroups, err := r.S3Client.GetUserGroups(userResource.Spec.AccessKey) + groupsToDelete := []string{} + groupsToAdd := []string{} + for _, group := range userGroups { + groupFound := slices.Contains(userResource.Spec.Groups, group) + if !groupFound { + logger.Info(fmt.Sprintf("S3User groups definition must contains group %s", group)) + + groupsToDelete = append(groupsToDelete, group) + } + } + + for _, group := range userResource.Spec.Groups { + groupFound := slices.Contains(groupsToAdd, group) + if !groupFound { + logger.Info(fmt.Sprintf("S3User groups definition must contains group %s", group)) + groupsToAdd = append(groupsToAdd, group) + } + } + + if len(groupsToDelete) > 0 { + r.S3Client.RemoveGroupsFromUser(userResource.Spec.AccessKey, groupsToDelete) + } + if len(groupsToAdd) > 0 { + r.S3Client.AddGroupsToUser(userResource.Spec.AccessKey, groupsToAdd) + } + + logger.Info("User was reconcile without error") + + return r.setS3UserStatusConditionAndUpdate(ctx, userResource, "OperatorSucceeded", metav1.ConditionTrue, "UserUpdated", + fmt.Sprintf("The user [%s] was updated according to its matching custom resource", userResource.Name), nil) +} + +func handleS3UserDeletion(ctx context.Context, userResource *s3v1alpha1.S3User, r *S3UserReconciler, logger logr.Logger, err error) (reconcile.Result, error) { + if controllerutil.ContainsFinalizer(userResource, userFinalizer) { + // Run finalization logic for S3UserFinalizer. If the finalization logic fails, don't remove the finalizer so that we can retry during the next reconciliation. + if err := r.finalizeS3User(userResource); err != nil { + logger.Error(err, "an error occurred when attempting to finalize the user", "user", userResource.Name) + return r.setS3UserStatusConditionAndUpdate(ctx, userResource, "OperatorFailed", metav1.ConditionFalse, "S3UserFinalizeFailed", + fmt.Sprintf("An error occurred when attempting to delete user [%s]", userResource.Name), err) + } + + //Remove userFinalizer. Once all finalizers have been removed, the object will be deleted. + controllerutil.RemoveFinalizer(userResource, userFinalizer) + err = r.Update(ctx, userResource) + if err != nil { + logger.Error(err, "Failed to remove finalizer.") + return r.setS3UserStatusConditionAndUpdate(ctx, userResource, "OperatorFailed", metav1.ConditionFalse, "S3UserFinalizerRemovalFailed", + fmt.Sprintf("An error occurred when attempting to remove the finalizer from user [%s]", userResource.Name), err) + } + } + return ctrl.Result{}, nil +} + +func handleS3UserCreation(ctx context.Context, userResource *s3v1alpha1.S3User, r *S3UserReconciler) (reconcile.Result, error) { + logger := log.FromContext(ctx) + + if !controllerutil.ContainsFinalizer(userResource, userFinalizer) { + controllerutil.AddFinalizer(userResource, userFinalizer) + err := r.Update(ctx, userResource) + if err != nil { + logger.Error(err, "Failed to add finalizer.") + return r.setS3UserStatusConditionAndUpdate(ctx, userResource, "OperatorFailed", metav1.ConditionFalse, "S3UserResourceAddFinalizerFailed", + fmt.Sprintf("Failed to add finalizer on userResource [%s] has failed", userResource.Name), err) + } + } + + secretKey, err := password.Generate(20, true, false, true) + if err != nil { + logger.Error(err, fmt.Sprintf("Fail to generate password for user %s", userResource.Name)) + return r.setS3UserStatusConditionAndUpdate(ctx, userResource, "OperatorFailed", metav1.ConditionFalse, "S3UserGeneratePasswordFailed", + fmt.Sprintf("An error occurred when attempting to generate password for user [%s]", userResource.Name), err) + } + + err = r.S3Client.CreateUser(userResource.Spec.AccessKey, secretKey) + + if err != nil { + logger.Error(err, "an error occurred while creating user on S3 server", "user", userResource.Name) + return r.setS3UserStatusConditionAndUpdate(ctx, userResource, "OperatorFailed", metav1.ConditionFalse, "S3UserCreationFailed", + fmt.Sprintf("Creation of user %s on S3 instance has failed", userResource.Name), err) + } + + policies := userResource.Spec.Policies + if policies != nil { + err := r.S3Client.AddPoliciesToUser(userResource.Spec.AccessKey, policies) + if err != nil { + logger.Error(err, "an error occurred while adding policy to user", "user", userResource.Name) + return r.setS3UserStatusConditionAndUpdate(ctx, userResource, "OperatorFailed", metav1.ConditionFalse, "S3UserCreationFailed", + fmt.Sprintf("Error while updating policies of user [%s] on S3 instance has failed", userResource.Name), err) + } + + } + + groups := userResource.Spec.Groups + if groups != nil { + err := r.S3Client.AddGroupsToUser(userResource.Spec.AccessKey, groups) + if err != nil { + logger.Error(err, "An error occurred while adding groups to user", "user", userResource.Name) + return r.setS3UserStatusConditionAndUpdate(ctx, userResource, "OperatorFailed", metav1.ConditionFalse, "S3UserCreationFailed", + fmt.Sprintf("Error while updating groups of user [%s] on S3 instance has failed", userResource.Name), err) + } + } + + // Define a new K8S Secrets + secret, err := r.newSecretForCR(ctx, userResource, map[string][]byte{"accessKey": []byte(userResource.Spec.AccessKey), "secretKey": []byte(secretKey)}) + if err != nil { + // Error while creating the Kubernetes secret - requeue the request. + logger.Error(err, "Could not generate Kubernetes secret") + return r.setS3UserStatusConditionAndUpdate(ctx, userResource, "OperatorFailed", metav1.ConditionFalse, "SecretGenerationFailed", + fmt.Sprintf("Generation of k8s secrets [%s] has failed", userResource.Name), err) + } + + // Check if this Secret already exists + err = r.Get(ctx, types.NamespacedName{Name: secret.Name, Namespace: secret.Namespace}, &corev1.Secret{}) + if err != nil && errors.IsNotFound(err) { + logger.Info("Creating a new Secret", "Secret.Namespace", secret.Namespace, "Secret.Name", secret.Name) + err = r.Create(ctx, secret) + if err != nil { + logger.Error(err, "Could not create secret") + return r.setS3UserStatusConditionAndUpdate(ctx, userResource, "OperatorFailed", metav1.ConditionFalse, "SecretCreationFailed", + fmt.Sprintf("Creation of k8s secrets [%s] has failed", secret.Name), err) + } + return r.setS3UserStatusConditionAndUpdate(ctx, userResource, "OperatorSucceeded", metav1.ConditionTrue, "SecretCreationSuccess", + fmt.Sprintf("The secret [%s] was created according to its matching custom resource", secret.Name), nil) + } else if err != nil { + logger.Error(err, "Could not create secret") + return r.setS3UserStatusConditionAndUpdate(ctx, userResource, "OperatorFailed", metav1.ConditionFalse, "SecretCreationFailed", + fmt.Sprintf("Creation of k8s secrets [%s] has failed", secret.Name), err) + } else { + if r.OverrideExistingSecret { + logger.Info(fmt.Sprintf("A secret with the name %s already exist. You choose to update existing secret.", userResource.Name)) + err = r.Update(ctx, secret) + if err != nil { + logger.Error(err, "Could not update secret") + return r.setS3UserStatusConditionAndUpdate(ctx, userResource, "OperatorFailed", metav1.ConditionFalse, "SecretUpdateFailed", + fmt.Sprintf("Update of k8s secrets [%s] has failed", secret.Name), err) + } + + return r.setS3UserStatusConditionAndUpdate(ctx, userResource, "OperatorSucceeded", metav1.ConditionTrue, "SecretCreationSuccess", + fmt.Sprintf("The secret [%s] was created according to its matching custom resource", secret.Name), nil) + } else { + return r.setS3UserStatusConditionAndUpdate(ctx, userResource, "OperatorFailed", metav1.ConditionFalse, "SecretCreationFailed", + fmt.Sprintf("A secret with the name %s already exist NOOP", secret.Name), nil) + } + } +} + +// SetupWithManager sets up the controller with the Manager.* +func (r *S3UserReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&s3v1alpha1.S3User{}). + Owns(&corev1.Secret{}). + // TODO : implement a real strategy for event filtering ; for now just using the example from OpSDK doc + // (https://sdk.operatorframework.io/docs/building-operators/golang/references/event-filtering/) + WithEventFilter(predicate.Funcs{ + UpdateFunc: func(e event.UpdateEvent) bool { + // Ignore updates to CR status in which case metadata.Generation does not change + return e.ObjectOld.GetGeneration() != e.ObjectNew.GetGeneration() + }, + DeleteFunc: func(e event.DeleteEvent) bool { + // Evaluates to false if the object has been confirmed deleted. + return !e.DeleteStateUnknown + }, + }). + WithOptions(controller.Options{MaxConcurrentReconciles: 10}). + Complete(r) +} + +func (r *S3UserReconciler) setS3UserStatusConditionAndUpdate(ctx context.Context, userResource *s3v1alpha1.S3User, conditionType string, status metav1.ConditionStatus, reason string, message string, srcError error) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + // We moved away from meta.SetStatusCondition, as the implementation did not allow for updating + // lastTransitionTime if a Condition (as identified by Reason instead of Type) was previously + // obtained and updated to again. + userResource.Status.Conditions = utils.UpdateConditions(userResource.Status.Conditions, metav1.Condition{ + Type: conditionType, + Status: status, + Reason: reason, + LastTransitionTime: metav1.NewTime(time.Now()), + Message: message, + ObservedGeneration: userResource.GetGeneration(), + }) + + err := r.Status().Update(ctx, userResource) + if err != nil { + logger.Error(err, "an error occurred while updating the status of the S3User resource") + return ctrl.Result{}, utilerrors.NewAggregate([]error{err, srcError}) + } + return ctrl.Result{}, srcError +} + +func (r *S3UserReconciler) finalizeS3User(userResource *s3v1alpha1.S3User) error { + if r.S3UserDeletion { + return r.S3Client.DeleteUser(userResource.Spec.AccessKey) + } + return nil +} + +// newSecretForCR returns a secret with the same name/namespace as the CR. The secret will include all labels and +// annotations from the CR. +func (r *S3UserReconciler) newSecretForCR(ctx context.Context, userResource *s3v1alpha1.S3User, data map[string][]byte) (*corev1.Secret, error) { + logger := log.FromContext(ctx) + + labels := map[string]string{} + for k, v := range userResource.ObjectMeta.Labels { + labels[k] = v + } + + annotations := map[string]string{} + for k, v := range userResource.ObjectMeta.Annotations { + annotations[k] = v + } + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: userResource.Name, + Namespace: userResource.Namespace, + Labels: labels, + Annotations: annotations, + }, + Data: data, + Type: "Opaque", + } + // Set S3User instance as the owner and controller + err := ctrl.SetControllerReference(userResource, secret, r.Scheme) + if err != nil { + logger.Error(err, "Could not set owner of kubernetes secret") + return nil, err + } + + return secret, nil + +} diff --git a/controllers/utils/password/password_generator.go b/controllers/utils/password/password_generator.go new file mode 100644 index 0000000..38d5f14 --- /dev/null +++ b/controllers/utils/password/password_generator.go @@ -0,0 +1,165 @@ +package password + +import ( + "crypto/rand" + "io" + "math/big" + "strings" +) + +type PasswordGenerator interface { + Generate(int, int, int, bool, bool) (string, error) +} + +const ( + // LowerLetters is the list of lowercase letters. + LowerLetters = "abcdefghijklmnopqrstuvwxyz" + + // UpperLetters is the list of uppercase letters. + UpperLetters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + // Digits is the list of permitted digits. + Digits = "0123456789" + + // Symbols is the list of symbols. + Symbols = "~!@#$%^&*()_+`-={}|[]\\:\"<>?,./" +) + +// func GeneratePassword(length int, useLetters bool, useSpecial bool, useNum bool) string { +func Generate(length int, useLetters bool, useSpecial bool, useNum bool) (string, error) { + gen, err := NewGenerator(nil) + if err != nil { + return "", err + } + return gen.Generate(length, true, false, true, true, true) +} + +// Generate generates a password with the given requirements. length is the +// total number of characters in the password. numDigits is the number of digits +// to include in the result. numSymbols is the number of symbols to include in +// the result. noUpper excludes uppercase letters from the results. allowRepeat +// allows characters to repeat. +// +// The algorithm is fast, but it's not designed to be performant; it favors +// entropy over speed. This function is safe for concurrent use. +func (g *Generator) Generate(length int, useDigit bool, useSymbol bool, useUpper bool, useLower bool, allowRepeat bool) (string, error) { + choices := "" + + if !useDigit { + choices += g.lowerLetters + } + + if !useSymbol { + choices += g.symbols + } + + if !useUpper { + choices += g.upperLetters + } + + if !useLower { + choices += g.lowerLetters + } + + var result string + + for i := 0; i < length; i++ { + ch, err := randomElement(g.reader, choices) + if err != nil { + return "", err + } + + if !allowRepeat && strings.Contains(result, ch) { + i-- + continue + } + + result, err = randomInsert(g.reader, result, ch) + if err != nil { + return "", err + } + } + + return result, nil +} + +// Generator is the stateful generator which can be used to customize the list +// of letters, digits, and/or symbols. +type Generator struct { + lowerLetters string + upperLetters string + digits string + symbols string + reader io.Reader +} + +// GeneratorInput is used as input to the NewGenerator function. +type GeneratorInput struct { + LowerLetters string + UpperLetters string + Digits string + Symbols string + Reader io.Reader // rand.Reader by default +} + +// NewGenerator creates a new Generator from the specified configuration. If no +// input is given, all the default values are used. This function is safe for +// concurrent use. +func NewGenerator(i *GeneratorInput) (*Generator, error) { + if i == nil { + i = new(GeneratorInput) + } + + g := &Generator{ + lowerLetters: i.LowerLetters, + upperLetters: i.UpperLetters, + digits: i.Digits, + symbols: i.Symbols, + reader: i.Reader, + } + + if g.lowerLetters == "" { + g.lowerLetters = LowerLetters + } + + if g.upperLetters == "" { + g.upperLetters = UpperLetters + } + + if g.digits == "" { + g.digits = Digits + } + + if g.symbols == "" { + g.symbols = Symbols + } + + if g.reader == nil { + g.reader = rand.Reader + } + + return g, nil +} + +// randomInsert randomly inserts the given value into the given string. +func randomInsert(reader io.Reader, s, val string) (string, error) { + if s == "" { + return val, nil + } + + n, err := rand.Int(reader, big.NewInt(int64(len(s)+1))) + if err != nil { + return "", err + } + i := n.Int64() + return s[0:i] + val + s[i:], nil +} + +// randomElement extracts a random element from the given string. +func randomElement(reader io.Reader, s string) (string, error) { + n, err := rand.Int(reader, big.NewInt(int64(len(s)))) + if err != nil { + return "", err + } + return string(s[n.Int64()]), nil +} diff --git a/main.go b/main.go index c12a83f..30abd08 100644 --- a/main.go +++ b/main.go @@ -81,6 +81,10 @@ func main() { var bucketDeletion bool var policyDeletion bool var pathDeletion bool + var s3userDeletion bool + + //K8S related variable + var overrideExistingSecret bool flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") @@ -100,6 +104,8 @@ func main() { flag.BoolVar(&bucketDeletion, "bucket-deletion", false, "Trigger bucket deletion on the S3 backend upon CR deletion. Will fail if bucket is not empty.") flag.BoolVar(&policyDeletion, "policy-deletion", false, "Trigger policy deletion on the S3 backend upon CR deletion") flag.BoolVar(&pathDeletion, "path-deletion", false, "Trigger path deletion on the S3 backend upon CR deletion. Limited to deleting the `.keep` files used by the operator.") + flag.BoolVar(&s3userDeletion, "s3user-deletion", false, "Trigger S3 deletion on the S3 backend upon CR deletion") + flag.BoolVar(&overrideExistingSecret, "override-existing-secret", false, "Override existing secret associated to user in case of the secret already exist") opts := zap.Options{ Development: true, @@ -193,6 +199,16 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "Policy") os.Exit(1) } + if err = (&controllers.S3UserReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + S3Client: s3Client, + S3UserDeletion: s3userDeletion, + OverrideExistingSecret: overrideExistingSecret, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "S3User") + os.Exit(1) + } //+kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {