Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@ require (
github.com/oapi-codegen/runtime v1.1.2
github.com/openkcm/api-sdk v0.15.0
github.com/openkcm/common-sdk v1.11.0
github.com/openkcm/identity-management-plugins v0.1.0
github.com/openkcm/orbital v0.4.0
github.com/openkcm/plugin-sdk v0.9.1
github.com/pkg/errors v0.9.1
github.com/pressly/goose/v3 v3.26.0
github.com/prometheus/client_golang v1.23.2
github.com/samber/oops v1.21.0
Expand Down Expand Up @@ -140,7 +142,6 @@ require (
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,8 @@ github.com/openkcm/api-sdk v0.15.0 h1:JfA7nEy4JnoxLafFIEerCpjmmhkJfNeEncSVB1VHn6
github.com/openkcm/api-sdk v0.15.0/go.mod h1:oYv9C+bUTcNHHhVN91nRoTveO9XSkd/EO1mFCM+a0fA=
github.com/openkcm/common-sdk v1.11.0 h1:C4mkKV8wI2ucYL9+CDGASODY+JBkngTGVwep2yyPU8M=
github.com/openkcm/common-sdk v1.11.0/go.mod h1:5QUCIBsjOkbwRxq9uqC0DnDhlHSX4OfVLm3s0s4UlO0=
github.com/openkcm/identity-management-plugins v0.1.0 h1:QiH5or2ENLq69S9PuRdNJq7hM0KqUdWrI/o7hCwEyuo=
github.com/openkcm/identity-management-plugins v0.1.0/go.mod h1:i1dJISA/rEtsXKCJhUgwMSxQZ/8z/wiu/9+Rs2Y3+a4=
github.com/openkcm/orbital v0.4.0 h1:3uzaJ1qZMkaaLvQdvAsBEDeFQ3zXGvkoNKrCH06nDpA=
github.com/openkcm/orbital v0.4.0/go.mod h1:BbCHxg/qS1zI5L1VtHdG0RUURPufbdLiFubEA1zFXA0=
github.com/openkcm/plugin-sdk v0.9.1 h1:lbQNu2/YK/5jMpXVCAaLB4ba0pE2QzYKRJHlbX31NB0=
Expand Down
6 changes: 5 additions & 1 deletion internal/grpc/catalog/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,22 @@ import (
slogctx "github.com/veqryn/slog-context"

"github.com/openkcm/cmk/internal/config"
"github.com/openkcm/cmk/internal/plugins"
)

// New creates a new instance of Catalog with the provided configuration.
func New(ctx context.Context, cfg *config.Config) (*plugincatalog.Catalog, error) {
buildInPlugins := plugincatalog.DefaultBuiltInPluginRegistry()
plugins.RegisterAllBuiltInPlugins(buildInPlugins)

catalogLogger := slog.With("context", "plugin-catalog")
catalogConfig := plugincatalog.Config{
Logger: catalogLogger,
PluginConfigs: cfg.Plugins,
HostServices: []api.ServiceServer{},
}

catalog, err := plugincatalog.Load(ctx, catalogConfig)
catalog, err := plugincatalog.Load(ctx, catalogConfig, buildInPlugins.Get()...)
if err != nil {
catalogLogger.ErrorContext(ctx, "Error loading plugins", "error", err)
return nil, fmt.Errorf("error loading plugins: %w", err)
Expand Down
11 changes: 11 additions & 0 deletions internal/plugins/builtin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package plugins

import (
"github.com/openkcm/plugin-sdk/pkg/catalog"

"github.com/openkcm/cmk/internal/plugins/identity-management/scim"
)

func RegisterAllBuiltInPlugins(registry catalog.BuiltInPluginRegistry) {
scim.Register(registry)
}
299 changes: 299 additions & 0 deletions internal/plugins/identity-management/scim/client/connect.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,299 @@
package client

import (
"context"
"encoding/base64"
"errors"
"fmt"
"io"
"log/slog"
"net/http"

"github.com/openkcm/common-sdk/pkg/commoncfg"
"github.com/openkcm/common-sdk/pkg/pointers"

errors2 "github.com/pkg/errors"

"github.com/openkcm/cmk/internal/errs"
)

const (
ApplicationSCIMJson = "application/scim+json"

SearchRequestSchema = "urn:ietf:params:scim:api:messages:2.0:SearchRequest"

BasePathGroups = "/Groups"
BasePathUsers = "/Users"
PostSearchPath = ".search"

HeaderAuthorization = "Authorization"
)

var (
ErrAuthNotImplemented = errors.New("API Auth not implemented")
ErrGetUser = errors.New("error getting SCIM user")
ErrListUsers = errors.New("error listing SCIM users")
ErrGetGroup = errors.New("error getting SCIM group")
ErrListGroups = errors.New("error listing SCIM groups")
ErrClientID = errors.New("failed to load the client id")
ErrClientSecret = errors.New("failed to load the client secret")
)

type RequestParams struct {
Host string
Method string
Filter FilterExpression
Cursor *string
Count *int
Headers map[string]string
}

type Client struct {
logger *slog.Logger
httpClient *http.Client

basicAuth *basicAuth
}
type basicAuth struct {
clientID string
clientSecret string
}

func NewClient(authRef commoncfg.SecretRef, logger *slog.Logger) (*Client, error) {
switch authRef.Type {
case commoncfg.BasicSecretType:
clientId, err := commoncfg.LoadValueFromSourceRef(authRef.Basic.Username)
if err != nil {
return nil, ErrClientID
}

clientSecret, err := commoncfg.LoadValueFromSourceRef(authRef.Basic.Password)
if err != nil {
return nil, ErrClientSecret
}

return &Client{
logger: logger,
httpClient: &http.Client{},
basicAuth: &basicAuth{
clientID: string(clientId),
clientSecret: string(clientSecret),
},
}, nil
case commoncfg.MTLSSecretType:
mtls, err := commoncfg.LoadMTLSConfig(&authRef.MTLS)
if err != nil {
return nil, fmt.Errorf("failed to parse client certificate x509 pair: %w", err)
}

return &Client{
logger: logger,
httpClient: &http.Client{
Transport: &http.Transport{
TLSClientConfig: mtls,
},
},
}, nil
default:
return nil, ErrAuthNotImplemented
}
}

// GetUser retrieves a SCIM user by its ID.
func (c *Client) GetUser(ctx context.Context, id string, params RequestParams) (*User, error) {
resp, err := c.baseCreateAndExecuteHTTPRequest(
ctx, params.Host, http.MethodGet, BasePathUsers+"/"+id, nil, nil, params.Headers,
)

if resp != nil {
defer func() {
err := resp.Body.Close()
if err != nil {
c.logger.Error("failed to close GetUser response body", "error", err)
}
}()
}

if err != nil {
return nil, errs.Wrap(ErrGetUser, err)
}

user, err := DecodeResponse[User](ctx, "SCIM", resp, http.StatusOK)
if err != nil {
return nil, errs.Wrap(ErrGetUser, err)
}

return user, nil
}

// ListUsers retrieves a list of SCIM users.
// It supports filtering, pagination (using cursor), and count parameters.
// The useHTTPPost parameter determines whether to use POST method + /.search path for the request.
func (c *Client) ListUsers(ctx context.Context, params RequestParams) (*UserList, error) {
resp, err := c.createAndExecuteHTTPRequest(ctx, params, BasePathUsers)
if err != nil {
return nil, errs.Wrap(ErrListUsers, err)
}

defer func() {
err := resp.Body.Close()
if err != nil {
c.logger.Error("failed to close ListUsers response body", "error", err)
}
}()

users, err := DecodeResponse[UserList](ctx, "SCIM", resp, http.StatusOK)
if err != nil {
return nil, errs.Wrap(ErrListUsers, err)
}

return users, nil
}

// GetGroup retrieves a SCIM group by its ID.
func (c *Client) GetGroup(
ctx context.Context,
id string,
groupMemberAttribute string,
params RequestParams,
) (*Group, error) {
var queryString *string

if groupMemberAttribute != "" {
queryString = pointers.String("attributes=" + groupMemberAttribute)
}

resp, err := c.baseCreateAndExecuteHTTPRequest(
ctx, params.Host, http.MethodGet, BasePathGroups+"/"+id, queryString, nil, params.Headers,
)

if resp != nil {
defer func() {
err := resp.Body.Close()
if err != nil {
c.logger.Error("failed to close GetGroup response body", "error", err)
}
}()
}

if err != nil {
return nil, errors2.Wrap(err, ErrGetGroup.Error())
}

group, err := DecodeResponse[Group](ctx, "SCIM", resp, http.StatusOK)
if err != nil {
return nil, errs.Wrap(ErrGetGroup, err)
}

return group, nil
}

// ListGroups retrieves a list of SCIM groups.
// It supports filtering, pagination (using cursor), and count parameters.
// The useHTTPPost parameter determines whether to use POST method + /.search path for the request.
func (c *Client) ListGroups(
ctx context.Context,
params RequestParams,
) (*GroupList, error) {
resp, err := c.createAndExecuteHTTPRequest(ctx, params, BasePathGroups)

if resp != nil {
defer func() {
err := resp.Body.Close()
if err != nil {
c.logger.Error("failed to close ListGroups response body", "error", err)
}
}()
}

if err != nil {
return nil, errs.Wrap(ErrListGroups, err)
}

groups, err := DecodeResponse[GroupList](ctx, "SCIM", resp, http.StatusOK)
if err != nil {
return nil, errs.Wrap(ErrListGroups, err)
}

return groups, nil
}

func (c *Client) doRequest(req *http.Request) (*http.Response, error) {
if req.Method == http.MethodPost || req.Method == http.MethodPut || req.Method == http.MethodPatch {
req.Header.Set("Content-Type", ApplicationSCIMJson)
}

req.Header.Set("Accept", ApplicationSCIMJson)

if c.basicAuth != nil {
basicCreds := []byte(c.basicAuth.clientID + ":" + c.basicAuth.clientSecret)
req.Header.Set(HeaderAuthorization, "Basic "+base64.RawStdEncoding.EncodeToString(basicCreds))
}

return c.httpClient.Do(req)
}

func (c *Client) baseCreateAndExecuteHTTPRequest(
ctx context.Context,
host string,
method string,
resourcePath string,
queryString *string,
body io.Reader,
headers map[string]string,
) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, host+resourcePath, body)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}

if queryString != nil {
req.URL.RawQuery = *queryString
}

for key, value := range headers {
req.Header.Set(key, value)
}

resp, err := c.doRequest(req)
if err != nil {
return nil, fmt.Errorf("failed to make request: %w", err)
}

return resp, nil
}

// createAndExecuteHTTPRequest create a request to list SCIM resources (users or groups).
// It uses either GET or POST method based on the useHTTPPost parameter.
// It builds the request with the provided filter, cursor, and count parameters.
// For GET method, parameters are added to the query string.
// For POST method, parameters are included in the request body.
func (c *Client) createAndExecuteHTTPRequest(
ctx context.Context,
params RequestParams,
basePath string,
) (*http.Response, error) {
resourcePath := basePath + "/"

var (
body io.Reader
queryString string
)

if params.Method == http.MethodPost || params.Method == http.MethodPut || params.Method == http.MethodPatch {
resourcePath += PostSearchPath

var err error

body, err = buildBodyFromParams(params.Filter, params.Count, params.Cursor)
if err != nil {
return nil, fmt.Errorf("failed to build request: %w", err)
}
} else {
queryString = buildQueryStringFromParams(params.Filter, params.Cursor, params.Count)
}

return c.baseCreateAndExecuteHTTPRequest(
ctx, params.Host, params.Method, resourcePath, pointers.String(queryString), body, params.Headers,
)
}
Loading
Loading