The SCIM event handler read the client address from r.RemoteAddr only, so when probod runs behind an HTTP load balancer every event row recorded the load balancer's internal IP. Switch to clientip.Extract, which honors the Forwarded / X-Forwarded-For headers already validated by trustedproxy middleware. Signed-off-by: Bryan Frimin <bryan@getprobo.com>
347 lines
9.7 KiB
Go
347 lines
9.7 KiB
Go
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
|
//
|
|
// Permission to use, copy, modify, and/or distribute this software for any
|
|
// purpose with or without fee is hereby granted, provided that the above
|
|
// copyright notice and this permission notice appear in all copies.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
// PERFORMANCE OF THIS SOFTWARE.
|
|
|
|
package connect_v1
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net"
|
|
"net/http"
|
|
|
|
"github.com/elimity-com/scim"
|
|
scimerrors "github.com/elimity-com/scim/errors"
|
|
"github.com/elimity-com/scim/optional"
|
|
"github.com/elimity-com/scim/schema"
|
|
scimfilter "github.com/scim2/filter-parser/v2"
|
|
"go.gearno.de/kit/httpserver"
|
|
"go.gearno.de/kit/log"
|
|
"go.probo.inc/probo/pkg/bearertoken"
|
|
"go.probo.inc/probo/pkg/coredata"
|
|
"go.probo.inc/probo/pkg/gid"
|
|
"go.probo.inc/probo/pkg/iam"
|
|
scimservice "go.probo.inc/probo/pkg/iam/scim"
|
|
"go.probo.inc/probo/pkg/server/api/clientip"
|
|
)
|
|
|
|
type (
|
|
ctxKey struct{ name string }
|
|
|
|
SCIMHandler struct {
|
|
iam *iam.Service
|
|
logger *log.Logger
|
|
}
|
|
|
|
scimResourceHandler struct {
|
|
handler *SCIMHandler
|
|
}
|
|
|
|
scimRequestContext struct {
|
|
ctx context.Context
|
|
config *coredata.SCIMConfiguration
|
|
ipAddress net.IP
|
|
method string
|
|
path string
|
|
userName string
|
|
handler *scimResourceHandler
|
|
}
|
|
)
|
|
|
|
var (
|
|
scimConfigCtxKey = &ctxKey{name: "scim_config"}
|
|
)
|
|
|
|
func NewSCIMHandler(iam *iam.Service, logger *log.Logger) *SCIMHandler {
|
|
return &SCIMHandler{iam: iam, logger: logger}
|
|
}
|
|
|
|
func scimConfigFromContext(ctx context.Context) *coredata.SCIMConfiguration {
|
|
config, _ := ctx.Value(scimConfigCtxKey).(*coredata.SCIMConfiguration)
|
|
return config
|
|
}
|
|
|
|
// NewSCIMServer creates a new SCIM server using elimity-com/scim
|
|
func NewSCIMServer(h *SCIMHandler) http.Handler {
|
|
schema.SetAllowStringValues(true)
|
|
|
|
resourceTypes := []scim.ResourceType{
|
|
{
|
|
ID: optional.NewString("User"),
|
|
Name: "User",
|
|
Endpoint: "/Users",
|
|
Description: optional.NewString("User Account"),
|
|
Schema: scimservice.UserSchema(),
|
|
SchemaExtensions: []scim.SchemaExtension{
|
|
{Schema: scimservice.EnterpriseUserSchema()},
|
|
},
|
|
Handler: &scimResourceHandler{handler: h},
|
|
},
|
|
}
|
|
|
|
serverConfig := scim.ServiceProviderConfig{
|
|
SupportFiltering: true,
|
|
SupportPatch: true,
|
|
AuthenticationSchemes: []scim.AuthenticationScheme{
|
|
{
|
|
Type: scim.AuthenticationTypeOauthBearerToken,
|
|
Name: "OAuth Bearer Token",
|
|
Description: "Authentication using OAuth Bearer Token",
|
|
},
|
|
},
|
|
}
|
|
|
|
server, err := scim.NewServer(
|
|
&scim.ServerArgs{
|
|
ServiceProviderConfig: &serverConfig,
|
|
ResourceTypes: resourceTypes,
|
|
},
|
|
)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
return server
|
|
}
|
|
|
|
// BearerTokenMiddleware validates the bearer token and sets the SCIM configuration in context
|
|
func (h *SCIMHandler) BearerTokenMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
authHeader := r.Header.Get("Authorization")
|
|
if authHeader == "" {
|
|
httpserver.RenderError(w, http.StatusUnauthorized, errors.New("authorization header required"))
|
|
return
|
|
}
|
|
|
|
token, err := bearertoken.Parse(authHeader)
|
|
if err != nil {
|
|
httpserver.RenderError(w, http.StatusUnauthorized, errors.New("invalid authorization header"))
|
|
return
|
|
}
|
|
|
|
config, err := h.iam.SCIMService.ValidateToken(r.Context(), token)
|
|
if err != nil {
|
|
var invalidToken *scimservice.ErrSCIMInvalidToken
|
|
if errors.As(err, &invalidToken) {
|
|
httpserver.RenderError(w, http.StatusUnauthorized, errors.New("invalid token"))
|
|
return
|
|
}
|
|
|
|
h.logger.ErrorCtx(r.Context(), "SCIM token validation error", log.Error(err))
|
|
httpserver.RenderError(w, http.StatusInternalServerError, errors.New("internal server error"))
|
|
return
|
|
}
|
|
|
|
ctx := context.WithValue(r.Context(), scimConfigCtxKey, config)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
|
|
func (rc *scimRequestContext) logAndWrapError(err error, logMsg string) error {
|
|
var scimErr scimerrors.ScimError
|
|
if errors.As(err, &scimErr) {
|
|
errMsg := scimErr.Detail
|
|
|
|
// Don't reference profileID for 404 errors - the resource doesn't exist
|
|
userName := rc.userName
|
|
if scimErr.Status == http.StatusNotFound {
|
|
userName = ""
|
|
}
|
|
rc.handler.handler.iam.SCIMService.LogEvent(rc.ctx, rc.config, rc.method, rc.path, userName, rc.ipAddress, scimErr.Status, &errMsg)
|
|
return err
|
|
}
|
|
|
|
rc.handler.handler.logger.ErrorCtx(rc.ctx, logMsg, log.Error(err))
|
|
errMsg := "internal server error"
|
|
rc.handler.handler.iam.SCIMService.LogEvent(rc.ctx, rc.config, rc.method, rc.path, rc.userName, rc.ipAddress, 500, &errMsg)
|
|
return scimerrors.ScimErrorInternal
|
|
}
|
|
|
|
func (rc *scimRequestContext) logSuccess(statusCode int) {
|
|
rc.handler.handler.iam.SCIMService.LogEvent(rc.ctx, rc.config, rc.method, rc.path, rc.userName, rc.ipAddress, statusCode, nil)
|
|
}
|
|
|
|
func (h *scimResourceHandler) Create(r *http.Request, attributes scim.ResourceAttributes) (scim.Resource, error) {
|
|
rc := &scimRequestContext{
|
|
ctx: r.Context(),
|
|
config: scimConfigFromContext(r.Context()),
|
|
ipAddress: getIPAddress(r),
|
|
method: "POST",
|
|
path: "/Users",
|
|
handler: h,
|
|
}
|
|
|
|
resource, err := h.handler.iam.SCIMService.CreateUser(rc.ctx, rc.config, attributes)
|
|
if err != nil {
|
|
return scim.Resource{}, rc.logAndWrapError(err, "cannot create user")
|
|
}
|
|
|
|
rc.userName = resource.Attributes["userName"].(string)
|
|
|
|
rc.logSuccess(201)
|
|
|
|
return resource, nil
|
|
}
|
|
|
|
func (h *scimResourceHandler) Get(r *http.Request, id string) (scim.Resource, error) {
|
|
rc := &scimRequestContext{
|
|
ctx: r.Context(),
|
|
config: scimConfigFromContext(r.Context()),
|
|
ipAddress: getIPAddress(r),
|
|
method: "GET",
|
|
path: "/Users/" + id,
|
|
handler: h,
|
|
}
|
|
|
|
profileID, err := gid.ParseGID(id)
|
|
if err != nil {
|
|
return scim.Resource{}, rc.logAndWrapError(scimerrors.ScimErrorResourceNotFound(id), "invalid profile ID")
|
|
}
|
|
|
|
resource, err := h.handler.iam.SCIMService.GetUser(rc.ctx, rc.config, profileID)
|
|
if err != nil {
|
|
return scim.Resource{}, rc.logAndWrapError(err, "cannot get user")
|
|
}
|
|
|
|
rc.userName = resource.Attributes["userName"].(string)
|
|
|
|
rc.logSuccess(200)
|
|
|
|
return resource, nil
|
|
}
|
|
|
|
func (h *scimResourceHandler) GetAll(r *http.Request, params scim.ListRequestParams) (scim.Page, error) {
|
|
path := "/Users"
|
|
if r.URL.RawQuery != "" {
|
|
path += "?" + r.URL.RawQuery
|
|
}
|
|
|
|
rc := &scimRequestContext{
|
|
ctx: r.Context(),
|
|
config: scimConfigFromContext(r.Context()),
|
|
ipAddress: getIPAddress(r),
|
|
method: "GET",
|
|
path: path,
|
|
handler: h,
|
|
}
|
|
|
|
var filterExpr scimfilter.Expression
|
|
if params.FilterValidator != nil {
|
|
if err := params.FilterValidator.Validate(); err != nil {
|
|
return scim.Page{}, rc.logAndWrapError(scimerrors.ScimErrorBadRequest(err.Error()), "invalid filter")
|
|
}
|
|
|
|
filterExpr = params.FilterValidator.GetFilter()
|
|
}
|
|
|
|
resources, totalCount, err := h.handler.iam.SCIMService.ListUsers(rc.ctx, rc.config, filterExpr, params.StartIndex, params.Count)
|
|
if err != nil {
|
|
return scim.Page{}, rc.logAndWrapError(err, "cannot list users")
|
|
}
|
|
|
|
rc.logSuccess(200)
|
|
return scim.Page{
|
|
TotalResults: totalCount,
|
|
Resources: resources,
|
|
}, nil
|
|
}
|
|
|
|
func (h *scimResourceHandler) Replace(r *http.Request, id string, attributes scim.ResourceAttributes) (scim.Resource, error) {
|
|
rc := &scimRequestContext{
|
|
ctx: r.Context(),
|
|
config: scimConfigFromContext(r.Context()),
|
|
ipAddress: getIPAddress(r),
|
|
method: "PUT",
|
|
path: "/Users/" + id,
|
|
handler: h,
|
|
}
|
|
|
|
profileID, err := gid.ParseGID(id)
|
|
if err != nil {
|
|
return scim.Resource{}, rc.logAndWrapError(scimerrors.ScimErrorResourceNotFound(id), "invalid profile ID")
|
|
}
|
|
|
|
resource, err := h.handler.iam.SCIMService.ReplaceUser(rc.ctx, rc.config, profileID, attributes)
|
|
if err != nil {
|
|
return scim.Resource{}, rc.logAndWrapError(err, "cannot update user")
|
|
}
|
|
|
|
rc.userName = resource.Attributes["userName"].(string)
|
|
|
|
rc.logSuccess(200)
|
|
|
|
return resource, nil
|
|
}
|
|
|
|
func (h *scimResourceHandler) Patch(r *http.Request, id string, operations []scim.PatchOperation) (scim.Resource, error) {
|
|
rc := &scimRequestContext{
|
|
ctx: r.Context(),
|
|
config: scimConfigFromContext(r.Context()),
|
|
ipAddress: getIPAddress(r),
|
|
method: "PATCH",
|
|
path: "/Users/" + id,
|
|
handler: h,
|
|
}
|
|
|
|
profileID, err := gid.ParseGID(id)
|
|
if err != nil {
|
|
return scim.Resource{}, rc.logAndWrapError(scimerrors.ScimErrorResourceNotFound(id), "invalid profile ID")
|
|
}
|
|
|
|
resource, err := h.handler.iam.SCIMService.PatchUser(rc.ctx, rc.config, profileID, operations)
|
|
if err != nil {
|
|
return scim.Resource{}, rc.logAndWrapError(err, "cannot patch user")
|
|
}
|
|
|
|
rc.userName = resource.Attributes["userName"].(string)
|
|
|
|
rc.logSuccess(200)
|
|
|
|
return resource, nil
|
|
}
|
|
|
|
func (h *scimResourceHandler) Delete(r *http.Request, id string) error {
|
|
rc := &scimRequestContext{
|
|
ctx: r.Context(),
|
|
config: scimConfigFromContext(r.Context()),
|
|
ipAddress: getIPAddress(r),
|
|
method: "DELETE",
|
|
path: "/Users/" + id,
|
|
handler: h,
|
|
}
|
|
|
|
profileID, err := gid.ParseGID(id)
|
|
if err != nil {
|
|
return rc.logAndWrapError(scimerrors.ScimErrorResourceNotFound(id), "invalid profile ID")
|
|
}
|
|
|
|
err = h.handler.iam.SCIMService.DeleteUser(rc.ctx, rc.config, profileID)
|
|
if err != nil {
|
|
return rc.logAndWrapError(err, "cannot delete user")
|
|
}
|
|
|
|
rc.userName = ""
|
|
|
|
rc.logSuccess(204)
|
|
|
|
return nil
|
|
}
|
|
|
|
func getIPAddress(r *http.Request) net.IP {
|
|
if ip := net.ParseIP(clientip.Extract(r)); ip != nil {
|
|
return ip
|
|
}
|
|
|
|
return net.IPv4(127, 0, 0, 1)
|
|
}
|