diff --git a/api/v1alpha1/postgresqlexternalserviceuser_types.go b/api/v1alpha1/postgresqlexternalserviceuser_types.go new file mode 100644 index 00000000..78b06df0 --- /dev/null +++ b/api/v1alpha1/postgresqlexternalserviceuser_types.go @@ -0,0 +1,132 @@ +/* +Copyright 2024. + +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 ( + apiv1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// PostgreSQLExternalServiceUserSpec defines the desired state of PostgreSQLExternalServiceUser. +// +k8s:openapi-gen=true +type PostgreSQLExternalServiceUserSpec struct { + // PrincipalArn is the ARN of the IAM user or role to grant RDS IAM authentication access. + // Accepts IAM user ARNs (arn:aws:iam::ACCOUNT:user/NAME) and role ARNs + // (arn:aws:iam::ACCOUNT:role/NAME) from any AWS account. + PrincipalArn string `json:"principalArn"` + + // Host is the RDS instance to connect to. + Host ResourceVar `json:"host"` + + // DBUsername is the Postgres role to create with IAM authentication enabled (rds_iam). + // No password is set — authentication is performed exclusively via IAM. + DBUsername string `json:"dbUsername"` + + // Roles lists Postgres roles to grant to DBUsername. + // +optional + Roles []PostgreSQLExternalServiceUserRole `json:"roles,omitempty"` +} + +// PostgreSQLExternalServiceUserRole defines a Postgres role to grant to the external service user. +// +k8s:openapi-gen=true +type PostgreSQLExternalServiceUserRole struct { + // RoleName is the name of the Postgres role to grant. + RoleName string `json:"roleName"` +} + +// PostgreSQLExternalServiceUserConditionType represents the current phase of a PostgreSQL external service user. +// +k8s:openapi-gen=true +type PostgreSQLExternalServiceUserConditionType string + +const ( + // PostgreSQLExternalServiceUserPhaseFailed indicates the controller was unable to reconcile the resource. + PostgreSQLExternalServiceUserPhaseFailed PostgreSQLExternalServiceUserConditionType = "Failed" + // PostgreSQLExternalServiceUserPhaseInvalid indicates the spec was not valid and will not be retried until changed. + PostgreSQLExternalServiceUserPhaseInvalid PostgreSQLExternalServiceUserConditionType = "Invalid" + // PostgreSQLExternalServiceUserPhaseRunning indicates the controller has successfully reconciled the resource. + PostgreSQLExternalServiceUserPhaseRunning PostgreSQLExternalServiceUserConditionType = "Running" +) + +// PostgreSQLExternalServiceUserCondition describes the state of a PostgreSQL external service user. +type PostgreSQLExternalServiceUserCondition struct { + // Type is the current state of the resource. + Type PostgreSQLExternalServiceUserConditionType `json:"type" protobuf:"bytes,1,opt,name=type,casttype=PostgreSQLExternalServiceUserConditionType"` + + // Status of the condition. + Status apiv1.ConditionStatus `json:"status" protobuf:"bytes,2,opt,name=status,casttype=k8s.io/api/core/v1.ConditionStatus"` + + // LastUpdateTime is the last time this condition was updated. + LastUpdateTime metav1.Time `json:"lastUpdateTime,omitempty" protobuf:"bytes,6,opt,name=lastUpdateTime"` + + // LastTransitionTime is the last time the condition transitioned from one status to another. + LastTransitionTime metav1.Time `json:"lastTransitionTime,omitempty" protobuf:"bytes,7,opt,name=lastTransitionTime"` + + // Reason is a brief machine-readable explanation for the condition's last transition. + Reason string `json:"reason,omitempty" protobuf:"bytes,4,opt,name=reason"` + + // Message is a human-readable message indicating details about the transition. + Message string `json:"message,omitempty" protobuf:"bytes,5,opt,name=message"` +} + +// PostgreSQLExternalServiceUserStatus defines the observed state of PostgreSQLExternalServiceUser. +type PostgreSQLExternalServiceUserStatus struct { + // ObservedGeneration reflects the generation most recently observed by the controller. + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty" protobuf:"varint,1,opt,name=observedGeneration"` + + // Conditions represents the latest available observations of the resource's current state. + // +optional + // +patchMergeKey=type + // +patchStrategy=merge + Conditions []PostgreSQLExternalServiceUserCondition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,2,rep,name=conditions"` + + // DBUsername is the last successfully reconciled Postgres role name. + // Tracked so the controller can clean up the old IAM policy and Postgres + // role if spec.dbUsername is changed. + // +optional + DBUsername string `json:"dbUsername,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Principal",type="string",JSONPath=".spec.principalArn" +// +kubebuilder:printcolumn:name="DBUser",type="string",JSONPath=".spec.dbUsername" +// +kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.conditions[0].message" + +// PostgreSQLExternalServiceUser is the Schema for the postgresqlexternalserviceusers API. +// It grants an IAM principal (user or role from any AWS account) RDS IAM authentication +// access to a PostgreSQL database, without requiring a password. +type PostgreSQLExternalServiceUser struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec PostgreSQLExternalServiceUserSpec `json:"spec"` + Status *PostgreSQLExternalServiceUserStatus `json:"status,omitempty"` +} + +//+kubebuilder:object:root=true + +// PostgreSQLExternalServiceUserList contains a list of PostgreSQLExternalServiceUser. +type PostgreSQLExternalServiceUserList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata"` + Items []PostgreSQLExternalServiceUser `json:"items"` +} + +func init() { + SchemeBuilder.Register(&PostgreSQLExternalServiceUser{}, &PostgreSQLExternalServiceUserList{}) +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 26e1da0c..cb6116f5 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -334,6 +334,144 @@ func (in *PostgreSQLDatabaseStatus) DeepCopy() *PostgreSQLDatabaseStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PostgreSQLExternalServiceUser) DeepCopyInto(out *PostgreSQLExternalServiceUser) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + if in.Status != nil { + in, out := &in.Status, &out.Status + *out = new(PostgreSQLExternalServiceUserStatus) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLExternalServiceUser. +func (in *PostgreSQLExternalServiceUser) DeepCopy() *PostgreSQLExternalServiceUser { + if in == nil { + return nil + } + out := new(PostgreSQLExternalServiceUser) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *PostgreSQLExternalServiceUser) 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 *PostgreSQLExternalServiceUserCondition) DeepCopyInto(out *PostgreSQLExternalServiceUserCondition) { + *out = *in + in.LastUpdateTime.DeepCopyInto(&out.LastUpdateTime) + in.LastTransitionTime.DeepCopyInto(&out.LastTransitionTime) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLExternalServiceUserCondition. +func (in *PostgreSQLExternalServiceUserCondition) DeepCopy() *PostgreSQLExternalServiceUserCondition { + if in == nil { + return nil + } + out := new(PostgreSQLExternalServiceUserCondition) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PostgreSQLExternalServiceUserList) DeepCopyInto(out *PostgreSQLExternalServiceUserList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]PostgreSQLExternalServiceUser, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLExternalServiceUserList. +func (in *PostgreSQLExternalServiceUserList) DeepCopy() *PostgreSQLExternalServiceUserList { + if in == nil { + return nil + } + out := new(PostgreSQLExternalServiceUserList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *PostgreSQLExternalServiceUserList) 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 *PostgreSQLExternalServiceUserRole) DeepCopyInto(out *PostgreSQLExternalServiceUserRole) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLExternalServiceUserRole. +func (in *PostgreSQLExternalServiceUserRole) DeepCopy() *PostgreSQLExternalServiceUserRole { + if in == nil { + return nil + } + out := new(PostgreSQLExternalServiceUserRole) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PostgreSQLExternalServiceUserSpec) DeepCopyInto(out *PostgreSQLExternalServiceUserSpec) { + *out = *in + in.Host.DeepCopyInto(&out.Host) + if in.Roles != nil { + in, out := &in.Roles, &out.Roles + *out = make([]PostgreSQLExternalServiceUserRole, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLExternalServiceUserSpec. +func (in *PostgreSQLExternalServiceUserSpec) DeepCopy() *PostgreSQLExternalServiceUserSpec { + if in == nil { + return nil + } + out := new(PostgreSQLExternalServiceUserSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PostgreSQLExternalServiceUserStatus) DeepCopyInto(out *PostgreSQLExternalServiceUserStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]PostgreSQLExternalServiceUserCondition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgreSQLExternalServiceUserStatus. +func (in *PostgreSQLExternalServiceUserStatus) DeepCopy() *PostgreSQLExternalServiceUserStatus { + if in == nil { + return nil + } + out := new(PostgreSQLExternalServiceUserStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PostgreSQLHostCredentials) DeepCopyInto(out *PostgreSQLHostCredentials) { *out = *in diff --git a/cmd/main.go b/cmd/main.go index 951f0c0d..bf00947e 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -190,6 +190,26 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "PostgreSQLServiceUser") os.Exit(1) } + if err = (&controller.PostgreSQLExternalServiceUserReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + + EnsureIAMExternalServiceUser: iam.EnsureExternalServiceUser, + RemoveIAMExternalServiceUser: iam.RemoveExternalServiceUser, + + AWSPolicyName: config.AWS.PolicyName, + AWSRegion: config.AWS.Region, + AWSAccountID: config.AWS.AccountID, + AWSProfile: config.AWS.Profile, + AWSAccessKeyID: config.AWS.AccessKeyID, + AWSSecretAccessKey: config.AWS.SecretAccessKey, + IAMPolicyPrefix: config.IAMPolicyPrefix, + AWSLoginRoles: config.GetLoginRoles(), + HostCredentials: config.HostCredentials, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "PostgreSQLExternalServiceUser") + os.Exit(1) + } if err = (&controller.CustomRoleReconciler{ Client: mgr.GetClient(), Log: ctrl.Log.WithName("controllers").WithName("CustomRole"), diff --git a/config/crd/bases/postgresql.lunar.tech_postgresqlexternalserviceusers.yaml b/config/crd/bases/postgresql.lunar.tech_postgresqlexternalserviceusers.yaml new file mode 100644 index 00000000..5d1225e9 --- /dev/null +++ b/config/crd/bases/postgresql.lunar.tech_postgresqlexternalserviceusers.yaml @@ -0,0 +1,178 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.20.1 + name: postgresqlexternalserviceusers.postgresql.lunar.tech +spec: + group: postgresql.lunar.tech + names: + kind: PostgreSQLExternalServiceUser + listKind: PostgreSQLExternalServiceUserList + plural: postgresqlexternalserviceusers + singular: postgresqlexternalserviceuser + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.principalArn + name: Principal + type: string + - jsonPath: .spec.dbUsername + name: DBUser + type: string + - jsonPath: .status.conditions[0].message + name: Status + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + PostgreSQLExternalServiceUser is the Schema for the postgresqlexternalserviceusers API. + It grants an IAM principal (user or role from any AWS account) RDS IAM authentication + access to a PostgreSQL database, without requiring a password. + 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: PostgreSQLExternalServiceUserSpec defines the desired state + of PostgreSQLExternalServiceUser. + properties: + dbUsername: + description: |- + DBUsername is the Postgres role to create with IAM authentication enabled (rds_iam). + No password is set — authentication is performed exclusively via IAM. + type: string + host: + description: Host is the RDS instance to connect to. + properties: + value: + description: Defaults to "". + type: string + valueFrom: + description: Source to read the value from. + properties: + configMapKeyRef: + description: Selects a key of a config map in the custom resource's + namespace + properties: + key: + description: The key of the secret or config map to select + from. Must be a valid key. + type: string + name: + description: The name of the secret or config map in the + namespace to select from. + type: string + required: + - key + type: object + secretKeyRef: + description: Selects a key of a secret in the custom resource's + namespace + properties: + key: + description: The key of the secret or config map to select + from. Must be a valid key. + type: string + name: + description: The name of the secret or config map in the + namespace to select from. + type: string + required: + - key + type: object + type: object + type: object + principalArn: + description: |- + PrincipalArn is the ARN of the IAM user or role to grant RDS IAM authentication access. + Accepts IAM user ARNs (arn:aws:iam::ACCOUNT:user/NAME) and role ARNs + (arn:aws:iam::ACCOUNT:role/NAME) from any AWS account. + type: string + roles: + description: Roles lists Postgres roles to grant to DBUsername. + items: + description: PostgreSQLExternalServiceUserRole defines a Postgres + role to grant to the external service user. + properties: + roleName: + description: RoleName is the name of the Postgres role to grant. + type: string + required: + - roleName + type: object + type: array + required: + - dbUsername + - host + - principalArn + type: object + status: + description: PostgreSQLExternalServiceUserStatus defines the observed + state of PostgreSQLExternalServiceUser. + properties: + conditions: + description: Conditions represents the latest available observations + of the resource's current state. + items: + description: PostgreSQLExternalServiceUserCondition describes the + state of a PostgreSQL external service user. + properties: + lastTransitionTime: + description: LastTransitionTime is the last time the condition + transitioned from one status to another. + format: date-time + type: string + lastUpdateTime: + description: LastUpdateTime is the last time this condition + was updated. + format: date-time + type: string + message: + description: Message is a human-readable message indicating + details about the transition. + type: string + reason: + description: Reason is a brief machine-readable explanation + for the condition's last transition. + type: string + status: + description: Status of the condition. + type: string + type: + description: Type is the current state of the resource. + type: string + required: + - status + - type + type: object + type: array + observedGeneration: + description: ObservedGeneration reflects the generation most recently + observed by the controller. + format: int64 + type: integer + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 977aa57e..8b5fa3db 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -17,6 +17,7 @@ rules: resources: - customroles - postgresqldatabases + - postgresqlexternalserviceusers - postgresqlserviceusers - postgresqlusers verbs: @@ -31,6 +32,7 @@ rules: - postgresql.lunar.tech resources: - customroles/finalizers + - postgresqlexternalserviceusers/finalizers - postgresqlhostcredentials/finalizers - postgresqlserviceusers/finalizers - postgresqlusers/finalizers @@ -41,6 +43,7 @@ rules: resources: - customroles/status - postgresqldatabases/status + - postgresqlexternalserviceusers/status - postgresqlhostcredentials/status - postgresqlserviceusers/status - postgresqlusers/status diff --git a/internal/controller/postgresqlexternalserviceuser_controller.go b/internal/controller/postgresqlexternalserviceuser_controller.go new file mode 100644 index 00000000..fa74fe3b --- /dev/null +++ b/internal/controller/postgresqlexternalserviceuser_controller.go @@ -0,0 +1,300 @@ +/* +Copyright 2024. + +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 controller + +import ( + "context" + "database/sql" + "fmt" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/session" + apiv1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + 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/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + postgresqlv1alpha1 "go.lunarway.com/postgresql-controller/api/v1alpha1" + "go.lunarway.com/postgresql-controller/pkg/iam" + "go.lunarway.com/postgresql-controller/pkg/kube" + "go.lunarway.com/postgresql-controller/pkg/postgres" + + "github.com/go-logr/logr" +) + +const externalServiceUserFinalizer = "postgresqlexternalserviceuser.lunar.tech/finalizer" + +// PostgreSQLExternalServiceUserReconciler reconciles a PostgreSQLExternalServiceUser object. +type PostgreSQLExternalServiceUserReconciler struct { + client.Client + Scheme *runtime.Scheme + + EnsureIAMExternalServiceUser func(client *iam.Client, log logr.Logger, config iam.EnsureExternalServiceUserConfig, principalArn, dbUsername string) error + RemoveIAMExternalServiceUser func(client *iam.Client, log logr.Logger, config iam.EnsureExternalServiceUserConfig, dbUsername string) error + + AWSPolicyName string + AWSRegion string + AWSAccountID string + AWSProfile string + AWSAccessKeyID string + AWSSecretAccessKey string + IAMPolicyPrefix string + AWSLoginRoles []string + HostCredentials map[string]postgres.Credentials +} + +//+kubebuilder:rbac:groups=postgresql.lunar.tech,resources=postgresqlexternalserviceusers,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=postgresql.lunar.tech,resources=postgresqlexternalserviceusers/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=postgresql.lunar.tech,resources=postgresqlexternalserviceusers/finalizers,verbs=update +//+kubebuilder:rbac:groups="",resources=secrets;configmaps,verbs=get;list + +func (r *PostgreSQLExternalServiceUserReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + reqLogger := log.FromContext(ctx) + reqLogger.V(1).Info("Reconciling PostgreSQLExternalServiceUser") + + result, err := r.reconcile(ctx, reqLogger, req) + if err != nil { + reqLogger.Error(err, "Failed to reconcile PostgreSQLExternalServiceUser") + } + return result, err +} + +func (r *PostgreSQLExternalServiceUserReconciler) reconcile(ctx context.Context, reqLogger logr.Logger, req reconcile.Request) (ctrl.Result, error) { + obj := &postgresqlv1alpha1.PostgreSQLExternalServiceUser{} + if err := r.Client.Get(ctx, req.NamespacedName, obj); err != nil { + if apierrors.IsNotFound(err) { + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + + reqLogger = reqLogger.WithValues("principalArn", obj.Spec.PrincipalArn, "dbUsername", obj.Spec.DBUsername) + + // Build the AWS IAM client used for policy management. + awsCfg := &aws.Config{ + Region: aws.String(r.AWSRegion), + Credentials: r.getCredentials(), + } + awsSession, err := session.NewSession(awsCfg) + if err != nil { + return ctrl.Result{}, r.persistFailed(ctx, obj, fmt.Errorf("initialise AWS session for region %s: %w", r.AWSRegion, err)) + } + iamClient := iam.NewClient(awsSession, reqLogger, r.AWSAccountID, r.IAMPolicyPrefix) + + iamConfig := iam.EnsureExternalServiceUserConfig{ + Region: r.AWSRegion, + AccountID: r.AWSAccountID, + PolicyBaseName: r.AWSPolicyName, + AWSLoginRoles: r.AWSLoginRoles, + } + + // Handle deletion via finalizer. + if !obj.DeletionTimestamp.IsZero() { + if controllerutil.ContainsFinalizer(obj, externalServiceUserFinalizer) { + reqLogger.V(1).Info("Cleaning up PostgreSQLExternalServiceUser before deletion") + + if err := r.RemoveIAMExternalServiceUser(iamClient, reqLogger, iamConfig, obj.Spec.DBUsername); err != nil { + return ctrl.Result{}, fmt.Errorf("remove IAM policy for %s: %w", obj.Spec.DBUsername, err) + } + + // Drop the Postgres role. Logged but non-blocking: if the host is + // unavailable the IAM policy has already been removed and the role + // will be inert. + if err := r.dropPostgresRole(ctx, reqLogger, req.Namespace, obj.Spec.Host, obj.Spec.DBUsername); err != nil { + reqLogger.Error(err, "Failed to drop Postgres role during deletion; continuing") + } + + controllerutil.RemoveFinalizer(obj, externalServiceUserFinalizer) + if err := r.Update(ctx, obj); err != nil { + return ctrl.Result{}, fmt.Errorf("remove finalizer: %w", err) + } + } + return ctrl.Result{}, nil + } + + // Ensure the finalizer is registered before doing any work. + if !controllerutil.ContainsFinalizer(obj, externalServiceUserFinalizer) { + controllerutil.AddFinalizer(obj, externalServiceUserFinalizer) + if err := r.Update(ctx, obj); err != nil { + return ctrl.Result{}, fmt.Errorf("add finalizer: %w", err) + } + return ctrl.Result{Requeue: true}, nil + } + + // If spec.dbUsername was renamed, clean up the stale IAM policy and Postgres + // role before creating resources under the new name. + if obj.Status != nil && obj.Status.DBUsername != "" && obj.Status.DBUsername != obj.Spec.DBUsername { + previousUsername := obj.Status.DBUsername + reqLogger.Info("dbUsername changed, cleaning up previous resources", "previous", previousUsername, "new", obj.Spec.DBUsername) + + if err := r.RemoveIAMExternalServiceUser(iamClient, reqLogger, iamConfig, previousUsername); err != nil { + return ctrl.Result{}, r.persistFailed(ctx, obj, fmt.Errorf("remove old IAM policy for %s: %w", previousUsername, err)) + } + if err := r.dropPostgresRole(ctx, reqLogger, req.Namespace, obj.Spec.Host, previousUsername); err != nil { + reqLogger.Error(err, "Failed to drop previous Postgres role after rename; continuing", "role", previousUsername) + } + } + + // Connect to the target Postgres instance. + db, err := r.connectPostgres(ctx, req.Namespace, obj) + if err != nil { + return ctrl.Result{}, r.persistFailed(ctx, obj, fmt.Errorf("connect to postgres: %w", err)) + } + defer db.Close() + + // Build the desired Postgres role list: rds_iam is always required for + // IAM-based RDS authentication, plus any additional roles from the spec. + desiredRoles := []string{"rds_iam"} + for _, role := range obj.Spec.Roles { + desiredRoles = append(desiredRoles, role.RoleName) + } + + // Ensure the Postgres LOGIN role exists and has the correct grants. + if err := postgres.EnsureIAMLoginRole(reqLogger, db, obj.Spec.DBUsername, desiredRoles); err != nil { + return ctrl.Result{}, r.persistFailed(ctx, obj, fmt.Errorf("ensure postgres role %s: %w", obj.Spec.DBUsername, err)) + } + + // Ensure the IAM policy exists and is attached to the login roles. + if err := r.EnsureIAMExternalServiceUser(iamClient, reqLogger, iamConfig, obj.Spec.PrincipalArn, obj.Spec.DBUsername); err != nil { + return ctrl.Result{}, r.persistFailed(ctx, obj, fmt.Errorf("ensure IAM policy for %s: %w", obj.Spec.DBUsername, err)) + } + + return ctrl.Result{}, r.persistRunning(ctx, obj) +} + +// connectPostgres resolves the host ResourceVar and opens a connection using the +// matching entry in HostCredentials. +func (r *PostgreSQLExternalServiceUserReconciler) connectPostgres(ctx context.Context, namespace string, obj *postgresqlv1alpha1.PostgreSQLExternalServiceUser) (*sql.DB, error) { + host, err := kube.ResourceValue(r.Client, obj.Spec.Host, namespace) + if err != nil { + return nil, fmt.Errorf("resolve host: %w", err) + } + + creds, ok := r.HostCredentials[host] + if !ok { + return nil, fmt.Errorf("no credentials configured for host %q", host) + } + + connStr := postgres.ConnectionString{ + Host: host, + Database: creds.Name, + User: creds.User, + Password: creds.Password, + Params: creds.Params, + } + db, err := postgres.Connect(connStr) + if err != nil { + return nil, fmt.Errorf("connect to %s: %w", connStr, err) + } + return db, nil +} + +// dropPostgresRole connects to Postgres via the given host ResourceVar and drops +// the named role. Accepting host and roleName separately allows it to be called +// with a previous username during a rename, or with spec values during deletion. +func (r *PostgreSQLExternalServiceUserReconciler) dropPostgresRole(ctx context.Context, log logr.Logger, namespace string, host postgresqlv1alpha1.ResourceVar, roleName string) error { + resolvedHost, err := kube.ResourceValue(r.Client, host, namespace) + if err != nil { + return fmt.Errorf("resolve host: %w", err) + } + creds, ok := r.HostCredentials[resolvedHost] + if !ok { + return fmt.Errorf("no credentials configured for host %q", resolvedHost) + } + db, err := postgres.Connect(postgres.ConnectionString{ + Host: resolvedHost, + Database: creds.Name, + User: creds.User, + Password: creds.Password, + Params: creds.Params, + }) + if err != nil { + return fmt.Errorf("connect to %s: %w", resolvedHost, err) + } + defer db.Close() + return postgres.DropCustomRole(log, db, roleName) +} + +// getCredentials returns AWS credentials from either a shared profile or +// static key/secret, mirroring the PostgreSQLUserReconciler approach. +func (r *PostgreSQLExternalServiceUserReconciler) getCredentials() *credentials.Credentials { + if r.AWSProfile != "" { + return credentials.NewSharedCredentials("", r.AWSProfile) + } + return credentials.NewStaticCredentials(r.AWSAccessKeyID, r.AWSSecretAccessKey, "") +} + +// persistRunning updates the resource status to Running after a successful reconcile. +// DBUsername is recorded so the controller can detect renames on the next reconcile. +func (r *PostgreSQLExternalServiceUserReconciler) persistRunning(ctx context.Context, obj *postgresqlv1alpha1.PostgreSQLExternalServiceUser) error { + now := metav1.Now() + obj.Status = &postgresqlv1alpha1.PostgreSQLExternalServiceUserStatus{ + ObservedGeneration: obj.Generation, + DBUsername: obj.Spec.DBUsername, + Conditions: []postgresqlv1alpha1.PostgreSQLExternalServiceUserCondition{ + { + Type: postgresqlv1alpha1.PostgreSQLExternalServiceUserPhaseRunning, + Status: apiv1.ConditionTrue, + LastUpdateTime: now, + LastTransitionTime: now, + Message: "Reconciled successfully", + }, + }, + } + if err := r.Client.Status().Update(ctx, obj); err != nil { + return fmt.Errorf("update status to Running: %w", err) + } + return nil +} + +// persistFailed updates the resource status to Failed and returns the original error +// so the reconcile loop requeues. +func (r *PostgreSQLExternalServiceUserReconciler) persistFailed(ctx context.Context, obj *postgresqlv1alpha1.PostgreSQLExternalServiceUser, reconcileErr error) error { + now := metav1.Now() + obj.Status = &postgresqlv1alpha1.PostgreSQLExternalServiceUserStatus{ + ObservedGeneration: obj.Generation, + Conditions: []postgresqlv1alpha1.PostgreSQLExternalServiceUserCondition{ + { + Type: postgresqlv1alpha1.PostgreSQLExternalServiceUserPhaseFailed, + Status: apiv1.ConditionTrue, + LastUpdateTime: now, + LastTransitionTime: now, + Message: reconcileErr.Error(), + }, + }, + } + if err := r.Client.Status().Update(ctx, obj); err != nil { + // Log but don't mask the original error. + log.Log.Error(err, "Failed to update status to Failed") + } + return reconcileErr +} + +// SetupWithManager registers the controller with the Manager. +func (r *PostgreSQLExternalServiceUserReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&postgresqlv1alpha1.PostgreSQLExternalServiceUser{}). + WithOptions(controller.Options{MaxConcurrentReconciles: 1}). + Complete(r) +} diff --git a/internal/controller/postgresqlexternalserviceuser_controller_test.go b/internal/controller/postgresqlexternalserviceuser_controller_test.go new file mode 100644 index 00000000..ecd30ecd --- /dev/null +++ b/internal/controller/postgresqlexternalserviceuser_controller_test.go @@ -0,0 +1,180 @@ +package controller + +import ( + "context" + "database/sql" + "fmt" + "testing" + "time" + + "github.com/go-logr/logr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + lunarwayv1alpha1 "go.lunarway.com/postgresql-controller/api/v1alpha1" + "go.lunarway.com/postgresql-controller/pkg/iam" + "go.lunarway.com/postgresql-controller/pkg/postgres" + "go.lunarway.com/postgresql-controller/test" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +// TestExternalServiceUser_reconcile_unknownHost verifies that reconciliation +// returns an error when the host is not in HostCredentials. +func TestExternalServiceUser_reconcile_unknownHost(t *testing.T) { + namespace := "default" + dbUsername := "svc_user" + + resource := &lunarwayv1alpha1.PostgreSQLExternalServiceUser{ + ObjectMeta: metav1.ObjectMeta{Name: dbUsername, Namespace: namespace}, + Spec: lunarwayv1alpha1.PostgreSQLExternalServiceUserSpec{ + PrincipalArn: "arn:aws:iam::478824949770:user/VVCTenantUser", + Host: lunarwayv1alpha1.ResourceVar{Value: "unknown-host:5432"}, + DBUsername: dbUsername, + }, + } + + s := scheme.Scheme + s.AddKnownTypes(lunarwayv1alpha1.GroupVersion, resource, &lunarwayv1alpha1.PostgreSQLExternalServiceUserList{}) + + cl := fake.NewClientBuilder(). + WithRuntimeObjects([]runtime.Object{resource}...). + WithStatusSubresource(resource). + Build() + + r := &PostgreSQLExternalServiceUserReconciler{ + Client: cl, + Scheme: s, + EnsureIAMExternalServiceUser: func(_ *iam.Client, _ logr.Logger, _ iam.EnsureExternalServiceUserConfig, _, _ string) error { + t.Fatal("EnsureIAMExternalServiceUser should not be called when host is unknown") + return nil + }, + RemoveIAMExternalServiceUser: func(_ *iam.Client, _ logr.Logger, _ iam.EnsureExternalServiceUserConfig, _ string) error { + return nil + }, + AWSRegion: "eu-west-1", + AWSAccountID: "000000000000", + AWSAccessKeyID: testAWSKeyID, + AWSSecretAccessKey: testAWSSecretKey, + IAMPolicyPrefix: "/test/", + AWSPolicyName: "test-policy", + HostCredentials: map[string]postgres.Credentials{}, // empty — host not found + } + + req := reconcile.Request{NamespacedName: types.NamespacedName{Name: dbUsername, Namespace: namespace}} + + // First pass adds the finalizer. + _, err := r.Reconcile(context.Background(), req) + require.NoError(t, err) + + // Second pass hits the unknown host error. + _, err = r.Reconcile(context.Background(), req) + assert.Error(t, err, "expected error when host is not in HostCredentials") +} + +// TestExternalServiceUser_reconcile_deletion verifies that finalizer cleanup +// calls RemoveIAMExternalServiceUser and drops the Postgres role. +func TestExternalServiceUser_reconcile_deletion(t *testing.T) { + logf.SetLogger(zap.New(zap.UseDevMode(true))) + + host := test.Integration(t) + + var ( + namespace = "default" + dbUsername = fmt.Sprintf("ext_svc_del_%d", time.Now().UnixNano()) + ) + + now := metav1.Now() + resource := &lunarwayv1alpha1.PostgreSQLExternalServiceUser{ + ObjectMeta: metav1.ObjectMeta{ + Name: dbUsername, + Namespace: namespace, + DeletionTimestamp: &now, + Finalizers: []string{externalServiceUserFinalizer}, + }, + Spec: lunarwayv1alpha1.PostgreSQLExternalServiceUserSpec{ + PrincipalArn: "arn:aws:iam::478824949770:user/VVCTenantUser", + Host: lunarwayv1alpha1.ResourceVar{Value: host}, + DBUsername: dbUsername, + }, + } + + s := scheme.Scheme + s.AddKnownTypes(lunarwayv1alpha1.GroupVersion, resource, &lunarwayv1alpha1.PostgreSQLExternalServiceUserList{}) + + cl := fake.NewClientBuilder(). + WithRuntimeObjects([]runtime.Object{resource}...). + WithStatusSubresource(resource). + Build() + + // Pre-create the role so deletion has something to drop. + adminDB, err := postgres.Connect(postgres.ConnectionString{ + Host: host, Database: "postgres", User: testPostgresUser, Password: testPostgresPassword, + }) + require.NoError(t, err) + _, err = adminDB.Exec(fmt.Sprintf("CREATE ROLE %s WITH LOGIN", dbUsername)) + require.NoError(t, err) + adminDB.Close() + + var removeCalled bool + r := &PostgreSQLExternalServiceUserReconciler{ + Client: cl, + Scheme: s, + EnsureIAMExternalServiceUser: func(_ *iam.Client, _ logr.Logger, _ iam.EnsureExternalServiceUserConfig, _, _ string) error { + return nil + }, + RemoveIAMExternalServiceUser: func(_ *iam.Client, _ logr.Logger, _ iam.EnsureExternalServiceUserConfig, username string) error { + removeCalled = true + assert.Equal(t, dbUsername, username) + return nil + }, + AWSRegion: "eu-west-1", + AWSAccountID: "000000000000", + AWSAccessKeyID: testAWSKeyID, + AWSSecretAccessKey: testAWSSecretKey, + IAMPolicyPrefix: "/test/", + AWSPolicyName: "test-policy", + HostCredentials: map[string]postgres.Credentials{ + host: {Name: "postgres", User: testPostgresUser, Password: testPostgresPassword}, + }, + } + + req := reconcile.Request{NamespacedName: types.NamespacedName{Name: dbUsername, Namespace: namespace}} + _, err = r.Reconcile(context.Background(), req) + require.NoError(t, err) + + assert.True(t, removeCalled, "RemoveIAMExternalServiceUser should have been called") + + db, err := postgres.Connect(postgres.ConnectionString{ + Host: host, Database: "postgres", User: testPostgresUser, Password: testPostgresPassword, + }) + require.NoError(t, err) + defer db.Close() + assertRoleNotExists(t, db, dbUsername) +} + +func assertRoleNotExists(t *testing.T, db *sql.DB, roleName string) { + t.Helper() + var exists bool + err := db.QueryRow("SELECT EXISTS(SELECT 1 FROM pg_roles WHERE rolname = $1)", roleName).Scan(&exists) + require.NoError(t, err, "failed to query pg_roles for %s", roleName) + assert.False(t, exists, "expected role %s to not exist", roleName) +} + +// testPostgresUser and testPostgresPassword are the credentials for the +// integration test Postgres instance (local Docker container, not production). +const ( + testPostgresUser = "iam_creator" + testPostgresPassword = testPostgresUser //nolint:gosec — local Docker test container only + testAWSKeyID = "foo" + testAWSSecretKey = "bar" //nolint:gosec +) + +// Ensure ctrl.Log is initialised for tests that don't use logf.SetLogger. +var _ = ctrl.Log diff --git a/pkg/iam/external_service_user.go b/pkg/iam/external_service_user.go new file mode 100644 index 00000000..5179b654 --- /dev/null +++ b/pkg/iam/external_service_user.go @@ -0,0 +1,207 @@ +package iam + +import ( + "encoding/json" + "fmt" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/aws/aws-sdk-go/service/iam" + "github.com/go-logr/logr" +) + +// EnsureExternalServiceUserConfig holds the configuration for managing IAM policies +// for external service users authenticated via IAM principal ARNs. +// +// Unlike EnsureUserConfig, there is no RolePrefix field: spec.DBUsername is the +// exact Postgres role name created by the controller, so the IAM resource ARN +// must reference it without any prefix to keep them aligned. +type EnsureExternalServiceUserConfig struct { + Region string + AccountID string + PolicyBaseName string + AWSLoginRoles []string +} + +// externalPolicyDocument is a separate document type from PolicyDocument to avoid +// conflating the ArnEquals condition with the existing StringLike/aws:userid condition +// used in the regular user flow. +type externalPolicyDocument struct { + Version string `json:"Version"` + Statement []externalStatementEntry `json:"Statement"` +} + +type externalStatementEntry struct { + Effect string `json:"Effect"` + Action []string `json:"Action"` + Resource []string `json:"Resource"` + Condition arnEqualsCondition `json:"Condition"` +} + +type arnEqualsCondition struct { + ArnEquals arnEqualsValue `json:"ArnEquals"` +} + +type arnEqualsValue struct { + PrincipalArn string `json:"aws:PrincipalArn"` +} + +// externalPolicyName returns the IAM policy name for a given external service user. +// Named distinctly from the packed user policies to avoid collisions. +func externalPolicyName(baseName, dbUsername string) string { + return fmt.Sprintf("%s-ext-%s", baseName, dbUsername) +} + +// newExternalPolicyDocument builds the IAM policy document granting rds-db:connect +// to the given principalArn on the DB user resource. +// +// dbUsername is used as-is in the resource ARN — no role prefix is applied, +// because spec.DBUsername is the exact Postgres role name the controller creates. +func newExternalPolicyDocument(region, accountID, principalArn, dbUsername string) *externalPolicyDocument { + return &externalPolicyDocument{ + Version: "2012-10-17", + Statement: []externalStatementEntry{ + { + Effect: "Allow", + Action: []string{"rds-db:connect"}, + Resource: []string{ + fmt.Sprintf("arn:aws:rds-db:%s:%s:dbuser:*/%s", region, accountID, dbUsername), + }, + Condition: arnEqualsCondition{ + ArnEquals: arnEqualsValue{PrincipalArn: principalArn}, + }, + }, + }, + } +} + +// EnsureExternalServiceUser creates or updates a dedicated IAM policy for the given +// IAM principal ARN, then attaches it to each AWSLoginRole. One policy is maintained +// per dbUsername — there is no packing (unlike the regular user flow). +// +// The policy grants rds-db:connect on the DB user resource, conditioned on +// aws:PrincipalArn matching principalArn exactly (ArnEquals). +func EnsureExternalServiceUser(client *Client, log logr.Logger, config EnsureExternalServiceUserConfig, principalArn, dbUsername string) error { + policyName := externalPolicyName(config.PolicyBaseName, dbUsername) + doc := newExternalPolicyDocument(config.Region, config.AccountID, principalArn, dbUsername) + + existing, err := client.getExternalPolicyByName(policyName) + if err != nil { + return err + } + + var iamPolicy *iam.Policy + if existing == nil { + log.V(1).Info("creating external service user policy", "policyName", policyName, "principalArn", principalArn) + iamPolicy, err = client.createExternalPolicy(policyName, doc) + if err != nil { + return err + } + } else { + log.V(1).Info("updating external service user policy", "policyName", policyName, "principalArn", principalArn) + if err = client.updateExternalPolicy(policyName, doc); err != nil { + return err + } + iamPolicy = existing + } + + for _, roleName := range config.AWSLoginRoles { + role, err := client.GetRole(roleName) + if err != nil { + return fmt.Errorf("get login role %s: %w", roleName, err) + } + if err = client.AttachPolicy(role, iamPolicy); err != nil { + return fmt.Errorf("attach policy %s to role %s: %w", policyName, roleName, err) + } + } + + return nil +} + +// RemoveExternalServiceUser detaches and deletes the dedicated IAM policy for dbUsername. +// It is a no-op if the policy does not exist. +func RemoveExternalServiceUser(client *Client, log logr.Logger, config EnsureExternalServiceUserConfig, dbUsername string) error { + policyName := externalPolicyName(config.PolicyBaseName, dbUsername) + log.V(1).Info("removing external service user policy", "policyName", policyName, "dbUsername", dbUsername) + return client.DeleteAndDetachPolicy(&Policy{Name: policyName}, config.AWSLoginRoles) +} + +// getExternalPolicyByName looks up an IAM policy by its constructed ARN. +// Returns nil, nil if the policy does not exist. +func (c *Client) getExternalPolicyByName(name string) (*iam.Policy, error) { + svc := iam.New(c.session) + policyArn := c.policyARN(name) + + result, err := svc.GetPolicy(&iam.GetPolicyInput{ + PolicyArn: aws.String(policyArn), + }) + if err != nil { + if awsErr, ok := err.(awserr.Error); ok && awsErr.Code() == iam.ErrCodeNoSuchEntityException { + return nil, nil + } + return nil, fmt.Errorf("get external policy %s: %w", name, err) + } + + return result.Policy, nil +} + +// createExternalPolicy creates a new IAM policy with the given externalPolicyDocument. +func (c *Client) createExternalPolicy(name string, doc *externalPolicyDocument) (*iam.Policy, error) { + svc := iam.New(c.session) + + jsonDoc, err := json.Marshal(doc) + if err != nil { + return nil, fmt.Errorf("marshal external policy document %s: %w", name, err) + } + + resp, err := svc.CreatePolicy(&iam.CreatePolicyInput{ + Description: aws.String("Created by postgresql controller"), + Path: aws.String(c.iamPrefix), + PolicyDocument: aws.String(string(jsonDoc)), + PolicyName: aws.String(name), + }) + if err != nil { + return nil, fmt.Errorf("create external policy %s: %w", name, err) + } + + return resp.Policy, nil +} + +// updateExternalPolicy replaces the policy document for an existing external service user policy. +func (c *Client) updateExternalPolicy(name string, doc *externalPolicyDocument) error { + svc := iam.New(c.session) + + jsonDoc, err := json.Marshal(doc) + if err != nil { + return fmt.Errorf("marshal external policy document %s: %w", name, err) + } + + policyArn := c.policyARN(name) + setAsDefault := true + + _, err = svc.CreatePolicyVersion(&iam.CreatePolicyVersionInput{ + PolicyArn: aws.String(policyArn), + PolicyDocument: aws.String(string(jsonDoc)), + SetAsDefault: &setAsDefault, + }) + if err != nil { + if awsErr, ok := err.(awserr.Error); ok && awsErr.Code() == iam.ErrCodeLimitExceededException { + // Prune old versions then retry + if err = c.deleteOldPolicyVersions(&Policy{Name: name}, svc); err != nil { + return fmt.Errorf("prune old versions of external policy %s: %w", name, err) + } + _, err = svc.CreatePolicyVersion(&iam.CreatePolicyVersionInput{ + PolicyArn: aws.String(policyArn), + PolicyDocument: aws.String(string(jsonDoc)), + SetAsDefault: &setAsDefault, + }) + if err != nil { + return fmt.Errorf("create policy version after pruning %s: %w", name, err) + } + return nil + } + return fmt.Errorf("update external policy %s: %w", name, err) + } + + return nil +} diff --git a/pkg/iam/external_service_user_test.go b/pkg/iam/external_service_user_test.go new file mode 100644 index 00000000..d490cf34 --- /dev/null +++ b/pkg/iam/external_service_user_test.go @@ -0,0 +1,269 @@ +package iam + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/iam" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.lunarway.com/postgresql-controller/test" +) + +// Unit tests — no external dependencies + +func Test_externalPolicyName(t *testing.T) { + assert.Equal(t, "mybase-ext-vvc_tenant", externalPolicyName("mybase", "vvc_tenant")) +} + +func Test_newExternalPolicyDocument_structure(t *testing.T) { + doc := newExternalPolicyDocument( + "eu-west-1", + "000000000000", + "arn:aws:iam::478824949770:user/VVCTenantUser", + "vvc_tenant", + ) + + require.Len(t, doc.Statement, 1) + s := doc.Statement[0] + + assert.Equal(t, "Allow", s.Effect) + assert.Equal(t, []string{"rds-db:connect"}, s.Action) + // No rolePrefix — dbUsername is the exact Postgres role name. + assert.Equal(t, []string{"arn:aws:rds-db:eu-west-1:000000000000:dbuser:*/vvc_tenant"}, s.Resource) + assert.Equal(t, "arn:aws:iam::478824949770:user/VVCTenantUser", s.Condition.ArnEquals.PrincipalArn) +} + +func Test_newExternalPolicyDocument_jsonShape(t *testing.T) { + doc := newExternalPolicyDocument( + "eu-west-1", + "000000000000", + "arn:aws:iam::478824949770:role/SomeRole", + "vvc_tenant", + ) + + raw, err := json.Marshal(doc) + require.NoError(t, err) + + // Verify the JSON condition key is exactly aws:PrincipalArn under ArnEquals + var parsed map[string]interface{} + require.NoError(t, json.Unmarshal(raw, &parsed)) + + stmts := parsed["Statement"].([]interface{}) + require.Len(t, stmts, 1) + + condition := stmts[0].(map[string]interface{})["Condition"].(map[string]interface{}) + arnEquals := condition["ArnEquals"].(map[string]interface{}) + assert.Equal(t, "arn:aws:iam::478824949770:role/SomeRole", arnEquals["aws:PrincipalArn"]) +} + +func Test_newExternalPolicyDocument_roleArn(t *testing.T) { + doc := newExternalPolicyDocument("eu-west-1", "111111111111", "arn:aws:iam::999:role/MyRole", "svc_user") + + require.Len(t, doc.Statement, 1) + assert.Equal(t, "arn:aws:rds-db:eu-west-1:111111111111:dbuser:*/svc_user", doc.Statement[0].Resource[0]) + assert.Equal(t, "arn:aws:iam::999:role/MyRole", doc.Statement[0].Condition.ArnEquals.PrincipalArn) +} + +// Integration tests — require localstack (POSTGRESQL_CONTROLLER_INTEGRATION_HOST) + +func TestEnsureExternalServiceUser_create(t *testing.T) { + test.Integration(t) + + logger := test.NewLogger(t) + + iamPrefix := GenerateRandomString(10) + loginRole := fmt.Sprintf("LoginRole_%s", GenerateRandomString(5)) + policyBase := t.Name() + + sess := CreateSession() + svc := iam.New(sess) + client := NewClient(sess, logger, accountID, iamPrefix) + + createRole(t, svc, accountID, loginRole) + + config := EnsureExternalServiceUserConfig{ + Region: region, + AccountID: accountID, + PolicyBaseName: policyBase, + AWSLoginRoles: []string{loginRole}, + } + + err := EnsureExternalServiceUser(client, logger, config, "arn:aws:iam::478824949770:user/VVCTenantUser", "vvc_tenant") + require.NoError(t, err) + + // Policy should exist and be attached to the login role. + role, err := client.GetRole(loginRole) + require.NoError(t, err) + attached, err := client.ListManagedAttachedPolicies(role) + require.NoError(t, err) + assert.Len(t, attached, 1) + assert.Equal(t, externalPolicyName(policyBase, "vvc_tenant"), *attached[0].PolicyName) +} + +func TestEnsureExternalServiceUser_idempotent(t *testing.T) { + test.Integration(t) + + logger := test.NewLogger(t) + + iamPrefix := GenerateRandomString(10) + loginRole := fmt.Sprintf("LoginRole_%s", GenerateRandomString(5)) + policyBase := t.Name() + + sess := CreateSession() + svc := iam.New(sess) + client := NewClient(sess, logger, accountID, iamPrefix) + + createRole(t, svc, accountID, loginRole) + + config := EnsureExternalServiceUserConfig{ + Region: region, + AccountID: accountID, + PolicyBaseName: policyBase, + AWSLoginRoles: []string{loginRole}, + } + + const principalArn = "arn:aws:iam::478824949770:user/VVCTenantUser" + + require.NoError(t, EnsureExternalServiceUser(client, logger, config, principalArn, "vvc_tenant")) + require.NoError(t, EnsureExternalServiceUser(client, logger, config, principalArn, "vvc_tenant")) + + // Still exactly one policy attached. + role, err := client.GetRole(loginRole) + require.NoError(t, err) + attached, err := client.ListManagedAttachedPolicies(role) + require.NoError(t, err) + assert.Len(t, attached, 1) +} + +func TestEnsureExternalServiceUser_arnChange(t *testing.T) { + test.Integration(t) + + logger := test.NewLogger(t) + + iamPrefix := GenerateRandomString(10) + loginRole := fmt.Sprintf("LoginRole_%s", GenerateRandomString(5)) + policyBase := t.Name() + + sess := CreateSession() + svc := iam.New(sess) + client := NewClient(sess, logger, accountID, iamPrefix) + + createRole(t, svc, accountID, loginRole) + + config := EnsureExternalServiceUserConfig{ + Region: region, + AccountID: accountID, + PolicyBaseName: policyBase, + AWSLoginRoles: []string{loginRole}, + } + + require.NoError(t, EnsureExternalServiceUser(client, logger, config, "arn:aws:iam::111:user/OldUser", "vvc_tenant")) + // Change the ARN — should update the policy in-place without creating a new one. + require.NoError(t, EnsureExternalServiceUser(client, logger, config, "arn:aws:iam::222:user/NewUser", "vvc_tenant")) + + role, err := client.GetRole(loginRole) + require.NoError(t, err) + attached, err := client.ListManagedAttachedPolicies(role) + require.NoError(t, err) + // Still a single policy — no duplicate created. + assert.Len(t, attached, 1) +} + +func TestEnsureExternalServiceUser_multipleLoginRoles(t *testing.T) { + test.Integration(t) + + logger := test.NewLogger(t) + + iamPrefix := GenerateRandomString(10) + loginRole1 := fmt.Sprintf("LoginRole1_%s", GenerateRandomString(5)) + loginRole2 := fmt.Sprintf("LoginRole2_%s", GenerateRandomString(5)) + policyBase := t.Name() + + sess := CreateSession() + svc := iam.New(sess) + client := NewClient(sess, logger, accountID, iamPrefix) + + createRole(t, svc, accountID, loginRole1) + createRole(t, svc, accountID, loginRole2) + + config := EnsureExternalServiceUserConfig{ + Region: region, + AccountID: accountID, + PolicyBaseName: policyBase, + AWSLoginRoles: []string{loginRole1, loginRole2}, + } + + err := EnsureExternalServiceUser(client, logger, config, "arn:aws:iam::478824949770:user/VVCTenantUser", "vvc_tenant") + require.NoError(t, err) + + assertPolicyOnAWSLoginRole(t, client, loginRole1) + assertPolicyOnAWSLoginRole(t, client, loginRole2) +} + +func TestRemoveExternalServiceUser(t *testing.T) { + test.Integration(t) + + logger := test.NewLogger(t) + + iamPrefix := GenerateRandomString(10) + loginRole := fmt.Sprintf("LoginRole_%s", GenerateRandomString(5)) + policyBase := t.Name() + + sess := CreateSession() + svc := iam.New(sess) + client := NewClient(sess, logger, accountID, iamPrefix) + + createRole(t, svc, accountID, loginRole) + + config := EnsureExternalServiceUserConfig{ + Region: region, + AccountID: accountID, + PolicyBaseName: policyBase, + AWSLoginRoles: []string{loginRole}, + } + + require.NoError(t, EnsureExternalServiceUser(client, logger, config, "arn:aws:iam::478824949770:user/VVCTenantUser", "vvc_tenant")) + require.NoError(t, RemoveExternalServiceUser(client, logger, config, "vvc_tenant")) + + // Policy should be gone and detached. + role, err := client.GetRole(loginRole) + require.NoError(t, err) + attached, err := client.ListManagedAttachedPolicies(role) + require.NoError(t, err) + assert.Empty(t, attached) + + // Verify the policy itself no longer exists. + policyArn := aws.String(client.policyARN(externalPolicyName(policyBase, "vvc_tenant"))) + _, err = iam.New(sess).GetPolicy(&iam.GetPolicyInput{PolicyArn: policyArn}) + assert.Error(t, err, "policy should have been deleted") +} + +func TestRemoveExternalServiceUser_notExist(t *testing.T) { + test.Integration(t) + + logger := test.NewLogger(t) + + iamPrefix := GenerateRandomString(10) + loginRole := fmt.Sprintf("LoginRole_%s", GenerateRandomString(5)) + policyBase := t.Name() + + sess := CreateSession() + svc := iam.New(sess) + client := NewClient(sess, logger, accountID, iamPrefix) + + createRole(t, svc, accountID, loginRole) + + config := EnsureExternalServiceUserConfig{ + Region: region, + AccountID: accountID, + PolicyBaseName: policyBase, + AWSLoginRoles: []string{loginRole}, + } + + // Removing a non-existent user should be a no-op. + err := RemoveExternalServiceUser(client, logger, config, "nonexistent_user") + assert.NoError(t, err) +} diff --git a/pkg/postgres/external_service_user.go b/pkg/postgres/external_service_user.go new file mode 100644 index 00000000..8c4fc579 --- /dev/null +++ b/pkg/postgres/external_service_user.go @@ -0,0 +1,77 @@ +package postgres + +import ( + "database/sql" + "fmt" + "strings" + + "github.com/go-logr/logr" + "github.com/lib/pq" +) + +// EnsureIAMLoginRole creates a PostgreSQL role with LOGIN capability if it does +// not already exist, then synchronises its granted server-level roles to exactly +// match grantRoles (revokes extras, grants missing ones). +// +// Unlike EnsureCustomRole (which uses NOLOGIN), this targets RDS IAM +// authentication where the role must be able to log in. Callers should include +// "rds_iam" in grantRoles to enable IAM token-based authentication. +func EnsureIAMLoginRole(log logr.Logger, db *sql.DB, roleName string, grantRoles []string) error { + log = log.WithValues("role", roleName) + log.Info("Ensuring IAM login role") + + _, err := db.Exec(fmt.Sprintf("CREATE ROLE %s WITH LOGIN", pq.QuoteIdentifier(roleName))) + if err != nil { + pqError, ok := err.(*pq.Error) + if !ok || pqError.Code.Name() != "duplicate_object" { + return fmt.Errorf("create role %s: %w", roleName, err) + } + log.Info("Role already exists", "errorCode", pqError.Code, "errorName", pqError.Code.Name()) + } else { + log.Info("Role created") + } + + current, err := currentGrantedRoles(db, roleName) + if err != nil { + return fmt.Errorf("query granted roles for %s: %w", roleName, err) + } + + desiredSet := make(map[string]struct{}, len(grantRoles)) + for _, r := range grantRoles { + desiredSet[r] = struct{}{} + } + + // Revoke roles no longer in the desired set. + for _, r := range current { + if _, ok := desiredSet[r]; !ok { + if _, err := db.Exec(fmt.Sprintf("REVOKE %s FROM %s", pq.QuoteIdentifier(r), pq.QuoteIdentifier(roleName))); err != nil { + return fmt.Errorf("revoke role %s from %s: %w", r, roleName, err) + } + log.Info("Revoked role", "role", r) + } + } + + // Grant roles not yet present. + currentSet := make(map[string]struct{}, len(current)) + for _, r := range current { + currentSet[r] = struct{}{} + } + var toGrant []string + for _, r := range grantRoles { + if _, ok := currentSet[r]; !ok { + toGrant = append(toGrant, r) + } + } + if len(toGrant) == 0 { + return nil + } + quoted := make([]string, len(toGrant)) + for i, r := range toGrant { + quoted[i] = pq.QuoteIdentifier(r) + } + if _, err := db.Exec(fmt.Sprintf("GRANT %s TO %s", strings.Join(quoted, ", "), pq.QuoteIdentifier(roleName))); err != nil { + return fmt.Errorf("grant roles %v to %s: %w", toGrant, roleName, err) + } + log.Info("Granted roles", "roles", toGrant) + return nil +}