diff --git a/connector/keystone/federation.go b/connector/keystone/federation.go new file mode 100644 index 0000000000..8151f5f9fa --- /dev/null +++ b/connector/keystone/federation.go @@ -0,0 +1,283 @@ +package keystone + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/url" + "strings" + "time" + + "github.com/dexidp/dex/connector" +) + +// FederationConnector implements the connector interface for Keystone federation authentication +type FederationConnector struct { + cfg FederationConfig + client *http.Client + logger *slog.Logger +} + +var ( + _ connector.CallbackConnector = &FederationConnector{} + _ connector.RefreshConnector = &FederationConnector{} +) + +// Validate returns error if config is invalid. +func (c *FederationConfig) Validate() error { + var missing []string + + if c.Domain == "" { + missing = append(missing, "domain") + } + if c.Host == "" { + missing = append(missing, "host") + } + if c.AdminUsername == "" { + missing = append(missing, "keystoneUsername") + } + if c.AdminPassword == "" { + missing = append(missing, "keystonePassword") + } + if c.CustomerName == "" { + missing = append(missing, "customerName") + } + if c.ShibbolethLoginPath == "" { + missing = append(missing, "shibbolethLoginPath") + } + if c.FederationAuthPath == "" { + missing = append(missing, "federationAuthPath") + } + + if len(missing) > 0 { + return fmt.Errorf("missing required fields in config: %s", strings.Join(missing, ", ")) + } + return nil +} + +// Open returns a connector using the federation configuration +func (c *FederationConfig) Open(id string, logger *slog.Logger) (connector.Connector, error) { + return NewFederationConnector(*c, logger) +} + +func NewFederationConnector(cfg FederationConfig, logger *slog.Logger) (*FederationConnector, error) { + if err := cfg.Validate(); err != nil { + return nil, err + } + return &FederationConnector{ + cfg: cfg, + client: &http.Client{ + Timeout: time.Duration(cfg.TimeoutSeconds) * time.Second, + }, + logger: logger, + }, nil +} + +func (c *FederationConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, error) { + baseURL := strings.TrimSuffix(c.cfg.Host, "/") + baseURL = strings.TrimSuffix(baseURL, "/keystone") + ssoLoginPath := strings.TrimPrefix(c.cfg.ShibbolethLoginPath, "/") + + u, err := url.Parse(fmt.Sprintf("%s/%s", baseURL, ssoLoginPath)) + if err != nil { + return "", fmt.Errorf("parsing SSO login URL: %w", err) + } + + // The target will be passed through the entire federation flow. + // target is nothing but the redirect url that will be used by shibboleth to redirect back to Dex. + target := fmt.Sprintf("%s?state=%s", callbackURL, state) + q := u.Query() + q.Set("target", target) + u.RawQuery = q.Encode() + return u.String(), nil +} + +func (c *FederationConnector) HandleCallback(scopes connector.Scopes, r *http.Request) (connector.Identity, error) { + c.logger.Debug("dex callback received", "method", r.Method) + + var ksToken string + var err error + var tokenInfo *tokenInfo + identity := connector.Identity{} + + ksToken, err = c.getKeystoneTokenFromFederation(r) + if err != nil { + c.logger.Error("failed to get token from federation cookies", "error", err) + return connector.Identity{}, err + } + c.logger.Debug("successfully obtained token from federation cookies") + + c.logger.Debug("retrieving user info") + tokenInfo, err = getTokenInfo(r.Context(), c.client, c.cfg.Host, ksToken, c.logger) + if err != nil { + return connector.Identity{}, err + } + if scopes.Groups { + c.logger.Debug("groups scope requested, fetching groups") + var err error + adminToken, err := getAdminTokenUnscoped(r.Context(), c.client, c.cfg.Host, c.cfg.AdminUsername, c.cfg.AdminPassword) + if err != nil { + c.logger.Error("failed to obtain admin token", "error", err) + return identity, err + } + identity.Groups, err = getAllGroupsForUser(r.Context(), c.client, c.cfg.Host, adminToken, c.cfg.CustomerName, c.cfg.Domain, tokenInfo, c.logger) + if err != nil { + return connector.Identity{}, err + } + } + identity.Username = tokenInfo.User.Name + identity.UserID = tokenInfo.User.ID + + user, err := getUser(r.Context(), c.client, c.cfg.Host, tokenInfo.User.ID, ksToken) + if err != nil { + return identity, err + } + if user.User.Email != "" { + identity.Email = user.User.Email + identity.EmailVerified = true + } + + data := connectorData{Token: ksToken} + connData, err := json.Marshal(data) + if err != nil { + c.logger.Error("failed to marshal connector data", "error", err) + return identity, err + } + identity.ConnectorData = connData + + return identity, nil +} + +// getKeystoneTokenFromFederation gets a Keystone token using an existing federation session. +// This method extracts federation cookies from the request and uses them to authenticate +// with Keystone's federation endpoint. +func (c *FederationConnector) getKeystoneTokenFromFederation(r *http.Request) (string, error) { + c.logger.Debug("getting keystone token from federation cookies") + baseURL := strings.TrimSuffix(c.cfg.Host, "/") + federationAuthPath := strings.TrimPrefix(c.cfg.FederationAuthPath, "/") + + federationAuthURL := fmt.Sprintf("%s/%s", baseURL, federationAuthPath) + c.logger.Debug("requesting keystone token from federation auth endpoint") + + req, err := http.NewRequest("GET", federationAuthURL, nil) + if err != nil { + c.logger.Error("failed to create federation auth request", "error", err) + return "", err + } + + shibbolethCookiePrefixes := []string{ + "_shibsession", + "_shibstate", + } + + for _, cookie := range r.Cookies() { + cookieName := strings.ToLower(cookie.Name) + for _, prefix := range shibbolethCookiePrefixes { + if strings.HasPrefix(cookieName, prefix) { + req.AddCookie(cookie) + break + } + } + } + + if userAgent := r.Header.Get("User-Agent"); userAgent != "" { + req.Header.Set("User-Agent", userAgent) + } + if referer := r.Header.Get("Referer"); referer != "" { + req.Header.Set("Referer", referer) + } + + clientNoRedirect := &http.Client{ + Timeout: c.client.Timeout, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + resp, err := clientNoRedirect.Do(req) + if err != nil { + c.logger.Error("failed to execute federation auth request", "error", err) + return "", err + } + defer resp.Body.Close() + + token := resp.Header.Get("X-Subject-Token") + if token == "" { + c.logger.Error("No X-Subject-Token found in federation auth response") + return "", fmt.Errorf("no X-Subject-Token found in federation auth response") + } + + c.logger.Debug("successfully obtained keystone token from federation") + return token, nil +} + +// Close does nothing since HTTP connections are closed automatically. +func (c *FederationConnector) Close() error { + return nil +} + +// Refresh is used to refresh identity during token refresh. +// It checks if the user still exists and refreshes their group membership. +func (c *FederationConnector) Refresh( + ctx context.Context, scopes connector.Scopes, identity connector.Identity, +) (connector.Identity, error) { + c.logger.Info("refresh called", "userID", identity.UserID) + + adminToken, err := getAdminTokenUnscoped(ctx, c.client, c.cfg.Host, c.cfg.AdminUsername, c.cfg.AdminPassword) + if err != nil { + c.logger.Error("failed to obtain admin token for refresh", "error", err) + return identity, err + } + + // Check if the user still exists + user, err := getUser(ctx, c.client, c.cfg.Host, identity.UserID, adminToken) + if err != nil { + c.logger.Error("failed to get user", "userID", identity.UserID, "error", err) + return identity, err + } + if user == nil { + c.logger.Error("user does not exist", "userID", identity.UserID) + return identity, fmt.Errorf("keystone federation: user %q does not exist", identity.UserID) + } + + tokenInfo := &tokenInfo{ + User: userKeystone{ + Name: identity.Username, + ID: identity.UserID, + }, + } + + // If there is a token associated with this refresh token, use that to get more info + var data connectorData + if err := json.Unmarshal(identity.ConnectorData, &data); err != nil { + c.logger.Error("failed to unmarshal connector data", "error", err) + return identity, err + } + + // If we have a stored token, try to use it to get token info + if len(data.Token) > 0 { + c.logger.Debug("using stored token to get token info") + tokenInfoFromStored, err := getTokenInfo(ctx, c.client, c.cfg.Host, data.Token, c.logger) + if err == nil { + // Only use the stored token info if we could retrieve it successfully + tokenInfo = tokenInfoFromStored + } else { + c.logger.Warn("could not get token info from stored token", "error", err) + } + } + + // If groups scope is requested, refresh the groups + if scopes.Groups { + c.logger.Info("refreshing groups", "userID", identity.UserID) + var err error + identity.Groups, err = getAllGroupsForUser(ctx, c.client, c.cfg.Host, adminToken, c.cfg.CustomerName, c.cfg.Domain, tokenInfo, c.logger) + if err != nil { + c.logger.Error("failed to get groups", "userID", identity.UserID, "error", err) + return identity, err + } + } + + return identity, nil +} diff --git a/connector/keystone/federation_test.go b/connector/keystone/federation_test.go new file mode 100644 index 0000000000..989ff13314 --- /dev/null +++ b/connector/keystone/federation_test.go @@ -0,0 +1,215 @@ +package keystone + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/dexidp/dex/connector" +) + +// minimal token structures for responses +type testTokenUser struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type testToken struct { + User testTokenUser `json:"user"` +} + +type testTokenResponse struct { + Token testToken `json:"token"` +} + +type testUserResponse struct { + User struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + } `json:"user"` +} + +func newTestFederationConnector(t *testing.T, cfg FederationConfig) *FederationConnector { + t.Helper() + logger := slog.New(slog.NewTextHandler(testDiscard{}, nil)) + fc, err := NewFederationConnector(cfg, logger) + if err != nil { + t.Fatalf("failed to create FederationConnector: %v", err) + } + return fc +} + +// testDiscard implements io.Writer but discards output +type testDiscard struct{} + +func (testDiscard) Write(p []byte) (int, error) { return len(p), nil } + +func TestFederation_LoginURL(t *testing.T) { + cases := []struct { + name string + host string + path string + }{ + {"no trailing/leading slash", "https://abc.com/keystone", "shib/login"}, + {"with trailing/leading slash", "https://abc.com/keystone/", "/shib/login"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg := FederationConfig{ + Domain: "default", + Host: tc.host, + AdminUsername: "admin", + AdminPassword: "pass", + CustomerName: "cust", + ShibbolethLoginPath: tc.path, + FederationAuthPath: "/fed/auth", + TimeoutSeconds: 5, + } + fc := newTestFederationConnector(t, cfg) + callback := "https://dex/callback" + state := "mystate" + u, err := fc.LoginURL(connector.Scopes{}, callback, state) + if err != nil { + t.Fatalf("LoginURL error: %v", err) + } + + parsed, err := url.Parse(u) + if err != nil { + t.Fatalf("parse result: %v", err) + } + // Expect path to be shib path at the root (host may have had trailing /keystone stripped) + if got, want := parsed.Path, "/shib/login"; got != want { + t.Fatalf("unexpected path: got %q want %q", got, want) + } + // target query must include callback and state + target := parsed.Query().Get("target") + if target == "" || target[:len(callback)] != callback { + t.Fatalf("missing/invalid target query: %q", target) + } + if target != fmt.Sprintf("%s?state=%s", callback, state) { + t.Fatalf("unexpected target: %q", target) + } + }) + } +} + +func TestFederation_getKeystoneTokenFromFederation(t *testing.T) { + // Test server that returns a token header on federation auth path + fedPath := "/fed/auth" + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == fedPath { + w.Header().Set("X-Subject-Token", "FED_TOKEN") + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer ts.Close() + + cfg := FederationConfig{ + Domain: "default", + Host: ts.URL, + AdminUsername: "admin", + AdminPassword: "pass", + CustomerName: "cust", + ShibbolethLoginPath: "/shib/login", + FederationAuthPath: fedPath, + TimeoutSeconds: 5, + } + fc := newTestFederationConnector(t, cfg) + + r, _ := http.NewRequest(http.MethodGet, "https://dex/callback", nil) + // Add cookies that should be forwarded (prefix is optional for this test) + r.AddCookie(&http.Cookie{Name: "_shibsession_123", Value: "abc"}) + + tok, err := fc.getKeystoneTokenFromFederation(r) + if err != nil { + t.Fatalf("getKeystoneTokenFromFederation error: %v", err) + } + if tok != "FED_TOKEN" { + t.Fatalf("unexpected token: got %q want %q", tok, "FED_TOKEN") + } +} + +func TestFederation_HandleCallback_NoGroups(t *testing.T) { + // Simulate keystone endpoints used in HandleCallback when Groups=false + fedPath := "/fed/auth" + userID := "u-123" + userName := "user1" + userEmail := "user1@example.com" + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == fedPath: + // federation auth returns subject token header + w.Header().Set("X-Subject-Token", "FED_TOKEN") + w.WriteHeader(http.StatusOK) + return + case r.Method == http.MethodPost && r.URL.Path == "/v3/auth/tokens": + // admin token unscoped expects 201 and header + w.Header().Set("X-Subject-Token", "ADMIN_TOKEN") + w.WriteHeader(http.StatusCreated) + return + case r.Method == http.MethodGet && r.URL.Path == "/v3/auth/tokens": + // getTokenInfo returns token info JSON + resp := testTokenResponse{Token: testToken{User: testTokenUser{ID: userID, Name: userName}}} + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(resp) + return + case r.Method == http.MethodGet && r.URL.Path == "/v3/users/"+userID: + // getUser returns user details with email + var ur testUserResponse + ur.User.ID = userID + ur.User.Name = userName + ur.User.Email = userEmail + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(ur) + return + default: + w.WriteHeader(http.StatusNotFound) + return + } + })) + defer ts.Close() + + cfg := FederationConfig{ + Domain: "Default", + Host: ts.URL, + AdminUsername: "admin", + AdminPassword: "pass", + CustomerName: "cust", + ShibbolethLoginPath: "/shib/login", + FederationAuthPath: fedPath, + TimeoutSeconds: 5, + } + fc := newTestFederationConnector(t, cfg) + + r, _ := http.NewRequest(http.MethodGet, "https://dex/callback", nil) + // Add a shibboleth cookie (optional) + r.AddCookie(&http.Cookie{Name: "_shibsession_123", Value: "abc"}) + + scopes := connector.Scopes{Groups: false} + identity, err := fc.HandleCallback(scopes, r) + if err != nil { + t.Fatalf("HandleCallback error: %v", err) + } + + if identity.UserID != userID { + t.Fatalf("unexpected userID: got %q want %q", identity.UserID, userID) + } + if identity.Username != userName { + t.Fatalf("unexpected username: got %q want %q", identity.Username, userName) + } + if identity.Email != userEmail || !identity.EmailVerified { + t.Fatalf("unexpected email fields: email=%q verified=%v", identity.Email, identity.EmailVerified) + } + if len(identity.Groups) != 0 { + t.Fatalf("expected no groups when Groups=false, got %v", identity.Groups) + } +} diff --git a/connector/keystone/keystone.go b/connector/keystone/keystone.go index 984a6112ae..d22c93bcf4 100644 --- a/connector/keystone/keystone.go +++ b/connector/keystone/keystone.go @@ -26,138 +26,6 @@ type conn struct { CustomerName string } -// type group struct { -// Name string `json:"name"` -// Replace string `json:"replace"` -// } - -type userKeystone struct { - Domain domainKeystone `json:"domain"` - ID string `json:"id"` - Name string `json:"name"` - OSFederation *struct { - Groups []keystoneGroup `json:"groups"` - IdentityProvider struct { - ID string `json:"id"` - } `json:"identity_provider"` - Protocol struct { - ID string `json:"id"` - } `json:"protocol"` - } `json:"OS-FEDERATION"` -} - -type domainKeystone struct { - ID string `json:"id,omitempty"` - Name string `json:"name,omitempty"` -} - -// Config holds the configuration parameters for Keystone connector. -// Keystone should expose API v3 -// An example config: -// -// connectors: -// type: keystone -// id: keystone -// name: Keystone -// config: -// keystoneHost: http://example:5000 -// domain: default -// keystoneUsername: demo -// keystonePassword: DEMO_PASS -// useRolesAsGroups: true -type Config struct { - Domain string `json:"domain"` - Host string `json:"keystoneHost"` - AdminUsername string `json:"keystoneUsername"` - AdminPassword string `json:"keystonePassword"` - InsecureSkipVerify bool `json:"insecureSkipVerify"` - CustomerName string `json:"customerName"` -} - -type loginRequestData struct { - auth `json:"auth"` -} - -type auth struct { - Identity identity `json:"identity"` -} - -type identity struct { - Methods []string `json:"methods"` - Password password `json:"password"` -} - -type password struct { - User user `json:"user"` -} - -type user struct { - Name string `json:"name"` - Domain domainKeystone `json:"domain"` - Password string `json:"password"` -} - -// type domain struct { -// ID string `json:"id"` -// } - -type tokenInfo struct { - User userKeystone `json:"user"` - Roles []role `json:"roles"` -} - -type tokenResponse struct { - Token tokenInfo `json:"token"` -} - -type keystoneGroup struct { - ID string `json:"id"` - Name string `json:"name"` -} - -type groupsResponse struct { - Groups []keystoneGroup `json:"groups"` -} - -type userResponse struct { - User struct { - Name string `json:"name"` - Email string `json:"email"` - ID string `json:"id"` - } `json:"user"` -} - -type role struct { - ID string `json:"id"` - Name string `json:"name"` - DomainID string `json:"domain_id"` - Description string `json:"description"` -} - -type project struct { - ID string `json:"id"` - Name string `json:"name"` - DomainID string `json:"domain_id"` - Description string `json:"description"` -} -type identifierContainer struct { - ID string `json:"id"` -} - -type projectScope struct { - Project identifierContainer `json:"project"` -} - -type roleAssignment struct { - Scope projectScope `json:"scope"` - User identifierContainer `json:"user"` - Role identifierContainer `json:"role"` -} - -type connectorData struct { - Token string `json:"token"` -} - var ( _ connector.PasswordConnector = &conn{} _ connector.RefreshConnector = &conn{} @@ -192,7 +60,7 @@ func (p *conn) Login(ctx context.Context, scopes connector.Scopes, username, pas var tokenInfo *tokenInfo if username == "" || username == "_TOKEN_" { token = password - tokenInfo, err = p.getTokenInfo(ctx, token) + tokenInfo, err = getTokenInfo(ctx, p.client, p.Host, token, p.Logger) if err != nil { return connector.Identity{}, false, err } @@ -202,15 +70,15 @@ func (p *conn) Login(ctx context.Context, scopes connector.Scopes, username, pas return identity, false, err } } - if scopes.Groups { - p.Logger.Info("groups scope requested, fetching groups") + p.Logger.Debug("groups scope requested, fetching groups") var err error - adminToken, err := p.getAdminTokenUnscoped(ctx) + adminToken, err := getAdminTokenUnscoped(ctx, p.client, p.Host, p.AdminUsername, p.AdminPassword) if err != nil { - return identity, false, fmt.Errorf("keystone: failed to obtain admin token: %v", err) + p.Logger.Error("failed to obtain admin token", "error", err) + return identity, false, err } - identity.Groups, err = p.getGroups(ctx, adminToken, tokenInfo) + identity.Groups, err = getAllGroupsForUser(ctx, p.client, p.Host, adminToken, p.CustomerName, p.Domain.Name, tokenInfo, p.Logger) if err != nil { return connector.Identity{}, false, err } @@ -218,7 +86,7 @@ func (p *conn) Login(ctx context.Context, scopes connector.Scopes, username, pas identity.Username = tokenInfo.User.Name identity.UserID = tokenInfo.User.ID - user, err := p.getUser(ctx, tokenInfo.User.ID, token) + user, err := getUser(ctx, p.client, p.Host, tokenInfo.User.ID, token) if err != nil { return identity, false, err } @@ -230,7 +98,8 @@ func (p *conn) Login(ctx context.Context, scopes connector.Scopes, username, pas data := connectorData{Token: token} connData, err := json.Marshal(data) if err != nil { - return identity, false, fmt.Errorf("marshal connector data: %v", err) + p.Logger.Error("failed to marshal connector data", "error", err) + return identity, false, err } identity.ConnectorData = connData @@ -242,9 +111,10 @@ func (p *conn) Prompt() string { return "username" } func (p *conn) Refresh( ctx context.Context, scopes connector.Scopes, identity connector.Identity, ) (connector.Identity, error) { - token, err := p.getAdminTokenUnscoped(ctx) + token, err := getAdminTokenUnscoped(ctx, p.client, p.Host, p.AdminUsername, p.AdminPassword) if err != nil { - return identity, fmt.Errorf("keystone: failed to obtain admin token: %v", err) + p.Logger.Error("failed to obtain admin token", "error", err) + return identity, err } ok, err := p.checkIfUserExists(ctx, identity.UserID, token) @@ -252,6 +122,7 @@ func (p *conn) Refresh( return identity, err } if !ok { + p.Logger.Error("user does not exist", "userID", identity.UserID) return identity, fmt.Errorf("keystone: user %q does not exist", identity.UserID) } @@ -263,12 +134,13 @@ func (p *conn) Refresh( } var data connectorData if err := json.Unmarshal(identity.ConnectorData, &data); err != nil { - return identity, fmt.Errorf("keystone: unmarshal token info: %v", err) + p.Logger.Error("failed to unmarshal token info", "error", err) + return identity, err } // If there is a token associated with this refresh token, use that to look up // the token info. This can contain things like SSO groups which are not present elsewhere. if len(data.Token) > 0 { - tokenInfo, err = p.getTokenInfo(ctx, data.Token) + tokenInfo, err = getTokenInfo(ctx, p.client, p.Host, data.Token, p.Logger) if err != nil { return identity, err } @@ -276,7 +148,7 @@ func (p *conn) Refresh( if scopes.Groups { var err error - identity.Groups, err = p.getGroups(ctx, token, tokenInfo) + identity.Groups, err = getAllGroupsForUser(ctx, p.client, p.Host, token, p.CustomerName, p.Domain.Name, tokenInfo, p.Logger) if err != nil { return identity, err } @@ -303,8 +175,13 @@ func (p *conn) authenticate(ctx context.Context, username, pass string) (string, if err != nil { return "", nil, err } - // https://developer.openstack.org/api-ref/identity/v3/#password-authentication-with-unscoped-authorization - authTokenURL := p.Host + "/v3/auth/tokens/" + + // Build auth token URL preserving any base path in p.Host (e.g., /keystone) + authTokenURL, err := url.JoinPath(p.Host, "v3", "auth", "tokens") + if err != nil { + return "", nil, err + } + req, err := http.NewRequest("POST", authTokenURL, bytes.NewBuffer(jsonValue)) if err != nil { return "", nil, err @@ -315,10 +192,12 @@ func (p *conn) authenticate(ctx context.Context, username, pass string) (string, resp, err := p.client.Do(req) if err != nil { - return "", nil, fmt.Errorf("keystone: error %v", err) + p.Logger.Error("keystone authentication request failed", "error", err) + return "", nil, err } if resp.StatusCode/100 != 2 { - return "", nil, fmt.Errorf("keystone login: error %v", resp.StatusCode) + p.Logger.Error("keystone login failed", "statusCode", resp.StatusCode) + return "", nil, fmt.Errorf("keystone login: URL %s error %v", authTokenURL, resp.StatusCode) } if resp.StatusCode != 201 { return "", nil, nil @@ -332,12 +211,13 @@ func (p *conn) authenticate(ctx context.Context, username, pass string) (string, tokenResp := &tokenResponse{} err = json.Unmarshal(data, tokenResp) if err != nil { - return "", nil, fmt.Errorf("keystone: invalid token response: %v", err) + p.Logger.Error("invalid token response", "error", err) + return "", nil, err } return token, &tokenResp.Token, nil } -func (p *conn) getAdminTokenUnscoped(ctx context.Context) (string, error) { +func getAdminTokenUnscoped(ctx context.Context, client *http.Client, baseURL, adminUsername, adminPassword string) (string, error) { domain := domainKeystone{ Name: "Default", } @@ -347,9 +227,9 @@ func (p *conn) getAdminTokenUnscoped(ctx context.Context) (string, error) { Methods: []string{"password"}, Password: password{ User: user{ - Name: p.AdminUsername, + Name: adminUsername, Domain: domain, - Password: p.AdminPassword, + Password: adminPassword, }, }, }, @@ -360,7 +240,10 @@ func (p *conn) getAdminTokenUnscoped(ctx context.Context) (string, error) { return "", err } // https://developer.openstack.org/api-ref/identity/v3/#password-authentication-with-unscoped-authorization - authTokenURL := p.Host + "/v3/auth/tokens/" + authTokenURL, err := url.JoinPath(baseURL, "v3", "auth", "tokens") + if err != nil { + return "", err + } req, err := http.NewRequest("POST", authTokenURL, bytes.NewBuffer(jsonValue)) if err != nil { return "", err @@ -368,7 +251,7 @@ func (p *conn) getAdminTokenUnscoped(ctx context.Context) (string, error) { req.Header.Set("Content-Type", "application/json") req = req.WithContext(ctx) - resp, err := p.client.Do(req) + resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("keystone: error %v", err) } @@ -382,238 +265,135 @@ func (p *conn) getAdminTokenUnscoped(ctx context.Context) (string, error) { } func (p *conn) checkIfUserExists(ctx context.Context, userID string, token string) (bool, error) { - user, err := p.getUser(ctx, userID, token) + user, err := getUser(ctx, p.client, p.Host, userID, token) return user != nil, err } -func (p *conn) getGroups(ctx context.Context, token string, tokenInfo *tokenInfo) ([]string, error) { - var userGroups []string //nolint:prealloc - var userGroupIDs []string //nolint:prealloc - - allGroups, err := p.getAllGroups(ctx, token) +// getAllKeystoneGroups returns all groups in keystone +func getAllKeystoneGroups(ctx context.Context, client *http.Client, baseURL, token string) ([]keystoneGroup, error) { + // https://docs.openstack.org/api-ref/identity/v3/?expanded=list-groups-detail#list-groups + groupsURL, err := url.JoinPath(baseURL, "v3", "groups") if err != nil { return nil, err } - - // For SSO users, groups are passed down through the federation API. - if tokenInfo.User.OSFederation != nil { - for _, osGroup := range tokenInfo.User.OSFederation.Groups { - // If grouop name is empty, try to find the group by ID - if len(osGroup.Name) == 0 { - var ok bool - osGroup, ok = findGroupByID(allGroups, osGroup.ID) - if !ok { - p.Logger.Warn("GroupID attached to user could not be found. Skipping.", "group_id", osGroup.ID, "user_id", tokenInfo.User.ID) - continue - } - } - userGroups = append(userGroups, osGroup.Name) - userGroupIDs = append(userGroupIDs, osGroup.ID) - } - } - - // For local users, fetch the groups stored in Keystone. - localGroups, err := p.getUserGroups(ctx, tokenInfo.User.ID, token) + req, err := http.NewRequest(http.MethodGet, groupsURL, nil) if err != nil { return nil, err } - - for _, localGroup := range localGroups { - // If group name is empty, try to find the group by ID - if len(localGroup.Name) == 0 { - var ok bool - localGroup, ok = findGroupByID(allGroups, localGroup.ID) - if !ok { - p.Logger.Warn("Group with ID attached to user could not be found. Skipping.", "group_id", localGroup.ID, "user_id", tokenInfo.User.ID) - continue - } - } - userGroups = append(userGroups, localGroup.Name) - userGroupIDs = append(userGroupIDs, localGroup.ID) - } - - // Get user-related role assignments - roleAssignments := []roleAssignment{} - localUserRoleAssignments, err := p.getRoleAssignments(ctx, token, getRoleAssignmentsOptions{ - userID: tokenInfo.User.ID, - }) - if err != nil { - p.Logger.Error("failed to fetch role assignments for userID", "userID", tokenInfo.User.ID, "error", err) - return userGroups, err - } - roleAssignments = append(roleAssignments, localUserRoleAssignments...) - - // Get group-related role assignments - for _, groupID := range userGroupIDs { - groupRoleAssignments, err := p.getRoleAssignments(ctx, token, getRoleAssignmentsOptions{ - groupID: groupID, - }) - if err != nil { - p.Logger.Error("failed to fetch role assignments for groupID", "groupID", groupID, "error", err) - return userGroups, err - } - roleAssignments = append(roleAssignments, groupRoleAssignments...) - } - - if len(roleAssignments) == 0 { - p.Logger.Warn("Warning: no role assignments found.") - return userGroups, nil - } - - roles, err := p.getRoles(ctx, token) + req.Header.Set("X-Auth-Token", token) + req = req.WithContext(ctx) + resp, err := client.Do(req) if err != nil { - return userGroups, err - } - roleMap := map[string]role{} - for _, role := range roles { - roleMap[role.ID] = role + return nil, err } - projects, err := p.getProjects(ctx, token) + data, err := io.ReadAll(resp.Body) if err != nil { - return userGroups, err - } - projectMap := map[string]project{} - for _, project := range projects { - projectMap[project.ID] = project - } - - // Now create groups based on the role assignments - roleGroups := make([]string, 0, len(roleAssignments)) - - // get the customer name to be prefixed in the group name - customerName := p.CustomerName - // if customerName is not provided in the keystone config get it from keystone host url. - if customerName == "" { - customerName, err = p.getHostname() - if err != nil { - return userGroups, err - } - } - for _, roleAssignment := range roleAssignments { - role, ok := roleMap[roleAssignment.Role.ID] - if !ok { - // Ignore role assignments to non-existent roles (shouldn't happen) - continue - } - project, ok := projectMap[roleAssignment.Scope.Project.ID] - if !ok { - // Ignore role assignments to non-existent projects (shouldn't happen) - continue - } - groupName := p.generateGroupName(project, role, customerName) - roleGroups = append(roleGroups, groupName) + return nil, err } + defer resp.Body.Close() - // combine user-groups and role-groups - userGroups = append(userGroups, roleGroups...) - return pruneDuplicates(userGroups), nil -} + groupsResp := new(groupsResponse) -func (p *conn) getHostname() (string, error) { - keystoneURL := p.Host - parsedURL, err := url.Parse(keystoneURL) + err = json.Unmarshal(data, &groupsResp) if err != nil { - return "", fmt.Errorf("error parsing URL: %v", err) + return nil, err } - customerFqdn := parsedURL.Hostname() - // get customer name and not the full fqdn - parts := strings.Split(customerFqdn, ".") - hostName := parts[0] - - return hostName, nil + return groupsResp.Groups, nil } -func (p *conn) generateGroupName(project project, role role, customerName string) string { - roleName := role.Name - if roleName == "_member_" { - roleName = "member" +// getUserLocalGroups returns local groups for a user +func getUserLocalGroups(ctx context.Context, client *http.Client, baseURL, userID, token string) ([]keystoneGroup, error) { + // https://developer.openstack.org/api-ref/identity/v3/#list-groups-to-which-a-user-belongs + groupsURL, err := url.JoinPath(baseURL, "v3", "users", userID, "groups") + if err != nil { + return nil, err } - domainName := strings.ToLower(strings.ReplaceAll(p.Domain.Name, "_", "-")) - projectName := strings.ToLower(strings.ReplaceAll(project.Name, "_", "-")) - return customerName + "-" + domainName + "-" + projectName + "-" + roleName -} - -func (p *conn) getUser(ctx context.Context, userID string, token string) (*userResponse, error) { - // https://developer.openstack.org/api-ref/identity/v3/#show-user-details - userURL := p.Host + "/v3/users/" + userID - req, err := http.NewRequest("GET", userURL, nil) + req, err := http.NewRequest("GET", groupsURL, nil) if err != nil { return nil, err } - req.Header.Set("X-Auth-Token", token) req = req.WithContext(ctx) - resp, err := p.client.Do(req) + resp, err := client.Do(req) if err != nil { return nil, err } - defer resp.Body.Close() - if resp.StatusCode != 200 { + data, err := io.ReadAll(resp.Body) + if err != nil { return nil, err } + defer resp.Body.Close() - data, err := io.ReadAll(resp.Body) + groupsResp := new(groupsResponse) + + err = json.Unmarshal(data, &groupsResp) if err != nil { return nil, err } + return groupsResp.Groups, nil +} - user := userResponse{} - err = json.Unmarshal(data, &user) +// getRoleAssignments returns role assignments for a user or group +func getRoleAssignments(ctx context.Context, client *http.Client, baseURL, token string, opts getRoleAssignmentsOptions, logger *slog.Logger) ([]roleAssignment, error) { + endpoint, err := url.JoinPath(baseURL, "v3", "role_assignments") if err != nil { return nil, err } + if len(opts.userID) > 0 { + endpoint = fmt.Sprintf("%s?effective&user.id=%s", endpoint, opts.userID) + } else if len(opts.groupID) > 0 { + endpoint = fmt.Sprintf("%s?group.id=%s", endpoint, opts.groupID) + } - return &user, nil -} - -func (p *conn) getTokenInfo(ctx context.Context, token string) (*tokenInfo, error) { - // https://developer.openstack.org/api-ref/identity/v3/#password-authentication-with-unscoped-authorization - authTokenURL := p.Host + "/v3/auth/tokens" - p.Logger.Info("Fetching Keystone token info", "url", authTokenURL) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, authTokenURL, nil) + // https://docs.openstack.org/api-ref/identity/v3/?expanded=validate-and-show-information-for-token-detail,list-role-assignments-detail#list-role-assignments + req, err := http.NewRequest(http.MethodGet, endpoint, nil) if err != nil { return nil, err } req.Header.Set("X-Auth-Token", token) - req.Header.Set("X-Subject-Token", token) - resp, err := p.client.Do(req) + req = req.WithContext(ctx) + resp, err := client.Do(req) if err != nil { + logger.Error("failed to fetch role assignments", "error", err) return nil, err } + data, err := io.ReadAll(resp.Body) if err != nil { return nil, err } defer resp.Body.Close() - if resp.StatusCode >= 400 { - p.Logger.Error("keystone: failed to get token info", "error_status_code", resp.StatusCode, "response", strings.ReplaceAll(string(data), "\n", "")) - return nil, fmt.Errorf("keystone: get token info: error status code %d", resp.StatusCode) - } + roleAssignmentResp := struct { + RoleAssignments []roleAssignment `json:"role_assignments"` + }{} - tokenResp := &tokenResponse{} - err = json.Unmarshal(data, tokenResp) + err = json.Unmarshal(data, &roleAssignmentResp) if err != nil { return nil, err } - return &tokenResp.Token, nil + return roleAssignmentResp.RoleAssignments, nil } -func (p *conn) getAllGroups(ctx context.Context, token string) ([]keystoneGroup, error) { - // https://docs.openstack.org/api-ref/identity/v3/?expanded=list-groups-detail#list-groups - groupsURL := p.Host + "/v3/groups" - req, err := http.NewRequest(http.MethodGet, groupsURL, nil) +// getRoles returns all roles in keystone +func getRoles(ctx context.Context, client *http.Client, baseURL, token string, logger *slog.Logger) ([]role, error) { + // https://docs.openstack.org/api-ref/identity/v3/?expanded=validate-and-show-information-for-token-detail,list-role-assignments-detail,list-roles-detail#list-roles + rolesURL, err := url.JoinPath(baseURL, "v3", "roles") + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodGet, rolesURL, nil) if err != nil { return nil, err } req.Header.Set("X-Auth-Token", token) req = req.WithContext(ctx) - resp, err := p.client.Do(req) + resp, err := client.Do(req) if err != nil { - p.Logger.Error("keystone: error while fetching groups") + logger.Error("failed to fetch keystone roles", "error", err) return nil, err } @@ -623,27 +403,34 @@ func (p *conn) getAllGroups(ctx context.Context, token string) ([]keystoneGroup, } defer resp.Body.Close() - groupsResp := new(groupsResponse) + rolesResp := struct { + Roles []role `json:"roles"` + }{} - err = json.Unmarshal(data, &groupsResp) + err = json.Unmarshal(data, &rolesResp) if err != nil { return nil, err } - return groupsResp.Groups, nil + + return rolesResp.Roles, nil } -func (p *conn) getUserGroups(ctx context.Context, userID string, token string) ([]keystoneGroup, error) { - // https://developer.openstack.org/api-ref/identity/v3/#list-groups-to-which-a-user-belongs - groupsURL := p.Host + "/v3/users/" + userID + "/groups" - req, err := http.NewRequest("GET", groupsURL, nil) +// getProjects returns all projects in keystone +func getProjects(ctx context.Context, client *http.Client, baseURL, token string, logger *slog.Logger) ([]project, error) { + // https://docs.openstack.org/api-ref/identity/v3/?expanded=validate-and-show-information-for-token-detail,list-role-assignments-detail,list-roles-detail#list-roles + projectsURL, err := url.JoinPath(baseURL, "v3", "projects") + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodGet, projectsURL, nil) if err != nil { return nil, err } req.Header.Set("X-Auth-Token", token) req = req.WithContext(ctx) - resp, err := p.client.Do(req) + resp, err := client.Do(req) if err != nil { - p.Logger.Error("error while fetching user groups", "user_id", userID, "err", err) + logger.Error("failed to fetch keystone projects", "error", err) return nil, err } @@ -653,39 +440,38 @@ func (p *conn) getUserGroups(ctx context.Context, userID string, token string) ( } defer resp.Body.Close() - groupsResp := new(groupsResponse) + projectsResp := struct { + Projects []project `json:"projects"` + }{} - err = json.Unmarshal(data, &groupsResp) + err = json.Unmarshal(data, &projectsResp) if err != nil { return nil, err } - return groupsResp.Groups, nil -} -type getRoleAssignmentsOptions struct { - userID string - groupID string + return projectsResp.Projects, nil } -func (p *conn) getRoleAssignments(ctx context.Context, token string, opts getRoleAssignmentsOptions) ([]roleAssignment, error) { - endpoint := fmt.Sprintf("%s/v3/role_assignments?", p.Host) - // note: group and user filters are mutually exclusive - if len(opts.userID) > 0 { - endpoint = fmt.Sprintf("%seffective&user.id=%s", endpoint, opts.userID) - } else if len(opts.groupID) > 0 { - endpoint = fmt.Sprintf("%sgroup.id=%s", endpoint, opts.groupID) +func getUser(ctx context.Context, client *http.Client, baseURL, userID, token string) (*userResponse, error) { + // https://developer.openstack.org/api-ref/identity/v3/#show-user-details + userURL, err := url.JoinPath(baseURL, "v3", "users", userID) + if err != nil { + return nil, err } - - // https://docs.openstack.org/api-ref/identity/v3/?expanded=validate-and-show-information-for-token-detail,list-role-assignments-detail#list-role-assignments - req, err := http.NewRequest(http.MethodGet, endpoint, nil) + req, err := http.NewRequest("GET", userURL, nil) if err != nil { return nil, err } + req.Header.Set("X-Auth-Token", token) req = req.WithContext(ctx) - resp, err := p.client.Do(req) + resp, err := client.Do(req) if err != nil { - p.Logger.Error("keystone: error while fetching role assignments", "error", err) + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { return nil, err } @@ -693,86 +479,181 @@ func (p *conn) getRoleAssignments(ctx context.Context, token string, opts getRol if err != nil { return nil, err } - defer resp.Body.Close() - roleAssignmentResp := struct { - RoleAssignments []roleAssignment `json:"role_assignments"` - }{} - - err = json.Unmarshal(data, &roleAssignmentResp) + user := userResponse{} + err = json.Unmarshal(data, &user) if err != nil { return nil, err } - return roleAssignmentResp.RoleAssignments, nil + return &user, nil } -func (p *conn) getRoles(ctx context.Context, token string) ([]role, error) { - // https://docs.openstack.org/api-ref/identity/v3/?expanded=validate-and-show-information-for-token-detail,list-role-assignments-detail,list-roles-detail#list-roles - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/v3/roles", p.Host), nil) +// getAllGroupsForUser returns all groups for a user (local groups + SSO groups + role groups) +func getAllGroupsForUser(ctx context.Context, client *http.Client, baseURL, token, customerName, domainID string, tokenInfo *tokenInfo, logger *slog.Logger) ([]string, error) { + var userGroups []string //nolint:prealloc + var userGroupIDs []string //nolint:prealloc + + allGroups, err := getAllKeystoneGroups(ctx, client, baseURL, token) if err != nil { return nil, err } - req.Header.Set("X-Auth-Token", token) - req = req.WithContext(ctx) - resp, err := p.client.Do(req) + + // 1. Get SSO groups + // For SSO users, groups are passed down through the federation API. + if tokenInfo.User.OSFederation != nil { + for _, osGroup := range tokenInfo.User.OSFederation.Groups { + // If group name is empty, try to find the group by ID + if len(osGroup.Name) == 0 { + var ok bool + osGroup, ok = findGroupByID(allGroups, osGroup.ID) + if !ok { + logger.Warn("SSO group not found, skipping", "groupID", osGroup.ID, "userID", tokenInfo.User.ID) + continue + } + } + userGroups = append(userGroups, osGroup.Name) + userGroupIDs = append(userGroupIDs, osGroup.ID) + } + } + + // 2. Get local groups + // For local users, fetch the groups stored in Keystone. + localGroups, err := getUserLocalGroups(ctx, client, baseURL, tokenInfo.User.ID, token) if err != nil { - p.Logger.Error("keystone: error while fetching keystone roles", "error", err) return nil, err } - data, err := io.ReadAll(resp.Body) + for _, localGroup := range localGroups { + // If group name is empty, try to find the group by ID + if len(localGroup.Name) == 0 { + var ok bool + localGroup, ok = findGroupByID(allGroups, localGroup.ID) + if !ok { + logger.Warn("local group not found, skipping", "groupID", localGroup.ID, "userID", tokenInfo.User.ID) + continue + } + } + userGroups = append(userGroups, localGroup.Name) + userGroupIDs = append(userGroupIDs, localGroup.ID) + } + + // Get user-related role assignments + roleAssignments := []roleAssignment{} + localUserRoleAssignments, err := getRoleAssignments(ctx, client, baseURL, token, getRoleAssignmentsOptions{ + userID: tokenInfo.User.ID, + }, logger) if err != nil { - return nil, err + logger.Error("failed to fetch role assignments for user", "userID", tokenInfo.User.ID, "error", err) + return userGroups, err } - defer resp.Body.Close() + roleAssignments = append(roleAssignments, localUserRoleAssignments...) - rolesResp := struct { - Roles []role `json:"roles"` - }{} + // Get group-related role assignments + for _, groupID := range userGroupIDs { + groupRoleAssignments, err := getRoleAssignments(ctx, client, baseURL, token, getRoleAssignmentsOptions{ + groupID: groupID, + }, logger) + if err != nil { + logger.Error("failed to fetch role assignments for group", "groupID", groupID, "error", err) + return userGroups, err + } + roleAssignments = append(roleAssignments, groupRoleAssignments...) + } - err = json.Unmarshal(data, &rolesResp) + if len(roleAssignments) == 0 { + logger.Warn("no role assignments found") + return userGroups, nil + } + + roles, err := getRoles(ctx, client, baseURL, token, logger) if err != nil { - return nil, err + return userGroups, err + } + roleMap := map[string]role{} + for _, role := range roles { + roleMap[role.ID] = role } - return rolesResp.Roles, nil + projects, err := getProjects(ctx, client, baseURL, token, logger) + if err != nil { + return userGroups, err + } + projectMap := map[string]project{} + for _, project := range projects { + projectMap[project.ID] = project + } + + // 3. Now create groups based on the role assignments + roleGroups := make([]string, 0, len(roleAssignments)) + + // get the customer name to be prefixed in the group name + // if customerName is not provided in the keystone config get it from keystone host url. + if customerName == "" { + customerName, err = getHostname(baseURL) + if err != nil { + return userGroups, err + } + } + for _, roleAssignment := range roleAssignments { + role, ok := roleMap[roleAssignment.Role.ID] + if !ok { + // Ignore role assignments to non-existent roles (shouldn't happen) + continue + } + project, ok := projectMap[roleAssignment.Scope.Project.ID] + if !ok { + // Ignore role assignments to non-existent projects (shouldn't happen) + continue + } + groupName := generateGroupName(project, role, customerName, domainID) + roleGroups = append(roleGroups, groupName) + } + + // combine local groups + sso groups + role groups + userGroups = append(userGroups, roleGroups...) + return pruneDuplicates(userGroups), nil } -func (p *conn) getProjects(ctx context.Context, token string) ([]project, error) { - // https://docs.openstack.org/api-ref/identity/v3/?expanded=validate-and-show-information-for-token-detail,list-role-assignments-detail,list-roles-detail#list-roles - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/v3/projects", p.Host), nil) +func getTokenInfo(ctx context.Context, client *http.Client, baseURL, token string, logger *slog.Logger) (*tokenInfo, error) { + // https://developer.openstack.org/api-ref/identity/v3/#password-authentication-with-unscoped-authorization + authTokenURL, err := url.JoinPath(baseURL, "v3", "auth", "tokens") + if err != nil { + return nil, err + } + logger.Debug("fetching keystone token info", "url", authTokenURL) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, authTokenURL, nil) if err != nil { return nil, err } req.Header.Set("X-Auth-Token", token) - req = req.WithContext(ctx) - resp, err := p.client.Do(req) + req.Header.Set("X-Subject-Token", token) + resp, err := client.Do(req) if err != nil { - p.Logger.Error("keystone: error while fetching keystone projects", "error", err) return nil, err } - data, err := io.ReadAll(resp.Body) if err != nil { return nil, err } defer resp.Body.Close() - projectsResp := struct { - Projects []project `json:"projects"` - }{} + if resp.StatusCode >= 400 { + logger.Error("failed to get token info", "statusCode", resp.StatusCode, "response", strings.ReplaceAll(string(data), "\n", "")) + return nil, fmt.Errorf("keystone: get token info: error status code %d", resp.StatusCode) + } - err = json.Unmarshal(data, &projectsResp) + tokenResp := &tokenResponse{} + err = json.Unmarshal(data, tokenResp) if err != nil { return nil, err } - return projectsResp.Projects, nil + return &tokenResp.Token, nil } func pruneDuplicates(ss []string) []string { - set := map[string]struct{}{} + set := make(map[string]struct{}, len(ss)) ns := make([]string, 0, len(ss)) for _, s := range ss { if _, ok := set[s]; ok { @@ -784,6 +665,17 @@ func pruneDuplicates(ss []string) []string { return ns } +// generateGroupName generates a group name based on project, role, customer name, and domain ID +func generateGroupName(project project, role role, customerName, domainID string) string { + roleName := role.Name + if roleName == "_member_" { + roleName = "member" + } + domainName := strings.ToLower(strings.ReplaceAll(domainID, "_", "-")) + projectName := strings.ToLower(strings.ReplaceAll(project.Name, "_", "-")) + return customerName + "-" + domainName + "-" + projectName + "-" + roleName +} + func findGroupByID(groups []keystoneGroup, groupID string) (group keystoneGroup, ok bool) { for _, group := range groups { if group.ID == groupID { @@ -792,3 +684,18 @@ func findGroupByID(groups []keystoneGroup, groupID string) (group keystoneGroup, } return group, false } + +// getHostname returns the hostname from the base URL +func getHostname(baseURL string) (string, error) { + keystoneURL := baseURL + parsedURL, err := url.Parse(keystoneURL) + if err != nil { + return "", err + } + customerFqdn := parsedURL.Hostname() + // get customer name and not the full fqdn + parts := strings.Split(customerFqdn, ".") + hostName := parts[0] + + return hostName, nil +} diff --git a/connector/keystone/types.go b/connector/keystone/types.go new file mode 100644 index 0000000000..30855415d9 --- /dev/null +++ b/connector/keystone/types.go @@ -0,0 +1,158 @@ +package keystone + +// Config holds the configuration parameters for Keystone connector. +// Keystone should expose API v3 +type Config struct { + Domain string `json:"domain"` + Host string `json:"keystoneHost"` + AdminUsername string `json:"keystoneUsername"` + AdminPassword string `json:"keystonePassword"` + InsecureSkipVerify bool `json:"insecureSkipVerify"` + CustomerName string `json:"customerName"` +} + +// FederationConfig holds the configuration parameters for Keystone federation connector. +// This connector supports SSO authentication via Shibboleth and SAML. +type FederationConfig struct { + // Domain is domain ID, typically "default" + Domain string `json:"domain"` + // Host is Keystone host URL, e.g. https://keystone.pf9.com:5000 + Host string `json:"keystoneHost"` + // AdminUsername is Keystone admin username + AdminUsername string `json:"keystoneUsername"` + // AdminPassword is Keystone admin password + AdminPassword string `json:"keystonePassword"` + // CustomerName is customer name to be used in group names + CustomerName string `json:"customerName"` + // ShibbolethLoginPath is Shibboleth SSO login endpoint path, typically '/sso/{IdP}/Shibboleth.sso/Login' + ShibbolethLoginPath string `json:"shibbolethLoginPath,omitempty"` + // FederationAuthPath is OS-FEDERATION identity providers auth path, typically '/keystone/v3/OS-FEDERATION/identity_providers/{IdP}/protocols/saml2/auth' + FederationAuthPath string `json:"federationAuthPath,omitempty"` + // TimeoutSeconds is the timeout for HTTP requests in seconds + TimeoutSeconds int `json:"timeoutSeconds,omitempty"` +} + +// userKeystone represents a Keystone user +type userKeystone struct { + Domain domainKeystone `json:"domain"` + ID string `json:"id"` + Name string `json:"name"` + OSFederation *struct { + Groups []keystoneGroup `json:"groups"` + IdentityProvider struct { + ID string `json:"id"` + } `json:"identity_provider"` + Protocol struct { + ID string `json:"id"` + } `json:"protocol"` + } `json:"OS-FEDERATION"` +} + +// domainKeystone represents a Keystone domain +type domainKeystone struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` +} + +// loginRequestData represents a login request with unscoped authorization +type loginRequestData struct { + auth `json:"auth"` +} + +// auth represents the authentication part of a login request +type auth struct { + Identity identity `json:"identity"` +} + +// identity represents the identity part of an authentication request +type identity struct { + Methods []string `json:"methods"` + Password password `json:"password"` +} + +// password represents the password authentication method +type password struct { + User user `json:"user"` +} + +// user represents a user for authentication +type user struct { + Name string `json:"name"` + Domain domainKeystone `json:"domain"` + Password string `json:"password"` +} + +// tokenInfo represents information about a token +type tokenInfo struct { + User userKeystone `json:"user"` + Roles []role `json:"roles"` +} + +// tokenResponse represents a response containing a token +type tokenResponse struct { + Token tokenInfo `json:"token"` +} + +// keystoneGroup represents a Keystone group +type keystoneGroup struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// groupsResponse represents a response containing groups +type groupsResponse struct { + Groups []keystoneGroup `json:"groups"` +} + +// userResponse represents a response containing user information +type userResponse struct { + User struct { + Name string `json:"name"` + Email string `json:"email"` + ID string `json:"id"` + } `json:"user"` +} + +// role represents a Keystone role +type role struct { + ID string `json:"id"` + Name string `json:"name"` + DomainID string `json:"domain_id"` + Description string `json:"description"` +} + +// project represents a Keystone project +type project struct { + ID string `json:"id"` + Name string `json:"name"` + DomainID string `json:"domain_id"` + Description string `json:"description"` +} + +// identifierContainer represents an object with an ID +type identifierContainer struct { + ID string `json:"id"` +} + +// projectScope represents a project scope for authorization +type projectScope struct { + Project identifierContainer `json:"project"` +} + +// roleAssignment represents a role assignment +type roleAssignment struct { + Scope projectScope `json:"scope"` + User identifierContainer `json:"user"` + Role identifierContainer `json:"role"` +} + +// connectorData represents data stored with the connector +type connectorData struct { + Token string `json:"token"` +} + +// getRoleAssignmentsOptions represents options for getting role assignments +type getRoleAssignmentsOptions struct { + userID string + groupID string +} diff --git a/server/server.go b/server/server.go index 38d6f4a35a..67207bb364 100644 --- a/server/server.go +++ b/server/server.go @@ -668,6 +668,7 @@ type ConnectorConfig interface { // depending on the connector type. var ConnectorsConfig = map[string]func() ConnectorConfig{ "keystone": func() ConnectorConfig { return new(keystone.Config) }, + "keystonefed": func() ConnectorConfig { return new(keystone.FederationConfig) }, "mockCallback": func() ConnectorConfig { return new(mock.CallbackConfig) }, "mockPassword": func() ConnectorConfig { return new(mock.PasswordConfig) }, "ldap": func() ConnectorConfig { return new(ldap.Config) },