-
Notifications
You must be signed in to change notification settings - Fork 49
Added scim client #537
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Added scim client #537
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,289 @@ | ||
| package scim | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "crypto/tls" | ||
| "crypto/x509" | ||
| "encoding/json" | ||
| "fmt" | ||
| "io" | ||
| "net/http" | ||
| "net/url" | ||
| "time" | ||
|
|
||
| "github.com/rancher/shepherd/pkg/clientbase" | ||
| ) | ||
|
|
||
| const ( | ||
| SCIMSchemaUser = "urn:ietf:params:scim:schemas:core:2.0:User" | ||
| SCIMSchemaGroup = "urn:ietf:params:scim:schemas:core:2.0:Group" | ||
| SCIMSchemaPatchOp = "urn:ietf:params:scim:api:messages:2.0:PatchOp" | ||
| ) | ||
|
|
||
| type User struct { | ||
| Schemas []string `json:"schemas"` | ||
| UserName string `json:"userName"` | ||
| ExternalID string `json:"externalId,omitempty"` | ||
| Active *bool `json:"active,omitempty"` | ||
| } | ||
|
|
||
| type Group struct { | ||
| Schemas []string `json:"schemas"` | ||
| ID string `json:"id,omitempty"` | ||
| DisplayName string `json:"displayName"` | ||
| ExternalID string `json:"externalId,omitempty"` | ||
| Members []Member `json:"members,omitempty"` | ||
| } | ||
|
|
||
| type Member struct { | ||
| Value string `json:"value"` | ||
| } | ||
|
|
||
| type PatchOp struct { | ||
| Schemas []string `json:"schemas"` | ||
| Operations []Operation `json:"Operations"` | ||
| } | ||
|
|
||
| type Operation struct { | ||
| Op string `json:"op"` | ||
| Path string `json:"path,omitempty"` | ||
| Value interface{} `json:"value,omitempty"` | ||
| } | ||
|
|
||
| type Response struct { | ||
| StatusCode int | ||
| Body []byte | ||
| Header http.Header | ||
| } | ||
|
|
||
| func (r *Response) DecodeJSON(target interface{}) error { | ||
| return json.Unmarshal(r.Body, target) | ||
| } | ||
|
|
||
| func (r *Response) IDFromBody() (string, error) { | ||
| var m map[string]interface{} | ||
| if err := json.Unmarshal(r.Body, &m); err != nil { | ||
| return "", err | ||
| } | ||
| id, ok := m["id"].(string) | ||
| if !ok || id == "" { | ||
| return "", fmt.Errorf("id not found in response: %s", string(r.Body)) | ||
| } | ||
| return id, nil | ||
| } | ||
|
|
||
| func BoolPtr(b bool) *bool { return &b } | ||
|
|
||
| type scimTransport struct { | ||
| baseURL string | ||
| token string | ||
| httpClient *http.Client | ||
| } | ||
|
|
||
| func (t *scimTransport) do(method, resource, id string, query url.Values, body interface{}) (*Response, error) { | ||
| rawURL := fmt.Sprintf("%s/%s", t.baseURL, resource) | ||
| if id != "" { | ||
| rawURL += "/" + id | ||
| } | ||
| if len(query) > 0 { | ||
| rawURL += "?" + query.Encode() | ||
| } | ||
|
|
||
| var bodyReader io.Reader | ||
| if body != nil { | ||
| b, err := json.Marshal(body) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| bodyReader = bytes.NewReader(b) | ||
| } | ||
|
|
||
| req, err := http.NewRequest(method, rawURL, bodyReader) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| req.Header.Set("Authorization", "Bearer "+t.token) | ||
| req.Header.Set("Content-Type", "application/scim+json") | ||
| req.Header.Set("Accept", "application/scim+json") | ||
|
|
||
| resp, err := t.httpClient.Do(req) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| defer resp.Body.Close() | ||
|
|
||
| respBody, err := io.ReadAll(resp.Body) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| return &Response{StatusCode: resp.StatusCode, Body: respBody, Header: resp.Header}, nil | ||
| } | ||
|
|
||
| // Users is the interface for SCIM User operations. | ||
| type Users interface { | ||
| List(query url.Values) (*Response, error) | ||
| Create(user User) (*Response, error) | ||
| ByID(id string) (*Response, error) | ||
| Update(id string, user User) (*Response, error) | ||
| Patch(id string, patch PatchOp) (*Response, error) | ||
| Delete(id string) (*Response, error) | ||
| } | ||
|
|
||
| // Groups is the interface for SCIM Group operations. | ||
| type Groups interface { | ||
| List(query url.Values) (*Response, error) | ||
| Create(group Group) (*Response, error) | ||
| ByID(id string) (*Response, error) | ||
| ByIDWithQuery(id string, query url.Values) (*Response, error) | ||
| Update(id string, group Group) (*Response, error) | ||
| Patch(id string, patch PatchOp) (*Response, error) | ||
| Delete(id string) (*Response, error) | ||
| } | ||
|
|
||
| // Discovery is the interface for SCIM discovery operations. | ||
| type Discovery interface { | ||
| ServiceProviderConfig() (*Response, error) | ||
| ResourceTypes() (*Response, error) | ||
| ResourceTypeByID(id string) (*Response, error) | ||
| Schemas() (*Response, error) | ||
| SchemaByID(id string) (*Response, error) | ||
| } | ||
|
|
||
| type Client struct { | ||
| t *scimTransport | ||
| } | ||
|
|
||
| // Build the SCIM base URL by replacing any existing path on opts.URL. | ||
| // opts.URL may carry an API prefix (e.g. "/v3") so we parse and reset | ||
| // the path rather than appending to avoid paths like "/v3/v1-scim/...". | ||
|
|
||
| func NewClient(opts *clientbase.ClientOpts, provider string) *Client { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also here when you instantiate the client how do you pass in the opts? How does it get the bearer token? Why isn't this added to the parent/umbrella rancher client?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The token is passed via opts.TokenKey — same as every other shepherd client. Harvester does exactly this: clientOptsV1 sets TokenKey: restConfig.BearerToken. The reason SCIM is not on the parent rancher.Client is that SCIM uses a separate per-provider bearer token stored in a Kubernetes secret in cattle-global-data — not the Rancher admin token. The parent client is constructed with the admin token. If SCIM were added there, either the admin token would be used for SCIM (which returns 401 ), or the parent client constructor would need a second token parameter which breaks the existing interface for all callers. |
||
| baseURL := fmt.Sprintf("%s/v1-scim/%s", opts.URL, provider) | ||
| if u, err := url.Parse(opts.URL); err == nil { | ||
| u.Path = fmt.Sprintf("/v1-scim/%s", provider) | ||
| u.RawQuery = "" | ||
| u.Fragment = "" | ||
| baseURL = u.String() | ||
| } | ||
|
|
||
| httpClient := opts.HTTPClient | ||
| if httpClient == nil { | ||
| tr := &http.Transport{ | ||
| Proxy: http.ProxyFromEnvironment, | ||
| } | ||
| if opts.Insecure { | ||
| tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} | ||
| } else if opts.CACerts != "" { | ||
| roots := x509.NewCertPool() | ||
| roots.AppendCertsFromPEM([]byte(opts.CACerts)) | ||
| tr.TLSClientConfig = &tls.Config{RootCAs: roots} | ||
| } | ||
| timeout := opts.Timeout | ||
| if timeout == 0 { | ||
| timeout = time.Minute | ||
| } | ||
| httpClient = &http.Client{Transport: tr, Timeout: timeout} | ||
| } | ||
|
|
||
| return &Client{t: &scimTransport{ | ||
| baseURL: baseURL, | ||
| token: opts.TokenKey, | ||
| httpClient: httpClient, | ||
| }} | ||
dasarinaidu marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| func (c *Client) Users() Users { | ||
| return &userClient{t: c.t} | ||
| } | ||
|
|
||
| func (c *Client) Groups() Groups { | ||
| return &groupClient{t: c.t} | ||
| } | ||
|
|
||
| func (c *Client) Discovery() Discovery { | ||
| return &discoveryClient{t: c.t} | ||
| } | ||
|
|
||
| type userClient struct { | ||
| t *scimTransport | ||
| } | ||
|
|
||
| func (c *userClient) List(query url.Values) (*Response, error) { | ||
| return c.t.do(http.MethodGet, "Users", "", query, nil) | ||
| } | ||
|
|
||
| func (c *userClient) Create(user User) (*Response, error) { | ||
| return c.t.do(http.MethodPost, "Users", "", nil, user) | ||
| } | ||
|
|
||
| func (c *userClient) ByID(id string) (*Response, error) { | ||
| return c.t.do(http.MethodGet, "Users", id, nil, nil) | ||
| } | ||
|
|
||
| func (c *userClient) Update(id string, user User) (*Response, error) { | ||
| return c.t.do(http.MethodPut, "Users", id, nil, user) | ||
| } | ||
|
|
||
| func (c *userClient) Patch(id string, patch PatchOp) (*Response, error) { | ||
| return c.t.do(http.MethodPatch, "Users", id, nil, patch) | ||
| } | ||
|
|
||
| func (c *userClient) Delete(id string) (*Response, error) { | ||
| return c.t.do(http.MethodDelete, "Users", id, nil, nil) | ||
| } | ||
|
|
||
| type groupClient struct { | ||
| t *scimTransport | ||
| } | ||
|
|
||
| func (c *groupClient) List(query url.Values) (*Response, error) { | ||
| return c.t.do(http.MethodGet, "Groups", "", query, nil) | ||
| } | ||
|
|
||
| func (c *groupClient) Create(group Group) (*Response, error) { | ||
| return c.t.do(http.MethodPost, "Groups", "", nil, group) | ||
| } | ||
|
|
||
| func (c *groupClient) ByID(id string) (*Response, error) { | ||
| return c.t.do(http.MethodGet, "Groups", id, nil, nil) | ||
| } | ||
|
|
||
| func (c *groupClient) ByIDWithQuery(id string, query url.Values) (*Response, error) { | ||
| return c.t.do(http.MethodGet, "Groups", id, query, nil) | ||
| } | ||
|
|
||
| func (c *groupClient) Update(id string, group Group) (*Response, error) { | ||
| return c.t.do(http.MethodPut, "Groups", id, nil, group) | ||
| } | ||
|
|
||
| func (c *groupClient) Patch(id string, patch PatchOp) (*Response, error) { | ||
| return c.t.do(http.MethodPatch, "Groups", id, nil, patch) | ||
| } | ||
|
|
||
| func (c *groupClient) Delete(id string) (*Response, error) { | ||
| return c.t.do(http.MethodDelete, "Groups", id, nil, nil) | ||
| } | ||
|
|
||
| type discoveryClient struct { | ||
| t *scimTransport | ||
| } | ||
|
|
||
| func (c *discoveryClient) ServiceProviderConfig() (*Response, error) { | ||
| return c.t.do(http.MethodGet, "ServiceProviderConfig", "", nil, nil) | ||
| } | ||
|
|
||
| func (c *discoveryClient) ResourceTypes() (*Response, error) { | ||
| return c.t.do(http.MethodGet, "ResourceTypes", "", nil, nil) | ||
| } | ||
|
|
||
| func (c *discoveryClient) ResourceTypeByID(id string) (*Response, error) { | ||
| return c.t.do(http.MethodGet, "ResourceTypes", id, nil, nil) | ||
| } | ||
|
|
||
| func (c *discoveryClient) Schemas() (*Response, error) { | ||
| return c.t.do(http.MethodGet, "Schemas", "", nil, nil) | ||
| } | ||
|
|
||
| func (c *discoveryClient) SchemaByID(id string) (*Response, error) { | ||
| return c.t.do(http.MethodGet, "Schemas", id, nil, nil) | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do we need this ? Can we do similar to what we have here: https://github.com/rancher/shepherd/blob/main/clients/rancher/v1/client.go#L100 to create a SCIM client ? It looks like a lot of functionality in this file may already be covered and reimplementing what clientbase.NewAPIClient already does.
Something like:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hi Priya, thanks for the suggestion — I tried exactly this approach first. Unfortunately v1.NewClient cannot be used for SCIM due to a hard constraint in clientbase.NewAPIClient.
Specifically, newAPIClientInternal (common.go L~95) makes a GET to opts.URL during construction and requires an X-API-Schemas response header to bootstrap its schema registry. The SCIM endpoint (/v1-scim/) returns no such header — it's not a Norman/Steve API. So v1.NewClient(opts) always returns:
Failed to find schema at [https:///v1-scim/openldap]
This means the client construction itself fails before any SCIM call is made.
The second constraint is Ops.DoModify returns an error for any >= 300 status (ops.go). Our tests deliberately assert on 409, 401, and 404 as expected responses — TestSCIMInvalidTokenReturns401, TestSCIMCreateDuplicateUserReturns409 etc. Using DoModify would turn those into errors and break all negative-path tests.
The scimTransport.do() exists solely because of these two constraints — it's a thin HTTP wrapper (30 lines) that bypasses the schema layer while still reusing ClientOpts for TLS, timeout, CACerts and proxy config (addressed in the latest commit). Happy to discuss further if helpful.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This do method does not belong here. It should apart of an extension of clientbase. Let's maintain the patterns we have in place.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The reason do() is here rather than in clientbase is the same schema bootstrap constraint above. Additionally clientbase.Ops.DoModify returns an error for any >= 300 HTTP status. Our SCIM tests deliberately assert on 409, 401 and 404 as expected values — TestSCIMCreateDuplicateUserReturns409, TestSCIMInvalidTokenReturns401 etc. Using DoModify would convert those expected responses into errors and break all negative-path tests. do() is 30 lines and exists specifically because of these two constraints.