diff --git a/management/server/http/handler.go b/management/server/http/handler.go index b7c6c113c..9a22bd7fe 100644 --- a/management/server/http/handler.go +++ b/management/server/http/handler.go @@ -26,6 +26,7 @@ import ( "github.com/netbirdio/netbird/management/server/geolocation" nbgroups "github.com/netbirdio/netbird/management/server/groups" "github.com/netbirdio/netbird/management/server/http/handlers/accounts" + "github.com/netbirdio/netbird/management/server/http/handlers/connectors" "github.com/netbirdio/netbird/management/server/http/handlers/dns" "github.com/netbirdio/netbird/management/server/http/handlers/events" "github.com/netbirdio/netbird/management/server/http/handlers/groups" @@ -134,6 +135,7 @@ func NewAPIHandler( dns.AddEndpoints(accountManager, router) events.AddEndpoints(accountManager, router) networks.AddEndpoints(networksManager, resourceManager, routerManager, groupsManager, accountManager, router) + connectors.AddEndpoints(accountManager, router) return rootRouter, nil } diff --git a/management/server/http/handlers/connectors/connectors_handler.go b/management/server/http/handlers/connectors/connectors_handler.go new file mode 100644 index 000000000..8c7515288 --- /dev/null +++ b/management/server/http/handlers/connectors/connectors_handler.go @@ -0,0 +1,590 @@ +package connectors + +import ( + "encoding/json" + "net/http" + + "github.com/gorilla/mux" + + "github.com/netbirdio/netbird/management/server/account" + nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/management/server/idp" + "github.com/netbirdio/netbird/shared/management/http/util" + "github.com/netbirdio/netbird/shared/management/status" +) + +// API request/response types + +// ConnectorResponse represents an IdP connector in API responses +type ConnectorResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + State string `json:"state"` + Issuer string `json:"issuer,omitempty"` + Servers []string `json:"servers,omitempty"` +} + +// OIDCConnectorRequest represents a request to create an OIDC connector +type OIDCConnectorRequest struct { + Name string `json:"name"` + Issuer string `json:"issuer"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + Scopes []string `json:"scopes,omitempty"` + IsAutoCreation bool `json:"is_auto_creation,omitempty"` + IsAutoUpdate bool `json:"is_auto_update,omitempty"` + IsCreationAllowed bool `json:"is_creation_allowed,omitempty"` + IsLinkingAllowed bool `json:"is_linking_allowed,omitempty"` +} + +// LDAPConnectorRequest represents a request to create an LDAP connector +type LDAPConnectorRequest struct { + Name string `json:"name"` + Servers []string `json:"servers"` + StartTLS bool `json:"start_tls,omitempty"` + BaseDN string `json:"base_dn"` + BindDN string `json:"bind_dn"` + BindPassword string `json:"bind_password"` + UserBase string `json:"user_base,omitempty"` + UserObjectClass []string `json:"user_object_class,omitempty"` + UserFilters []string `json:"user_filters,omitempty"` + Timeout string `json:"timeout,omitempty"` + Attributes *LDAPAttributesRequest `json:"attributes,omitempty"` + IsAutoCreation bool `json:"is_auto_creation,omitempty"` + IsAutoUpdate bool `json:"is_auto_update,omitempty"` + IsCreationAllowed bool `json:"is_creation_allowed,omitempty"` + IsLinkingAllowed bool `json:"is_linking_allowed,omitempty"` +} + +// LDAPAttributesRequest maps LDAP attributes to user fields +type LDAPAttributesRequest struct { + IDAttribute string `json:"id_attribute,omitempty"` + FirstNameAttribute string `json:"first_name_attribute,omitempty"` + LastNameAttribute string `json:"last_name_attribute,omitempty"` + DisplayNameAttribute string `json:"display_name_attribute,omitempty"` + EmailAttribute string `json:"email_attribute,omitempty"` +} + +// SAMLConnectorRequest represents a request to create a SAML connector +type SAMLConnectorRequest struct { + Name string `json:"name"` + MetadataXML string `json:"metadata_xml,omitempty"` + MetadataURL string `json:"metadata_url,omitempty"` + Binding string `json:"binding,omitempty"` + WithSignedRequest bool `json:"with_signed_request,omitempty"` + NameIDFormat string `json:"name_id_format,omitempty"` + IsAutoCreation bool `json:"is_auto_creation,omitempty"` + IsAutoUpdate bool `json:"is_auto_update,omitempty"` + IsCreationAllowed bool `json:"is_creation_allowed,omitempty"` + IsLinkingAllowed bool `json:"is_linking_allowed,omitempty"` +} + +// handler handles HTTP requests for IdP connectors +type handler struct { + accountManager account.Manager +} + +// AddEndpoints registers the connector endpoints to the router +func AddEndpoints(accountManager account.Manager, router *mux.Router) { + h := &handler{accountManager: accountManager} + + router.HandleFunc("/connectors", h.listConnectors).Methods("GET", "OPTIONS") + router.HandleFunc("/connectors/{connectorId}", h.getConnector).Methods("GET", "OPTIONS") + router.HandleFunc("/connectors/{connectorId}", h.deleteConnector).Methods("DELETE", "OPTIONS") + router.HandleFunc("/connectors/oidc", h.addOIDCConnector).Methods("POST", "OPTIONS") + router.HandleFunc("/connectors/ldap", h.addLDAPConnector).Methods("POST", "OPTIONS") + router.HandleFunc("/connectors/saml", h.addSAMLConnector).Methods("POST", "OPTIONS") + router.HandleFunc("/connectors/{connectorId}/activate", h.activateConnector).Methods("POST", "OPTIONS") + router.HandleFunc("/connectors/{connectorId}/deactivate", h.deleteConnector).Methods("POST", "OPTIONS") +} + +// getConnectorManager retrieves the connector manager from the IdP manager +func (h *handler) getConnectorManager() (idp.ConnectorManager, error) { + idpManager := h.accountManager.GetIdpManager() + if idpManager == nil { + return nil, status.Errorf(status.PreconditionFailed, "IdP manager is not configured") + } + + connectorManager, ok := idpManager.(idp.ConnectorManager) + if !ok { + return nil, status.Errorf(status.PreconditionFailed, "IdP manager does not support connector management") + } + + return connectorManager, nil +} + +// listConnectors returns all configured IdP connectors +func (h *handler) listConnectors(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w) + return + } + + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + // Only admins can manage connectors + user, err := h.accountManager.GetUserByID(r.Context(), userAuth.UserId) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + if !user.HasAdminPower() { + util.WriteError(r.Context(), status.Errorf(status.PermissionDenied, "only admins can manage IdP connectors"), w) + return + } + + connectorManager, err := h.getConnectorManager() + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + connectors, err := connectorManager.ListConnectors(r.Context()) + if err != nil { + util.WriteError(r.Context(), status.Errorf(status.Internal, "failed to list connectors: %v", err), w) + return + } + + response := make([]*ConnectorResponse, 0, len(connectors)) + for _, c := range connectors { + response = append(response, toConnectorResponse(c)) + } + + util.WriteJSONObject(r.Context(), w, response) +} + +// getConnector returns a specific connector by ID +func (h *handler) getConnector(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w) + return + } + + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + user, err := h.accountManager.GetUserByID(r.Context(), userAuth.UserId) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + if !user.HasAdminPower() { + util.WriteError(r.Context(), status.Errorf(status.PermissionDenied, "only admins can manage IdP connectors"), w) + return + } + + vars := mux.Vars(r) + connectorID := vars["connectorId"] + if connectorID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "connector ID is required"), w) + return + } + + connectorManager, err := h.getConnectorManager() + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + connector, err := connectorManager.GetConnector(r.Context(), connectorID) + if err != nil { + util.WriteError(r.Context(), status.Errorf(status.NotFound, "connector not found: %v", err), w) + return + } + + util.WriteJSONObject(r.Context(), w, toConnectorResponse(connector)) +} + +// deleteConnector removes a connector +func (h *handler) deleteConnector(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w) + return + } + + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + user, err := h.accountManager.GetUserByID(r.Context(), userAuth.UserId) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + if !user.HasAdminPower() { + util.WriteError(r.Context(), status.Errorf(status.PermissionDenied, "only admins can manage IdP connectors"), w) + return + } + + vars := mux.Vars(r) + connectorID := vars["connectorId"] + if connectorID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "connector ID is required"), w) + return + } + + connectorManager, err := h.getConnectorManager() + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + if err := connectorManager.DeleteConnector(r.Context(), connectorID); err != nil { + util.WriteError(r.Context(), status.Errorf(status.Internal, "failed to delete connector: %v", err), w) + return + } + + util.WriteJSONObject(r.Context(), w, util.EmptyObject{}) +} + +// addOIDCConnector creates a new OIDC connector +func (h *handler) addOIDCConnector(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w) + return + } + + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + user, err := h.accountManager.GetUserByID(r.Context(), userAuth.UserId) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + if !user.HasAdminPower() { + util.WriteError(r.Context(), status.Errorf(status.PermissionDenied, "only admins can manage IdP connectors"), w) + return + } + + var req OIDCConnectorRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.WriteErrorResponse("invalid request body", http.StatusBadRequest, w) + return + } + + if req.Name == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "name is required"), w) + return + } + if req.Issuer == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "issuer is required"), w) + return + } + if req.ClientID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "client_id is required"), w) + return + } + if req.ClientSecret == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "client_secret is required"), w) + return + } + + connectorManager, err := h.getConnectorManager() + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + config := idp.OIDCConnectorConfig{ + Name: req.Name, + Issuer: req.Issuer, + ClientID: req.ClientID, + ClientSecret: req.ClientSecret, + Scopes: req.Scopes, + IsAutoCreation: req.IsAutoCreation, + IsAutoUpdate: req.IsAutoUpdate, + IsCreationAllowed: req.IsCreationAllowed, + IsLinkingAllowed: req.IsLinkingAllowed, + } + + connector, err := connectorManager.AddOIDCConnector(r.Context(), config) + if err != nil { + util.WriteError(r.Context(), status.Errorf(status.Internal, "failed to add OIDC connector: %v", err), w) + return + } + + w.WriteHeader(http.StatusCreated) + util.WriteJSONObject(r.Context(), w, toConnectorResponse(connector)) +} + +// addLDAPConnector creates a new LDAP connector +func (h *handler) addLDAPConnector(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w) + return + } + + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + user, err := h.accountManager.GetUserByID(r.Context(), userAuth.UserId) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + if !user.HasAdminPower() { + util.WriteError(r.Context(), status.Errorf(status.PermissionDenied, "only admins can manage IdP connectors"), w) + return + } + + var req LDAPConnectorRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.WriteErrorResponse("invalid request body", http.StatusBadRequest, w) + return + } + + if req.Name == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "name is required"), w) + return + } + if len(req.Servers) == 0 { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "at least one server is required"), w) + return + } + if req.BaseDN == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "base_dn is required"), w) + return + } + if req.BindDN == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "bind_dn is required"), w) + return + } + if req.BindPassword == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "bind_password is required"), w) + return + } + + connectorManager, err := h.getConnectorManager() + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + config := idp.LDAPConnectorConfig{ + Name: req.Name, + Servers: req.Servers, + StartTLS: req.StartTLS, + BaseDN: req.BaseDN, + BindDN: req.BindDN, + BindPassword: req.BindPassword, + UserBase: req.UserBase, + UserObjectClass: req.UserObjectClass, + UserFilters: req.UserFilters, + Timeout: req.Timeout, + IsAutoCreation: req.IsAutoCreation, + IsAutoUpdate: req.IsAutoUpdate, + IsCreationAllowed: req.IsCreationAllowed, + IsLinkingAllowed: req.IsLinkingAllowed, + } + + if req.Attributes != nil { + config.Attributes = idp.LDAPAttributes{ + IDAttribute: req.Attributes.IDAttribute, + FirstNameAttribute: req.Attributes.FirstNameAttribute, + LastNameAttribute: req.Attributes.LastNameAttribute, + DisplayNameAttribute: req.Attributes.DisplayNameAttribute, + EmailAttribute: req.Attributes.EmailAttribute, + } + } + + connector, err := connectorManager.AddLDAPConnector(r.Context(), config) + if err != nil { + util.WriteError(r.Context(), status.Errorf(status.Internal, "failed to add LDAP connector: %v", err), w) + return + } + + w.WriteHeader(http.StatusCreated) + util.WriteJSONObject(r.Context(), w, toConnectorResponse(connector)) +} + +// addSAMLConnector creates a new SAML connector +func (h *handler) addSAMLConnector(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w) + return + } + + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + user, err := h.accountManager.GetUserByID(r.Context(), userAuth.UserId) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + if !user.HasAdminPower() { + util.WriteError(r.Context(), status.Errorf(status.PermissionDenied, "only admins can manage IdP connectors"), w) + return + } + + var req SAMLConnectorRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.WriteErrorResponse("invalid request body", http.StatusBadRequest, w) + return + } + + if req.Name == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "name is required"), w) + return + } + if req.MetadataXML == "" && req.MetadataURL == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "either metadata_xml or metadata_url is required"), w) + return + } + + connectorManager, err := h.getConnectorManager() + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + config := idp.SAMLConnectorConfig{ + Name: req.Name, + MetadataXML: req.MetadataXML, + MetadataURL: req.MetadataURL, + Binding: req.Binding, + WithSignedRequest: req.WithSignedRequest, + NameIDFormat: req.NameIDFormat, + IsAutoCreation: req.IsAutoCreation, + IsAutoUpdate: req.IsAutoUpdate, + IsCreationAllowed: req.IsCreationAllowed, + IsLinkingAllowed: req.IsLinkingAllowed, + } + + connector, err := connectorManager.AddSAMLConnector(r.Context(), config) + if err != nil { + util.WriteError(r.Context(), status.Errorf(status.Internal, "failed to add SAML connector: %v", err), w) + return + } + + w.WriteHeader(http.StatusCreated) + util.WriteJSONObject(r.Context(), w, toConnectorResponse(connector)) +} + +// activateConnector adds the connector to the login policy +func (h *handler) activateConnector(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w) + return + } + + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + user, err := h.accountManager.GetUserByID(r.Context(), userAuth.UserId) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + if !user.HasAdminPower() { + util.WriteError(r.Context(), status.Errorf(status.PermissionDenied, "only admins can manage IdP connectors"), w) + return + } + + vars := mux.Vars(r) + connectorID := vars["connectorId"] + if connectorID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "connector ID is required"), w) + return + } + + connectorManager, err := h.getConnectorManager() + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + if err := connectorManager.ActivateConnector(r.Context(), connectorID); err != nil { + util.WriteError(r.Context(), status.Errorf(status.Internal, "failed to activate connector: %v", err), w) + return + } + + util.WriteJSONObject(r.Context(), w, util.EmptyObject{}) +} + +// deactivateConnector removes the connector from the login policy +func (h *handler) deactivateConnector(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w) + return + } + + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + user, err := h.accountManager.GetUserByID(r.Context(), userAuth.UserId) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + if !user.HasAdminPower() { + util.WriteError(r.Context(), status.Errorf(status.PermissionDenied, "only admins can manage IdP connectors"), w) + return + } + + vars := mux.Vars(r) + connectorID := vars["connectorId"] + if connectorID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "connector ID is required"), w) + return + } + + connectorManager, err := h.getConnectorManager() + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + if err := connectorManager.DeactivateConnector(r.Context(), connectorID); err != nil { + util.WriteError(r.Context(), status.Errorf(status.Internal, "failed to deactivate connector: %v", err), w) + return + } + + util.WriteJSONObject(r.Context(), w, util.EmptyObject{}) +} + +// toConnectorResponse converts an idp.Connector to a ConnectorResponse +func toConnectorResponse(c *idp.Connector) *ConnectorResponse { + return &ConnectorResponse{ + ID: c.ID, + Name: c.Name, + Type: string(c.Type), + State: c.State, + Issuer: c.Issuer, + Servers: c.Servers, + } +} diff --git a/management/server/idp/connector.go b/management/server/idp/connector.go new file mode 100644 index 000000000..a04078239 --- /dev/null +++ b/management/server/idp/connector.go @@ -0,0 +1,457 @@ +package idp + +import ( + "context" + "fmt" +) + +// ConnectorType represents the type of external identity provider connector +type ConnectorType string + +const ( + ConnectorTypeOIDC ConnectorType = "oidc" + ConnectorTypeLDAP ConnectorType = "ldap" + ConnectorTypeSAML ConnectorType = "saml" +) + +// Connector represents an external identity provider configured in Zitadel +type Connector struct { + ID string `json:"id"` + Name string `json:"name"` + Type ConnectorType `json:"type"` + State string `json:"state"` + Issuer string `json:"issuer,omitempty"` // for OIDC + Servers []string `json:"servers,omitempty"` // for LDAP +} + +// OIDCConnectorConfig contains configuration for adding an OIDC connector +type OIDCConnectorConfig struct { + Name string `json:"name"` + Issuer string `json:"issuer"` + ClientID string `json:"clientId"` + ClientSecret string `json:"clientSecret"` + Scopes []string `json:"scopes,omitempty"` + IsIDTokenMapping bool `json:"isIdTokenMapping,omitempty"` + IsAutoCreation bool `json:"isAutoCreation,omitempty"` + IsAutoUpdate bool `json:"isAutoUpdate,omitempty"` + IsCreationAllowed bool `json:"isCreationAllowed,omitempty"` + IsLinkingAllowed bool `json:"isLinkingAllowed,omitempty"` + IsAutoAccountLinking bool `json:"isAutoAccountLinking,omitempty"` + AccountLinkingEnabled bool `json:"accountLinkingEnabled,omitempty"` +} + +// LDAPConnectorConfig contains configuration for adding an LDAP connector +type LDAPConnectorConfig struct { + Name string `json:"name"` + Servers []string `json:"servers"` // e.g., ["ldap://localhost:389"] + StartTLS bool `json:"startTls,omitempty"` + BaseDN string `json:"baseDn"` + BindDN string `json:"bindDn"` + BindPassword string `json:"bindPassword"` + UserBase string `json:"userBase,omitempty"` // typically "dn" + UserObjectClass []string `json:"userObjectClass,omitempty"` // e.g., ["user", "person"] + UserFilters []string `json:"userFilters,omitempty"` // e.g., ["uid", "email"] + Timeout string `json:"timeout,omitempty"` // e.g., "10s" + Attributes LDAPAttributes `json:"attributes,omitempty"` + IsAutoCreation bool `json:"isAutoCreation,omitempty"` + IsAutoUpdate bool `json:"isAutoUpdate,omitempty"` + IsCreationAllowed bool `json:"isCreationAllowed,omitempty"` + IsLinkingAllowed bool `json:"isLinkingAllowed,omitempty"` +} + +// LDAPAttributes maps LDAP attributes to Zitadel user fields +type LDAPAttributes struct { + IDAttribute string `json:"idAttribute,omitempty"` + FirstNameAttribute string `json:"firstNameAttribute,omitempty"` + LastNameAttribute string `json:"lastNameAttribute,omitempty"` + DisplayNameAttribute string `json:"displayNameAttribute,omitempty"` + NickNameAttribute string `json:"nickNameAttribute,omitempty"` + EmailAttribute string `json:"emailAttribute,omitempty"` + EmailVerified string `json:"emailVerified,omitempty"` + PhoneAttribute string `json:"phoneAttribute,omitempty"` + PhoneVerified string `json:"phoneVerified,omitempty"` + AvatarURLAttribute string `json:"avatarUrlAttribute,omitempty"` + ProfileAttribute string `json:"profileAttribute,omitempty"` +} + +// SAMLConnectorConfig contains configuration for adding a SAML connector +type SAMLConnectorConfig struct { + Name string `json:"name"` + MetadataXML string `json:"metadataXml,omitempty"` + MetadataURL string `json:"metadataUrl,omitempty"` + Binding string `json:"binding,omitempty"` // "SAML_BINDING_POST" or "SAML_BINDING_REDIRECT" + WithSignedRequest bool `json:"withSignedRequest,omitempty"` + NameIDFormat string `json:"nameIdFormat,omitempty"` + IsAutoCreation bool `json:"isAutoCreation,omitempty"` + IsAutoUpdate bool `json:"isAutoUpdate,omitempty"` + IsCreationAllowed bool `json:"isCreationAllowed,omitempty"` + IsLinkingAllowed bool `json:"isLinkingAllowed,omitempty"` +} + +// ConnectorManager defines the interface for managing external IdP connectors +type ConnectorManager interface { + // AddOIDCConnector adds a Generic OIDC identity provider connector + AddOIDCConnector(ctx context.Context, config OIDCConnectorConfig) (*Connector, error) + // AddLDAPConnector adds an LDAP identity provider connector + AddLDAPConnector(ctx context.Context, config LDAPConnectorConfig) (*Connector, error) + // AddSAMLConnector adds a SAML identity provider connector + AddSAMLConnector(ctx context.Context, config SAMLConnectorConfig) (*Connector, error) + // ListConnectors returns all configured identity provider connectors + ListConnectors(ctx context.Context) ([]*Connector, error) + // GetConnector returns a specific connector by ID + GetConnector(ctx context.Context, connectorID string) (*Connector, error) + // DeleteConnector removes an identity provider connector + DeleteConnector(ctx context.Context, connectorID string) error + // ActivateConnector adds the connector to the login policy + ActivateConnector(ctx context.Context, connectorID string) error + // DeactivateConnector removes the connector from the login policy + DeactivateConnector(ctx context.Context, connectorID string) error +} + +// zitadelProviderResponse represents the response from creating a provider +type zitadelProviderResponse struct { + ID string `json:"id"` + Details struct { + Sequence string `json:"sequence"` + CreationDate string `json:"creationDate"` + ChangeDate string `json:"changeDate"` + ResourceOwner string `json:"resourceOwner"` + } `json:"details"` +} + +// zitadelProviderTemplate represents a provider in the list response +type zitadelProviderTemplate struct { + ID string `json:"id"` + Name string `json:"name"` + State string `json:"state"` // IDP_STATE_ACTIVE, IDP_STATE_INACTIVE + Type string `json:"type"` // IDP_TYPE_OIDC, IDP_TYPE_LDAP, IDP_TYPE_SAML, etc. + Owner string `json:"owner"` // IDP_OWNER_TYPE_ORG, IDP_OWNER_TYPE_SYSTEM + // Type-specific fields + OIDC *struct { + Issuer string `json:"issuer"` + ClientID string `json:"clientId"` + } `json:"oidc,omitempty"` + LDAP *struct { + Servers []string `json:"servers"` + BaseDN string `json:"baseDn"` + } `json:"ldap,omitempty"` + SAML *struct { + MetadataURL string `json:"metadataUrl"` + } `json:"saml,omitempty"` +} + +// AddOIDCConnector adds a Generic OIDC identity provider connector to Zitadel +func (zm *ZitadelManager) AddOIDCConnector(ctx context.Context, config OIDCConnectorConfig) (*Connector, error) { + // Set defaults for creation/linking if not specified + if !config.IsCreationAllowed && !config.IsLinkingAllowed { + config.IsCreationAllowed = true + config.IsLinkingAllowed = true + } + + payload := map[string]any{ + "name": config.Name, + "issuer": config.Issuer, + "clientId": config.ClientID, + "clientSecret": config.ClientSecret, + "isIdTokenMapping": config.IsIDTokenMapping, + "isAutoCreation": config.IsAutoCreation, + "isAutoUpdate": config.IsAutoUpdate, + "isCreationAllowed": config.IsCreationAllowed, + "isLinkingAllowed": config.IsLinkingAllowed, + } + + if len(config.Scopes) > 0 { + payload["scopes"] = config.Scopes + } + + body, err := zm.helper.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshal OIDC connector config: %w", err) + } + + respBody, err := zm.post(ctx, "idps/generic_oidc", string(body)) + if err != nil { + return nil, fmt.Errorf("add OIDC connector: %w", err) + } + + var resp zitadelProviderResponse + if err := zm.helper.Unmarshal(respBody, &resp); err != nil { + return nil, fmt.Errorf("unmarshal OIDC connector response: %w", err) + } + + return &Connector{ + ID: resp.ID, + Name: config.Name, + Type: ConnectorTypeOIDC, + State: "active", + Issuer: config.Issuer, + }, nil +} + +// AddLDAPConnector adds an LDAP identity provider connector to Zitadel +func (zm *ZitadelManager) AddLDAPConnector(ctx context.Context, config LDAPConnectorConfig) (*Connector, error) { + // Set defaults + if !config.IsCreationAllowed && !config.IsLinkingAllowed { + config.IsCreationAllowed = true + config.IsLinkingAllowed = true + } + if config.UserBase == "" { + config.UserBase = "dn" + } + if config.Timeout == "" { + config.Timeout = "10s" + } + + payload := map[string]any{ + "name": config.Name, + "servers": config.Servers, + "startTls": config.StartTLS, + "baseDn": config.BaseDN, + "bindDn": config.BindDN, + "bindPassword": config.BindPassword, + "userBase": config.UserBase, + "timeout": config.Timeout, + "isAutoCreation": config.IsAutoCreation, + "isAutoUpdate": config.IsAutoUpdate, + "isCreationAllowed": config.IsCreationAllowed, + "isLinkingAllowed": config.IsLinkingAllowed, + } + + if len(config.UserObjectClass) > 0 { + payload["userObjectClasses"] = config.UserObjectClass + } + if len(config.UserFilters) > 0 { + payload["userFilters"] = config.UserFilters + } + + // Add attribute mappings if provided + attrs := make(map[string]string) + if config.Attributes.IDAttribute != "" { + attrs["idAttribute"] = config.Attributes.IDAttribute + } + if config.Attributes.FirstNameAttribute != "" { + attrs["firstNameAttribute"] = config.Attributes.FirstNameAttribute + } + if config.Attributes.LastNameAttribute != "" { + attrs["lastNameAttribute"] = config.Attributes.LastNameAttribute + } + if config.Attributes.DisplayNameAttribute != "" { + attrs["displayNameAttribute"] = config.Attributes.DisplayNameAttribute + } + if config.Attributes.EmailAttribute != "" { + attrs["emailAttribute"] = config.Attributes.EmailAttribute + } + if len(attrs) > 0 { + payload["attributes"] = attrs + } + + body, err := zm.helper.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshal LDAP connector config: %w", err) + } + + respBody, err := zm.post(ctx, "idps/ldap", string(body)) + if err != nil { + return nil, fmt.Errorf("add LDAP connector: %w", err) + } + + var resp zitadelProviderResponse + if err := zm.helper.Unmarshal(respBody, &resp); err != nil { + return nil, fmt.Errorf("unmarshal LDAP connector response: %w", err) + } + + return &Connector{ + ID: resp.ID, + Name: config.Name, + Type: ConnectorTypeLDAP, + State: "active", + Servers: config.Servers, + }, nil +} + +// AddSAMLConnector adds a SAML identity provider connector to Zitadel +func (zm *ZitadelManager) AddSAMLConnector(ctx context.Context, config SAMLConnectorConfig) (*Connector, error) { + // Set defaults + if !config.IsCreationAllowed && !config.IsLinkingAllowed { + config.IsCreationAllowed = true + config.IsLinkingAllowed = true + } + + payload := map[string]any{ + "name": config.Name, + "isAutoCreation": config.IsAutoCreation, + "isAutoUpdate": config.IsAutoUpdate, + "isCreationAllowed": config.IsCreationAllowed, + "isLinkingAllowed": config.IsLinkingAllowed, + } + + if config.MetadataXML != "" { + payload["metadataXml"] = config.MetadataXML + } else if config.MetadataURL != "" { + payload["metadataUrl"] = config.MetadataURL + } else { + return nil, fmt.Errorf("either metadataXml or metadataUrl must be provided") + } + + if config.Binding != "" { + payload["binding"] = config.Binding + } + if config.WithSignedRequest { + payload["withSignedRequest"] = config.WithSignedRequest + } + if config.NameIDFormat != "" { + payload["nameIdFormat"] = config.NameIDFormat + } + + body, err := zm.helper.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshal SAML connector config: %w", err) + } + + respBody, err := zm.post(ctx, "idps/saml", string(body)) + if err != nil { + return nil, fmt.Errorf("add SAML connector: %w", err) + } + + var resp zitadelProviderResponse + if err := zm.helper.Unmarshal(respBody, &resp); err != nil { + return nil, fmt.Errorf("unmarshal SAML connector response: %w", err) + } + + return &Connector{ + ID: resp.ID, + Name: config.Name, + Type: ConnectorTypeSAML, + State: "active", + }, nil +} + +// ListConnectors returns all configured identity provider connectors +func (zm *ZitadelManager) ListConnectors(ctx context.Context) ([]*Connector, error) { + // Use the search endpoint to list all providers + respBody, err := zm.post(ctx, "idps/_search", "{}") + if err != nil { + return nil, fmt.Errorf("list connectors: %w", err) + } + + var resp struct { + Result []zitadelProviderTemplate `json:"result"` + } + if err := zm.helper.Unmarshal(respBody, &resp); err != nil { + return nil, fmt.Errorf("unmarshal connectors response: %w", err) + } + + connectors := make([]*Connector, 0, len(resp.Result)) + for _, p := range resp.Result { + connector := &Connector{ + ID: p.ID, + Name: p.Name, + State: normalizeState(p.State), + Type: normalizeType(p.Type), + } + + // Add type-specific fields + if p.OIDC != nil { + connector.Issuer = p.OIDC.Issuer + } + if p.LDAP != nil { + connector.Servers = p.LDAP.Servers + } + + connectors = append(connectors, connector) + } + + return connectors, nil +} + +// GetConnector returns a specific connector by ID +func (zm *ZitadelManager) GetConnector(ctx context.Context, connectorID string) (*Connector, error) { + respBody, err := zm.get(ctx, fmt.Sprintf("idps/%s", connectorID), nil) + if err != nil { + return nil, fmt.Errorf("get connector: %w", err) + } + + var resp struct { + IDP zitadelProviderTemplate `json:"idp"` + } + if err := zm.helper.Unmarshal(respBody, &resp); err != nil { + return nil, fmt.Errorf("unmarshal connector response: %w", err) + } + + connector := &Connector{ + ID: resp.IDP.ID, + Name: resp.IDP.Name, + State: normalizeState(resp.IDP.State), + Type: normalizeType(resp.IDP.Type), + } + + if resp.IDP.OIDC != nil { + connector.Issuer = resp.IDP.OIDC.Issuer + } + if resp.IDP.LDAP != nil { + connector.Servers = resp.IDP.LDAP.Servers + } + + return connector, nil +} + +// DeleteConnector removes an identity provider connector +func (zm *ZitadelManager) DeleteConnector(ctx context.Context, connectorID string) error { + if err := zm.delete(ctx, fmt.Sprintf("idps/%s", connectorID)); err != nil { + return fmt.Errorf("delete connector: %w", err) + } + return nil +} + +// ActivateConnector adds the connector to the organization's login policy +func (zm *ZitadelManager) ActivateConnector(ctx context.Context, connectorID string) error { + payload := map[string]string{ + "idpId": connectorID, + } + + body, err := zm.helper.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal activate request: %w", err) + } + + _, err = zm.post(ctx, "policies/login/idps", string(body)) + if err != nil { + return fmt.Errorf("activate connector: %w", err) + } + + return nil +} + +// DeactivateConnector removes the connector from the organization's login policy +func (zm *ZitadelManager) DeactivateConnector(ctx context.Context, connectorID string) error { + if err := zm.delete(ctx, fmt.Sprintf("policies/login/idps/%s", connectorID)); err != nil { + return fmt.Errorf("deactivate connector: %w", err) + } + return nil +} + +// normalizeState converts Zitadel state to a simple string +func normalizeState(state string) string { + switch state { + case "IDP_STATE_ACTIVE": + return "active" + case "IDP_STATE_INACTIVE": + return "inactive" + default: + return state + } +} + +// normalizeType converts Zitadel type to ConnectorType +func normalizeType(idpType string) ConnectorType { + switch idpType { + case "IDP_TYPE_OIDC", "IDP_TYPE_OIDC_GENERIC": + return ConnectorTypeOIDC + case "IDP_TYPE_LDAP": + return ConnectorTypeLDAP + case "IDP_TYPE_SAML": + return ConnectorTypeSAML + default: + return ConnectorType(idpType) + } +} diff --git a/management/server/idp/connector_test.go b/management/server/idp/connector_test.go new file mode 100644 index 000000000..cb50550ca --- /dev/null +++ b/management/server/idp/connector_test.go @@ -0,0 +1,313 @@ +package idp + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestZitadelManager_AddOIDCConnector(t *testing.T) { + // Create a mock response for the OIDC connector creation + mockResponse := `{"id": "oidc-123", "details": {"sequence": "1", "creationDate": "2024-01-01T00:00:00Z", "changeDate": "2024-01-01T00:00:00Z", "resourceOwner": "org-1"}}` + + mockClient := &mockHTTPClient{ + code: http.StatusCreated, + resBody: mockResponse, + } + + manager := &ZitadelManager{ + managementEndpoint: "https://zitadel.example.com/management/v1", + httpClient: mockClient, + credentials: &mockCredentials{token: "test-token"}, + helper: JsonParser{}, + } + + config := OIDCConnectorConfig{ + Name: "Okta", + Issuer: "https://okta.example.com", + ClientID: "client-123", + ClientSecret: "secret-456", + Scopes: []string{"openid", "profile", "email"}, + } + + connector, err := manager.AddOIDCConnector(context.Background(), config) + require.NoError(t, err) + assert.Equal(t, "oidc-123", connector.ID) + assert.Equal(t, "Okta", connector.Name) + assert.Equal(t, ConnectorTypeOIDC, connector.Type) + assert.Equal(t, "https://okta.example.com", connector.Issuer) + + // Verify the request body contains expected fields + var reqBody map[string]any + err = json.Unmarshal([]byte(mockClient.reqBody), &reqBody) + require.NoError(t, err) + assert.Equal(t, "Okta", reqBody["name"]) + assert.Equal(t, "https://okta.example.com", reqBody["issuer"]) + assert.Equal(t, "client-123", reqBody["clientId"]) +} + +func TestZitadelManager_AddLDAPConnector(t *testing.T) { + mockResponse := `{"id": "ldap-456", "details": {"sequence": "1", "creationDate": "2024-01-01T00:00:00Z", "changeDate": "2024-01-01T00:00:00Z", "resourceOwner": "org-1"}}` + + mockClient := &mockHTTPClient{ + code: http.StatusCreated, + resBody: mockResponse, + } + + manager := &ZitadelManager{ + managementEndpoint: "https://zitadel.example.com/management/v1", + httpClient: mockClient, + credentials: &mockCredentials{token: "test-token"}, + helper: JsonParser{}, + } + + config := LDAPConnectorConfig{ + Name: "Corporate LDAP", + Servers: []string{"ldap://ldap.example.com:389"}, + BaseDN: "dc=example,dc=com", + BindDN: "cn=admin,dc=example,dc=com", + BindPassword: "admin-password", + Attributes: LDAPAttributes{ + IDAttribute: "uid", + EmailAttribute: "mail", + }, + } + + connector, err := manager.AddLDAPConnector(context.Background(), config) + require.NoError(t, err) + assert.Equal(t, "ldap-456", connector.ID) + assert.Equal(t, "Corporate LDAP", connector.Name) + assert.Equal(t, ConnectorTypeLDAP, connector.Type) + assert.Equal(t, []string{"ldap://ldap.example.com:389"}, connector.Servers) +} + +func TestZitadelManager_AddSAMLConnector(t *testing.T) { + mockResponse := `{"id": "saml-789", "details": {"sequence": "1", "creationDate": "2024-01-01T00:00:00Z", "changeDate": "2024-01-01T00:00:00Z", "resourceOwner": "org-1"}}` + + mockClient := &mockHTTPClient{ + code: http.StatusCreated, + resBody: mockResponse, + } + + manager := &ZitadelManager{ + managementEndpoint: "https://zitadel.example.com/management/v1", + httpClient: mockClient, + credentials: &mockCredentials{token: "test-token"}, + helper: JsonParser{}, + } + + config := SAMLConnectorConfig{ + Name: "Enterprise SAML", + MetadataURL: "https://idp.example.com/metadata.xml", + } + + connector, err := manager.AddSAMLConnector(context.Background(), config) + require.NoError(t, err) + assert.Equal(t, "saml-789", connector.ID) + assert.Equal(t, "Enterprise SAML", connector.Name) + assert.Equal(t, ConnectorTypeSAML, connector.Type) +} + +func TestZitadelManager_AddSAMLConnector_RequiresMetadata(t *testing.T) { + manager := &ZitadelManager{ + managementEndpoint: "https://zitadel.example.com/management/v1", + httpClient: &mockHTTPClient{}, + credentials: &mockCredentials{token: "test-token"}, + helper: JsonParser{}, + } + + config := SAMLConnectorConfig{ + Name: "Invalid SAML", + // Neither MetadataXML nor MetadataURL provided + } + + _, err := manager.AddSAMLConnector(context.Background(), config) + require.Error(t, err) + assert.Contains(t, err.Error(), "metadataXml or metadataUrl must be provided") +} + +func TestZitadelManager_ListConnectors(t *testing.T) { + mockResponse := `{ + "result": [ + { + "id": "oidc-1", + "name": "Google", + "state": "IDP_STATE_ACTIVE", + "type": "IDP_TYPE_OIDC", + "oidc": {"issuer": "https://accounts.google.com", "clientId": "google-client"} + }, + { + "id": "ldap-1", + "name": "AD", + "state": "IDP_STATE_INACTIVE", + "type": "IDP_TYPE_LDAP", + "ldap": {"servers": ["ldap://ad.example.com:389"], "baseDn": "dc=example,dc=com"} + } + ] + }` + + mockClient := &mockHTTPClient{ + code: http.StatusOK, + resBody: mockResponse, + } + + manager := &ZitadelManager{ + managementEndpoint: "https://zitadel.example.com/management/v1", + httpClient: mockClient, + credentials: &mockCredentials{token: "test-token"}, + helper: JsonParser{}, + } + + connectors, err := manager.ListConnectors(context.Background()) + require.NoError(t, err) + require.Len(t, connectors, 2) + + assert.Equal(t, "oidc-1", connectors[0].ID) + assert.Equal(t, "Google", connectors[0].Name) + assert.Equal(t, "active", connectors[0].State) + assert.Equal(t, ConnectorTypeOIDC, connectors[0].Type) + assert.Equal(t, "https://accounts.google.com", connectors[0].Issuer) + + assert.Equal(t, "ldap-1", connectors[1].ID) + assert.Equal(t, "AD", connectors[1].Name) + assert.Equal(t, "inactive", connectors[1].State) + assert.Equal(t, ConnectorTypeLDAP, connectors[1].Type) + assert.Equal(t, []string{"ldap://ad.example.com:389"}, connectors[1].Servers) +} + +func TestZitadelManager_GetConnector(t *testing.T) { + mockResponse := `{ + "idp": { + "id": "oidc-123", + "name": "Okta", + "state": "IDP_STATE_ACTIVE", + "type": "IDP_TYPE_OIDC", + "oidc": {"issuer": "https://okta.example.com", "clientId": "client-123"} + } + }` + + mockClient := &mockHTTPClient{ + code: http.StatusOK, + resBody: mockResponse, + } + + manager := &ZitadelManager{ + managementEndpoint: "https://zitadel.example.com/management/v1", + httpClient: mockClient, + credentials: &mockCredentials{token: "test-token"}, + helper: JsonParser{}, + } + + connector, err := manager.GetConnector(context.Background(), "oidc-123") + require.NoError(t, err) + assert.Equal(t, "oidc-123", connector.ID) + assert.Equal(t, "Okta", connector.Name) + assert.Equal(t, ConnectorTypeOIDC, connector.Type) + assert.Equal(t, "https://okta.example.com", connector.Issuer) +} + +func TestZitadelManager_DeleteConnector(t *testing.T) { + mockClient := &mockHTTPClient{ + code: http.StatusOK, + resBody: "{}", + } + + manager := &ZitadelManager{ + managementEndpoint: "https://zitadel.example.com/management/v1", + httpClient: mockClient, + credentials: &mockCredentials{token: "test-token"}, + helper: JsonParser{}, + } + + err := manager.DeleteConnector(context.Background(), "oidc-123") + require.NoError(t, err) +} + +func TestZitadelManager_ActivateConnector(t *testing.T) { + mockClient := &mockHTTPClient{ + code: http.StatusOK, + resBody: "{}", + } + + manager := &ZitadelManager{ + managementEndpoint: "https://zitadel.example.com/management/v1", + httpClient: mockClient, + credentials: &mockCredentials{token: "test-token"}, + helper: JsonParser{}, + } + + err := manager.ActivateConnector(context.Background(), "oidc-123") + require.NoError(t, err) + + // Verify the request body + var reqBody map[string]string + err = json.Unmarshal([]byte(mockClient.reqBody), &reqBody) + require.NoError(t, err) + assert.Equal(t, "oidc-123", reqBody["idpId"]) +} + +func TestZitadelManager_DeactivateConnector(t *testing.T) { + mockClient := &mockHTTPClient{ + code: http.StatusOK, + resBody: "{}", + } + + manager := &ZitadelManager{ + managementEndpoint: "https://zitadel.example.com/management/v1", + httpClient: mockClient, + credentials: &mockCredentials{token: "test-token"}, + helper: JsonParser{}, + } + + err := manager.DeactivateConnector(context.Background(), "oidc-123") + require.NoError(t, err) +} + +func TestNormalizeState(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"IDP_STATE_ACTIVE", "active"}, + {"IDP_STATE_INACTIVE", "inactive"}, + {"custom", "custom"}, + } + + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + assert.Equal(t, tc.expected, normalizeState(tc.input)) + }) + } +} + +func TestNormalizeType(t *testing.T) { + tests := []struct { + input string + expected ConnectorType + }{ + {"IDP_TYPE_OIDC", ConnectorTypeOIDC}, + {"IDP_TYPE_OIDC_GENERIC", ConnectorTypeOIDC}, + {"IDP_TYPE_LDAP", ConnectorTypeLDAP}, + {"IDP_TYPE_SAML", ConnectorTypeSAML}, + {"CUSTOM", ConnectorType("CUSTOM")}, + } + + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + assert.Equal(t, tc.expected, normalizeType(tc.input)) + }) + } +} + +// mockCredentials is a mock implementation of ManagerCredentials for testing +type mockCredentials struct { + token string +} + +func (m *mockCredentials) Authenticate(ctx context.Context) (JWTToken, error) { + return JWTToken{AccessToken: m.token}, nil +}