diff --git a/api/v1alpha1/apiexportpolicy_types.go b/api/v1alpha1/apiexportpolicy_types.go new file mode 100644 index 00000000..455bd649 --- /dev/null +++ b/api/v1alpha1/apiexportpolicy_types.go @@ -0,0 +1,62 @@ +package v1alpha1 + +import ( + lifecycleapi "github.com/platform-mesh/golang-commons/controller/lifecycle/api" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type APIExportRef struct { + Name string `json:"name"` + ClusterPath string `json:"clusterPath"` +} + +type APIExportPolicySpec struct { + APIExportRef APIExportRef `json:"apiExportRef"` + + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinItems=1 + AllowPathExpressions []string `json:"allowPathExpressions"` +} + +type APIExportPolicyStatus struct { + Conditions []metav1.Condition `json:"conditions,omitempty"` + ManagedAllowExpressions []string `json:"managedAllowExpressions,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Cluster + +type APIExportPolicy struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec APIExportPolicySpec `json:"spec,omitempty"` + Status APIExportPolicyStatus `json:"status,omitempty"` +} + +// GetConditions implements lifecycle.RuntimeObjectConditions. +func (in *APIExportPolicy) GetConditions() []metav1.Condition { + return in.Status.Conditions +} + +// SetConditions implements lifecycle.RuntimeObjectConditions. +func (in *APIExportPolicy) SetConditions(conditions []metav1.Condition) { + in.Status.Conditions = conditions +} + +var _ lifecycleapi.RuntimeObjectConditions = &APIExportPolicy{} + +// +kubebuilder:object:root=true + +// APIExportPolicyList contains a list of APIExportPolicy. +type APIExportPolicyList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []APIExportPolicy `json:"items"` +} + +func init() { + SchemeBuilder.Register(&APIExportPolicy{}, &APIExportPolicyList{}) +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 500f4a8d..7fb7acb3 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -9,6 +9,128 @@ import ( runtime "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *APIExportPolicy) DeepCopyInto(out *APIExportPolicy) { + *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 APIExportPolicy. +func (in *APIExportPolicy) DeepCopy() *APIExportPolicy { + if in == nil { + return nil + } + out := new(APIExportPolicy) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *APIExportPolicy) 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 *APIExportPolicyList) DeepCopyInto(out *APIExportPolicyList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]APIExportPolicy, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new APIExportPolicyList. +func (in *APIExportPolicyList) DeepCopy() *APIExportPolicyList { + if in == nil { + return nil + } + out := new(APIExportPolicyList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *APIExportPolicyList) 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 *APIExportPolicySpec) DeepCopyInto(out *APIExportPolicySpec) { + *out = *in + out.APIExportRef = in.APIExportRef + if in.AllowPathExpressions != nil { + in, out := &in.AllowPathExpressions, &out.AllowPathExpressions + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new APIExportPolicySpec. +func (in *APIExportPolicySpec) DeepCopy() *APIExportPolicySpec { + if in == nil { + return nil + } + out := new(APIExportPolicySpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *APIExportPolicyStatus) DeepCopyInto(out *APIExportPolicyStatus) { + *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]) + } + } + if in.ManagedAllowExpressions != nil { + in, out := &in.ManagedAllowExpressions, &out.ManagedAllowExpressions + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new APIExportPolicyStatus. +func (in *APIExportPolicyStatus) DeepCopy() *APIExportPolicyStatus { + if in == nil { + return nil + } + out := new(APIExportPolicyStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *APIExportRef) DeepCopyInto(out *APIExportRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new APIExportRef. +func (in *APIExportRef) DeepCopy() *APIExportRef { + if in == nil { + return nil + } + out := new(APIExportRef) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AuthorizationModel) DeepCopyInto(out *AuthorizationModel) { *out = *in diff --git a/cmd/authorization.go b/cmd/authorization.go new file mode 100644 index 00000000..f87d1040 --- /dev/null +++ b/cmd/authorization.go @@ -0,0 +1,103 @@ +package cmd + +import ( + "context" + "crypto/tls" + + openfgav1 "github.com/openfga/api/proto/openfga/v1" + platformeshcontext "github.com/platform-mesh/golang-commons/context" + "github.com/platform-mesh/security-operator/internal/controller" + "github.com/spf13/cobra" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/healthz" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager" + + "k8s.io/client-go/rest" + + "github.com/kcp-dev/multicluster-provider/apiexport" +) + +var apiExportPolicyCmd = &cobra.Command{ + Use: "authorization", + Short: "Controllers for authorization.platform-mesh.io APIExport", + RunE: func(cmd *cobra.Command, args []string) error { + ctrl.SetLogger(log.ComponentLogger("controller-runtime").Logr()) + + ctx, _, shutdown := platformeshcontext.StartContext(log, defaultCfg, defaultCfg.ShutdownTimeout) + defer shutdown() + + restCfg, err := getKubeconfigFromPath(authorizationCfg.KCP.Kubeconfig) + if err != nil { + log.Error().Err(err).Msg("unable to get KCP kubeconfig") + return err + } + + opts := ctrl.Options{ + Scheme: scheme, + Metrics: metricsserver.Options{ + BindAddress: defaultCfg.Metrics.BindAddress, + TLSOpts: []func(*tls.Config){ + func(c *tls.Config) { + c.NextProtos = []string{"http/1.1"} + }, + }, + }, + HealthProbeBindAddress: defaultCfg.HealthProbeBindAddress, + LeaderElection: defaultCfg.LeaderElectionEnabled, + LeaderElectionID: "security-operator-authorization.platform-mesh.io", + BaseContext: func() context.Context { return ctx }, + } + + if defaultCfg.LeaderElectionEnabled { + inClusterCfg, err := rest.InClusterConfig() + if err != nil { + setupLog.Error(err, "unable to get in-cluster config for leader election") + return err + } + opts.LeaderElectionConfig = inClusterCfg + } + + provider, err := apiexport.New(restCfg, authorizationCfg.AuthorizationAPIExportEndpointSliceName, apiexport.Options{ + Scheme: scheme, + }) + if err != nil { + setupLog.Error(err, "unable to create path-aware provider") + return err + } + + mgr, err := mcmanager.New(restCfg, provider, opts) + if err != nil { + setupLog.Error(err, "unable to create apiexportpolicy manager") + return err + } + + conn, err := grpc.NewClient(authorizationCfg.FGA.Target, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + log.Error().Err(err).Msg("unable to create grpc client") + return err + } + + fga := openfgav1.NewOpenFGAServiceClient(conn) + + if err = controller.NewAPIExportPolicyReconciler(log, fga, mgr).SetupWithManager(mgr, defaultCfg); err != nil { + log.Error().Err(err).Str("controller", "apiexportpolicy").Msg("unable to create controller") + return err + } + + if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { + log.Error().Err(err).Msg("unable to set up health check") + return err + } + if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { + log.Error().Err(err).Msg("unable to set up ready check") + return err + } + + setupLog.Info("starting authorization manager") + + return mgr.Start(ctx) + }, +} diff --git a/cmd/model_generator.go b/cmd/model_generator.go index 7dd54c38..0bef3266 100644 --- a/cmd/model_generator.go +++ b/cmd/model_generator.go @@ -70,7 +70,7 @@ var modelGeneratorCmd = &cobra.Command{ return fmt.Errorf("scheme should not be nil") } - provider, err := apiexport.New(restCfg, operatorCfg.APIExportEndpointSliceName, apiexport.Options{ + provider, err := apiexport.New(restCfg, operatorCfg.CoreAPIExportEndpointSliceName, apiexport.Options{ Scheme: mgrOpts.Scheme, }) if err != nil { diff --git a/cmd/operator.go b/cmd/operator.go index ee1c0405..75a1501d 100644 --- a/cmd/operator.go +++ b/cmd/operator.go @@ -141,7 +141,7 @@ var operatorCmd = &cobra.Command{ return fmt.Errorf("scheme should not be nil") } - provider, err := apiexport.New(restCfg, operatorCfg.APIExportEndpointSliceName, apiexport.Options{ + provider, err := apiexport.New(restCfg, operatorCfg.CoreAPIExportEndpointSliceName, apiexport.Options{ Scheme: mgrOpts.Scheme, }) if err != nil { diff --git a/cmd/root.go b/cmd/root.go index ff5567ea..a7bbacd9 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -15,13 +15,14 @@ import ( ) var ( - defaultCfg *platformeshconfig.CommonServiceConfig - initializerCfg config.Config - terminatorCfg config.Config - operatorCfg config.Config - generatorCfg config.Config - log *logger.Logger - setupLog logr.Logger + defaultCfg *platformeshconfig.CommonServiceConfig + initializerCfg config.Config + terminatorCfg config.Config + operatorCfg config.Config + generatorCfg config.Config + authorizationCfg config.Config + log *logger.Logger + setupLog logr.Logger ) var rootCmd = &cobra.Command{ @@ -34,12 +35,14 @@ func init() { rootCmd.AddCommand(operatorCmd) rootCmd.AddCommand(modelGeneratorCmd) rootCmd.AddCommand(initContainerCmd) + rootCmd.AddCommand(apiExportPolicyCmd) defaultCfg = platformeshconfig.NewDefaultConfig() operatorCfg = config.NewConfig() generatorCfg = config.NewConfig() initializerCfg = config.NewConfig() terminatorCfg = config.NewConfig() + authorizationCfg = config.NewConfig() initContainerCfg = config.NewInitContainerConfig() defaultCfg.AddFlags(rootCmd.PersistentFlags()) @@ -47,6 +50,7 @@ func init() { generatorCfg.AddFlags(modelGeneratorCmd.Flags()) initializerCfg.AddFlags(initializerCmd.Flags()) terminatorCfg.AddFlags(terminatorCmd.Flags()) + authorizationCfg.AddFlags(apiExportPolicyCmd.Flags()) initContainerCfg.AddFlags(initContainerCmd.Flags()) cobra.OnInitialize(initLog) diff --git a/config/crd/bases/core.platform-mesh.io_apiexportpolicies.yaml b/config/crd/bases/core.platform-mesh.io_apiexportpolicies.yaml new file mode 100644 index 00000000..4cbeb9a6 --- /dev/null +++ b/config/crd/bases/core.platform-mesh.io_apiexportpolicies.yaml @@ -0,0 +1,126 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.4 + name: apiexportpolicies.core.platform-mesh.io +spec: + group: core.platform-mesh.io + names: + kind: APIExportPolicy + listKind: APIExportPolicyList + plural: apiexportpolicies + singular: apiexportpolicy + scope: Cluster + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + 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: + properties: + allowPathExpressions: + items: + type: string + minItems: 1 + type: array + apiExportRef: + properties: + clusterPath: + type: string + name: + type: string + required: + - clusterPath + - name + type: object + required: + - allowPathExpressions + - apiExportRef + type: object + status: + properties: + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + 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. + 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 + managedAllowExpressions: + items: + type: string + type: array + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/resources/apiexport-core.platform-mesh.io.yaml b/config/resources/apiexport-core.platform-mesh.io.yaml index a6002c20..062e48f3 100644 --- a/config/resources/apiexport-core.platform-mesh.io.yaml +++ b/config/resources/apiexport-core.platform-mesh.io.yaml @@ -5,6 +5,11 @@ metadata: name: core.platform-mesh.io spec: resources: + - group: core.platform-mesh.io + name: apiexportpolicies + schema: v260313-c8b198d.apiexportpolicies.core.platform-mesh.io + storage: + crd: {} - group: core.platform-mesh.io name: authorizationmodels schema: v260112-5925c7e.authorizationmodels.core.platform-mesh.io diff --git a/config/resources/apiresourceschema-apiexportpolicies.core.platform-mesh.io.yaml b/config/resources/apiresourceschema-apiexportpolicies.core.platform-mesh.io.yaml new file mode 100644 index 00000000..970b0705 --- /dev/null +++ b/config/resources/apiresourceschema-apiexportpolicies.core.platform-mesh.io.yaml @@ -0,0 +1,123 @@ +apiVersion: apis.kcp.io/v1alpha1 +kind: APIResourceSchema +metadata: + creationTimestamp: null + name: v260313-c8b198d.apiexportpolicies.core.platform-mesh.io +spec: + group: core.platform-mesh.io + names: + kind: APIExportPolicy + listKind: APIExportPolicyList + plural: apiexportpolicies + singular: apiexportpolicy + scope: Cluster + versions: + - name: v1alpha1 + schema: + 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: + properties: + allowPathExpressions: + items: + type: string + minItems: 1 + type: array + apiExportRef: + properties: + clusterPath: + type: string + name: + type: string + required: + - clusterPath + - name + type: object + required: + - allowPathExpressions + - apiExportRef + type: object + status: + properties: + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + 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. + 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 + managedAllowExpressions: + items: + type: string + type: array + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/samples/core_v1alpha1_apiexportpolicy.yaml b/config/samples/core_v1alpha1_apiexportpolicy.yaml new file mode 100644 index 00000000..5d592f80 --- /dev/null +++ b/config/samples/core_v1alpha1_apiexportpolicy.yaml @@ -0,0 +1,18 @@ +apiVersion: authorization.platform-mesh.io/v1alpha1 +kind: APIExportPolicy +metadata: + name: orchestrate-platform-mesh-io + namespace: platform-mesh-system +spec: + apiExportRef: + name: orchestrate.platform-mesh.io + clusterName: root:providers:httpbin + + allowPathExpressions: + # Allow binding only for specific account and its child accounts + - :root:orgs:default:abc + - :root:orgs:default:cde + # Allow binding for all accounts in all organizations + - :root:orgs:* + # Allow binding for all accounts within demo organization + - :root:orgs:demo:* \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go index 823449de..28ea2980 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -58,25 +58,26 @@ type IDPConfig struct { // Config struct to hold the app config type Config struct { - FGA FGAConfig - KCP KCPConfig - APIExportEndpointSliceName string - CoreModulePath string - BaseDomain string - GroupClaim string - UserClaim string - DevelopmentAllowUnverifiedEmails bool - WorkspacePath string - WorkspaceTypeName string - DomainCALookup bool - MigrateAuthorizationModels bool - HttpClientTimeoutSeconds int - SetDefaultPassword bool - AllowMemberTuplesEnabled bool - IDP IDPConfig - Keycloak KeycloakConfig - Initializer InitializerConfig - Webhooks WebhooksConfig + FGA FGAConfig + KCP KCPConfig + CoreAPIExportEndpointSliceName string + AuthorizationAPIExportEndpointSliceName string + CoreModulePath string + BaseDomain string + GroupClaim string + UserClaim string + DevelopmentAllowUnverifiedEmails bool + WorkspacePath string + WorkspaceTypeName string + DomainCALookup bool + MigrateAuthorizationModels bool + HttpClientTimeoutSeconds int + SetDefaultPassword bool + AllowMemberTuplesEnabled bool + IDP IDPConfig + Keycloak KeycloakConfig + Initializer InitializerConfig + Webhooks WebhooksConfig } func NewConfig() Config { @@ -89,13 +90,14 @@ func NewConfig() Config { KCP: KCPConfig{ Kubeconfig: "/api-kubeconfig/kubeconfig", }, - APIExportEndpointSliceName: "core.platform-mesh.io", - BaseDomain: "portal.dev.local:8443", - GroupClaim: "groups", - UserClaim: "email", - WorkspacePath: "root", - WorkspaceTypeName: "security", - HttpClientTimeoutSeconds: 30, + CoreAPIExportEndpointSliceName: "core.platform-mesh.io", + AuthorizationAPIExportEndpointSliceName: "authorization.platform-mesh.io", + BaseDomain: "portal.dev.local:8443", + GroupClaim: "groups", + UserClaim: "email", + WorkspacePath: "root", + WorkspaceTypeName: "security", + HttpClientTimeoutSeconds: 30, IDP: IDPConfig{ KubectlClientRedirectURLs: []string{"http://localhost:8000", "http://localhost:18000"}, AccessTokenLifespan: 28800, @@ -123,7 +125,8 @@ func (c *Config) AddFlags(fs *pflag.FlagSet) { fs.StringVar(&c.FGA.ParentRelation, "fga-parent-relation", c.FGA.ParentRelation, "Set the OpenFGA parent relation name") fs.StringVar(&c.FGA.CreatorRelation, "fga-creator-relation", c.FGA.CreatorRelation, "Set the OpenFGA creator relation name") fs.StringVar(&c.KCP.Kubeconfig, "kcp-kubeconfig", c.KCP.Kubeconfig, "Set the KCP kubeconfig path") - fs.StringVar(&c.APIExportEndpointSliceName, "api-export-endpoint-slice-name", c.APIExportEndpointSliceName, "Set the APIExportEndpointSlice name") + fs.StringVar(&c.CoreAPIExportEndpointSliceName, "core-api-export-endpoint-slice-name", c.CoreAPIExportEndpointSliceName, "Set the APIExportEndpointSlice name") + fs.StringVar(&c.AuthorizationAPIExportEndpointSliceName, "authorization-api-export-endpoint-slice-name", c.AuthorizationAPIExportEndpointSliceName, "Set the Authorization APIExportEndpointSlice name") fs.StringVar(&c.CoreModulePath, "core-module-path", c.CoreModulePath, "Set the path to the core module FGA model file") fs.StringVar(&c.BaseDomain, "base-domain", c.BaseDomain, "Set the base domain used to construct issuer URLs") fs.StringVar(&c.GroupClaim, "group-claim", c.GroupClaim, "Set the ID token group claim") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index ff07b7b2..ca18bd8d 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -12,7 +12,7 @@ func TestNewConfig(t *testing.T) { assert.Equal(t, "core_platform-mesh_io_account", cfg.FGA.ObjectType) assert.Equal(t, "/api-kubeconfig/kubeconfig", cfg.KCP.Kubeconfig) - assert.Equal(t, "core.platform-mesh.io", cfg.APIExportEndpointSliceName) + assert.Equal(t, "core.platform-mesh.io", cfg.CoreAPIExportEndpointSliceName) assert.Equal(t, "security-operator", cfg.Keycloak.ClientID) assert.Equal(t, 9443, cfg.Webhooks.Port) assert.Equal(t, []string{"http://localhost:8000", "http://localhost:18000"}, cfg.IDP.KubectlClientRedirectURLs) diff --git a/internal/controller/apiexportpolicy_controller.go b/internal/controller/apiexportpolicy_controller.go new file mode 100644 index 00000000..d4cf5050 --- /dev/null +++ b/internal/controller/apiexportpolicy_controller.go @@ -0,0 +1,108 @@ +package controller + +import ( + "context" + "strings" + + openfgav1 "github.com/openfga/api/proto/openfga/v1" + platformeshconfig "github.com/platform-mesh/golang-commons/config" + "github.com/platform-mesh/golang-commons/controller/lifecycle/builder" + "github.com/platform-mesh/golang-commons/controller/lifecycle/multicluster" + lifecyclesubroutine "github.com/platform-mesh/golang-commons/controller/lifecycle/subroutine" + "github.com/platform-mesh/golang-commons/logger" + corev1alpha1 "github.com/platform-mesh/security-operator/api/v1alpha1" + iclient "github.com/platform-mesh/security-operator/internal/client" + "github.com/platform-mesh/security-operator/internal/subroutine" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/cluster" + ctrhandler "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + mccontext "sigs.k8s.io/multicluster-runtime/pkg/context" + "sigs.k8s.io/multicluster-runtime/pkg/handler" + mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager" + mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile" + + "github.com/kcp-dev/logicalcluster/v3" + "k8s.io/apimachinery/pkg/types" +) + +type APIExportPolicyReconciler struct { + log *logger.Logger + mclifecycle *multicluster.LifecycleManager +} + +func NewAPIExportPolicyReconciler(log *logger.Logger, fga openfgav1.OpenFGAServiceClient, mcMgr mcmanager.Manager) *APIExportPolicyReconciler { + return &APIExportPolicyReconciler{ + log: log, + mclifecycle: builder.NewBuilder("apiexportpolicy", "APIExportPolicyReconciler", []lifecyclesubroutine.Subroutine{ + subroutine.NewAPIExportPolicySubroutine(fga, mcMgr), + }, log). + WithConditionManagement(). + BuildMultiCluster(mcMgr), + } +} + +func (r *APIExportPolicyReconciler) Reconcile(ctx context.Context, req mcreconcile.Request) (ctrl.Result, error) { + ctxWithCluster := mccontext.WithCluster(ctx, req.ClusterName) + return r.mclifecycle.Reconcile(ctxWithCluster, req, &corev1alpha1.APIExportPolicy{}) +} + +func (r *APIExportPolicyReconciler) SetupWithManager(mgr mcmanager.Manager, cfg *platformeshconfig.CommonServiceConfig, evp ...predicate.Predicate) error { + bld, err := r.mclifecycle.SetupWithManagerBuilder(mgr, cfg.MaxConcurrentReconciles, "apiexportpolicy", &corev1alpha1.APIExportPolicy{}, cfg.DebugLabelValue, r.log, evp...) + if err != nil { + return err + } + return bld. + Watches( + &corev1alpha1.Store{}, + func(clusterName string, c cluster.Cluster) ctrhandler.TypedEventHandler[client.Object, mcreconcile.Request] { + return handler.TypedEnqueueRequestsFromMapFuncWithClusterPreservation(func(ctx context.Context, obj client.Object) []mcreconcile.Request { + _, ok := obj.(*corev1alpha1.Store) + if !ok { + return nil + } + + // List all APIExportPolicy resources and enqueue those with root:orgs:* expression + return r.enqueueAllAPIExportPolicies(ctx, mgr) + }) + }, + ).Complete(r) +} + +func (r *APIExportPolicyReconciler) enqueueAllAPIExportPolicies(ctx context.Context, mgr mcmanager.Manager) []mcreconcile.Request { + allClient, err := iclient.NewForAllPlatformMeshResources(ctx, mgr.GetLocalManager().GetConfig(), mgr.GetLocalManager().GetScheme()) + if err != nil { + r.log.Error().Err(err).Msg("failed to create all-cluster client for APIExportPolicy listing") + return nil + } + + var policies corev1alpha1.APIExportPolicyList + if err := allClient.List(ctx, &policies); err != nil { + r.log.Error().Err(err).Msg("failed to list APIExportPolicy resources") + return nil + } + + var requests []mcreconcile.Request + for _, policy := range policies.Items { + // Check if policy has root:orgs:* expression + for _, expr := range policy.Spec.AllowPathExpressions { + trimmedExpr := strings.TrimPrefix(expr, ":") + + if trimmedExpr == "root:orgs:*" { + clusterName := logicalcluster.From(&policy) + requests = append(requests, mcreconcile.Request{ + Request: reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: policy.Name, + }, + }, + ClusterName: clusterName.String(), + }) + break + } + } + } + return requests +} diff --git a/internal/subroutine/apiexportpolicy.go b/internal/subroutine/apiexportpolicy.go new file mode 100644 index 00000000..32b15b99 --- /dev/null +++ b/internal/subroutine/apiexportpolicy.go @@ -0,0 +1,305 @@ +package subroutine + +import ( + "context" + "fmt" + "slices" + "strings" + + openfgav1 "github.com/openfga/api/proto/openfga/v1" + accountsv1alpha1 "github.com/platform-mesh/account-operator/api/v1alpha1" + lifecyclecontrollerruntime "github.com/platform-mesh/golang-commons/controller/lifecycle/runtimeobject" + lifecyclesubroutine "github.com/platform-mesh/golang-commons/controller/lifecycle/subroutine" + "github.com/platform-mesh/golang-commons/errors" + "github.com/platform-mesh/golang-commons/logger" + corev1alpha1 "github.com/platform-mesh/security-operator/api/v1alpha1" + iclient "github.com/platform-mesh/security-operator/internal/client" + "github.com/platform-mesh/security-operator/pkg/fga" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager" + + "github.com/kcp-dev/logicalcluster/v3" + kcpcorev1alpha1 "github.com/kcp-dev/sdk/apis/core/v1alpha1" +) + +const ( + orgsWorkspacePath = "root:orgs" + bindRelation = "bind" + bindInheritedRelation = "bind_inherited" +) + +type APIExportPolicySubroutine struct { + fga openfgav1.OpenFGAServiceClient + mgr mcmanager.Manager +} + +func NewAPIExportPolicySubroutine(fga openfgav1.OpenFGAServiceClient, mgr mcmanager.Manager) *APIExportPolicySubroutine { + return &APIExportPolicySubroutine{ + fga: fga, + mgr: mgr, + } +} + +var _ lifecyclesubroutine.Subroutine = &APIExportPolicySubroutine{} + +func (a *APIExportPolicySubroutine) GetName() string { + return "APIExportPolicySubroutine" +} + +func (a *APIExportPolicySubroutine) Finalizers(_ lifecyclecontrollerruntime.RuntimeObject) []string { + return []string{"authorization.platform-mesh.io/apiexportpolicy-finalizer"} +} + +func (a *APIExportPolicySubroutine) Process(ctx context.Context, instance lifecyclecontrollerruntime.RuntimeObject) (ctrl.Result, errors.OperatorError) { + log := logger.LoadLoggerFromContext(ctx) + policy := instance.(*corev1alpha1.APIExportPolicy) + + providerClusterID, err := a.getClusterID(ctx, policy.Spec.APIExportRef.ClusterPath) + if err != nil { + return ctrl.Result{}, errors.NewOperatorError( + fmt.Errorf("getting provider cluster ID for %s: %w", policy.Spec.APIExportRef.ClusterPath, err), + true, true) + } + + // Delete tuples for expressions that were removed from the spec + if err := a.deleteRemovedExpressions(ctx, policy); err != nil { + return ctrl.Result{}, errors.NewOperatorError( + fmt.Errorf("removing tuples for policy %s: %w", policy.Name, err), + true, true) + } + + for _, expression := range policy.Spec.AllowPathExpressions { + workspacePath, relation, err := parseAllowExpression(expression) + if err != nil { + return ctrl.Result{}, errors.NewOperatorError( + fmt.Errorf("parsing allow expression %s: %w", expression, err), + true, true) + } + + // for orgs workspace we need to write 1 tuple in every store + // for this we need to get cluster id for every org's workspace + if workspacePath == orgsWorkspacePath { + allclient, err := iclient.NewForAllPlatformMeshResources(ctx, a.mgr.GetLocalManager().GetConfig(), a.mgr.GetLocalManager().GetScheme()) + if err != nil { + log.Fatal().Err(err).Msg("unable to create all client") + } + + var accountInfoList accountsv1alpha1.AccountInfoList + if err := allclient.List(ctx, &accountInfoList); err != nil { + return ctrl.Result{}, errors.NewOperatorError( + fmt.Errorf("listing AccountInfo resources: %w", err), + true, true) + } + + for _, ai := range accountInfoList.Items { + if ai.Spec.Account.Type != accountsv1alpha1.AccountTypeOrg { + continue + } + + if ai.Spec.FGA.Store.Id == "" { + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("store id is empty in account info %w", err), true, true) + } + + tuple := corev1alpha1.Tuple{ + Object: fmt.Sprintf("core_platform-mesh_io_account:%s/%s", ai.Spec.Account.OriginClusterId, ai.Spec.Account.Name), + Relation: relation, + User: fmt.Sprintf("apis_kcp_io_apiexport:%s/%s", providerClusterID, policy.Spec.APIExportRef.Name), + } + + tm := fga.NewTupleManager(a.fga, ai.Spec.FGA.Store.Id, fga.AuthorizationModelIDLatest, log) + if err := tm.Apply(ctx, []corev1alpha1.Tuple{tuple}); err != nil { + return ctrl.Result{}, errors.NewOperatorError( + fmt.Errorf("applying tuple for expression %s: %w", expression, err), + true, true) + } + } + continue + } + + lcClient, err := iclient.NewForLogicalCluster(a.mgr.GetLocalManager().GetConfig(), a.mgr.GetLocalManager().GetScheme(), logicalcluster.Name(workspacePath)) + if err != nil { + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("getting client: %w", err), true, true) + } + + var ai accountsv1alpha1.AccountInfo + if err := lcClient.Get(ctx, client.ObjectKey{Name: "account"}, &ai); err != nil { + return ctrl.Result{}, errors.NewOperatorError( + fmt.Errorf("getting AccountInfo for workspace %s: %w", workspacePath, err), + true, true) + } + + tuple := corev1alpha1.Tuple{ + Object: fmt.Sprintf("core_platform-mesh_io_account:%s/%s", ai.Spec.Account.OriginClusterId, ai.Spec.Account.Name), + Relation: relation, + User: fmt.Sprintf("apis_kcp_io_apiexport:%s/%s", providerClusterID, policy.Spec.APIExportRef.Name), + } + + tm := fga.NewTupleManager(a.fga, ai.Spec.FGA.Store.Id, fga.AuthorizationModelIDLatest, log) + if err := tm.Apply(ctx, []corev1alpha1.Tuple{tuple}); err != nil { + return ctrl.Result{}, errors.NewOperatorError( + fmt.Errorf("applying tuple for expression %s: %w", expression, err), + true, true) + } + } + + cluster, err := a.mgr.ClusterFromContext(ctx) + if err != nil { + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("failed to get cluster from context %w", err), true, true) + } + + // Update status with managed expressions + original := policy.DeepCopy() + policy.Status.ManagedAllowExpressions = policy.Spec.AllowPathExpressions + + if err := cluster.GetClient().Status().Patch(ctx, policy, client.MergeFrom(original)); err != nil { + return ctrl.Result{}, errors.NewOperatorError( + fmt.Errorf("failed to patch APIExportPolicy status: %w", err), + true, true) + } + + log.Info().Msg("Successfully processed APIExportPolicy") + return ctrl.Result{}, nil +} + +func (a *APIExportPolicySubroutine) Finalize(ctx context.Context, instance lifecyclecontrollerruntime.RuntimeObject) (ctrl.Result, errors.OperatorError) { + log := logger.LoadLoggerFromContext(ctx) + policy := instance.(*corev1alpha1.APIExportPolicy) + + providerClusterID, err := a.getClusterID(ctx, policy.Spec.APIExportRef.ClusterPath) + if err != nil { + return ctrl.Result{}, errors.NewOperatorError( + fmt.Errorf("getting provider cluster ID for %s: %w", policy.Spec.APIExportRef.ClusterPath, err), + true, true) + } + + for _, expression := range policy.Spec.AllowPathExpressions { + err := a.deleteTuplesForExpression(ctx, expression, providerClusterID, policy.Spec.APIExportRef.Name) + if err != nil { + return ctrl.Result{}, errors.NewOperatorError( + fmt.Errorf("deleting tuples for expression %s: %w", expression, err), + true, true) + } + } + + log.Info().Msg("Finalized APIExportPolicy") + return ctrl.Result{}, nil +} + +func (a *APIExportPolicySubroutine) getClusterID(ctx context.Context, clusterPath string) (string, error) { + lcClient, err := iclient.NewForLogicalCluster(a.mgr.GetLocalManager().GetConfig(), a.mgr.GetLocalManager().GetScheme(), logicalcluster.Name(clusterPath)) + if err != nil { + return "", fmt.Errorf("getting client for workspace %s: %w", clusterPath, err) + } + + var lc kcpcorev1alpha1.LogicalCluster + if err := lcClient.Get(ctx, client.ObjectKey{Name: "cluster"}, &lc); err != nil { + return "", fmt.Errorf("getting logical cluster for path %s: %w", clusterPath, err) + } + + clusterID, ok := lc.Annotations["kcp.io/cluster"] + if !ok { + return "", fmt.Errorf("kcp.io/cluster annotation not found on logical cluster %s", clusterPath) + } + return clusterID, nil +} + +func parseAllowExpression(expr string) (workspacePath string, relation string, err error) { + expr = strings.TrimPrefix(expr, ":") + + if !strings.HasPrefix(expr, "root:orgs:") { + return "", "", fmt.Errorf("invalid path expression: must start with root:orgs") + } + + if strings.HasSuffix(expr, ":*") { + // Wildcard pattern, use bind_inherited relation + // Remove the trailing :* + workspacePath = strings.TrimSuffix(expr, ":*") + relation = bindInheritedRelation + return workspacePath, relation, nil + } + return expr, bindRelation, nil +} + +func (a *APIExportPolicySubroutine) deleteRemovedExpressions(ctx context.Context, policy *corev1alpha1.APIExportPolicy) error { + providerClusterID, err := a.getClusterID(ctx, policy.Spec.APIExportRef.ClusterPath) + if err != nil { + return fmt.Errorf("getting provider cluster ID for %s: %w", policy.Spec.APIExportRef.ClusterPath, err) + } + + for _, managedExpr := range policy.Status.ManagedAllowExpressions { + exists := slices.Contains(policy.Spec.AllowPathExpressions, managedExpr) + if exists { + continue + } + + err := a.deleteTuplesForExpression(ctx, managedExpr, providerClusterID, policy.Spec.APIExportRef.Name) + if err != nil { + return fmt.Errorf("removing tuples for expression %s: %w", managedExpr, err) + } + } + return nil + +} + +func (a *APIExportPolicySubroutine) deleteTuplesForExpression(ctx context.Context, expression string, providerClusterID string, apiExportName string) error { + log := logger.LoadLoggerFromContext(ctx) + + workspacePath, relation, err := parseAllowExpression(expression) + if err != nil { + return fmt.Errorf("parsing expression %s: %w", expression, err) + } + + if workspacePath == orgsWorkspacePath { + allclient, err := iclient.NewForAllPlatformMeshResources(ctx, a.mgr.GetLocalManager().GetConfig(), a.mgr.GetLocalManager().GetScheme()) + if err != nil { + return fmt.Errorf("creating all client: %w", err) + } + + var accountInfoList accountsv1alpha1.AccountInfoList + if err := allclient.List(ctx, &accountInfoList); err != nil { + return fmt.Errorf("listing AccountInfo resources for %s: %w", expression, err) + } + + for _, ai := range accountInfoList.Items { + if ai.Spec.FGA.Store.Id == "" { + return fmt.Errorf("empty store id in AccountInfo resources %w", err) + } + + tupleToDelete := corev1alpha1.Tuple{ + Object: fmt.Sprintf("core_platform-mesh_io_account:%s/%s", ai.Spec.Account.OriginClusterId, ai.Spec.Account.Name), + Relation: relation, + User: fmt.Sprintf("apis_kcp_io_apiexport:%s/%s", providerClusterID, apiExportName), + } + + tm := fga.NewTupleManager(a.fga, ai.Spec.FGA.Store.Id, fga.AuthorizationModelIDLatest, log) + if err := tm.Delete(ctx, []corev1alpha1.Tuple{tupleToDelete}); err != nil { + return fmt.Errorf("removing tuple in openFGA: %w", err) + } + } + return nil + } + + lcClient, err := iclient.NewForLogicalCluster(a.mgr.GetLocalManager().GetConfig(), a.mgr.GetLocalManager().GetScheme(), logicalcluster.Name(workspacePath)) + if err != nil { + return fmt.Errorf("getting client for workspace %s: %w", workspacePath, err) + } + + var ai accountsv1alpha1.AccountInfo + if err := lcClient.Get(ctx, client.ObjectKey{Name: "account"}, &ai); err != nil { + return fmt.Errorf("getting AccountInfo for workspace %s: %w", workspacePath, err) + } + + tupleToDelete := corev1alpha1.Tuple{ + Object: fmt.Sprintf("core_platform-mesh_io_account:%s/%s", ai.Spec.Account.OriginClusterId, ai.Spec.Account.Name), + Relation: relation, + User: fmt.Sprintf("apis_kcp_io_apiexport:%s/%s", providerClusterID, apiExportName), + } + + tm := fga.NewTupleManager(a.fga, ai.Spec.FGA.Store.Id, fga.AuthorizationModelIDLatest, log) + if err := tm.Delete(ctx, []corev1alpha1.Tuple{tupleToDelete}); err != nil { + return fmt.Errorf("removing tuples: %w", err) + } + + return nil +}