The source headers, LICENSE files, and license metadata had drifted apart. Align the entire project to MIT: - Convert every source-file header to the MIT text across all comment styles (Go, TS, TSX, JS, MJS, SQL, CSS, GraphQL, shell), including SPDX-License-Identifier tags - Set the root and cookie-banner LICENSE files to the MIT text with a "MIT License" title line - Switch the package.json license fields, Docker image label, and cookie-banner README to MIT - Update docs and the genmodels header generator accordingly - Normalize copyright lines to a single format (Copyright (c) <year(s)> Probo Inc <hello@probo.com>.): unify the hello@getprobo.com and hello@probo.inc emails to hello@probo.com and the comma-separated years to a hyphenated range Genuine third-party references are intentionally left untouched: the Lucide icon attributions (Lucide is ISC) and the trivy dependency license allowlist. Signed-off-by: Sacha Al Himdani <sacha@probo.com>
358 lines
10 KiB
Go
358 lines
10 KiB
Go
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
|
//
|
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
// of this software and associated documentation files (the "Software"), to deal
|
|
// in the Software without restriction, including without limitation the rights
|
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
// copies of the Software, and to permit persons to whom the Software is
|
|
// furnished to do so, subject to the following conditions:
|
|
//
|
|
// The above copyright notice and this permission notice shall be included in
|
|
// all copies or substantial portions of the Software.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
// 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 {
|
|
if _, ok := errors.AsType[*scimservice.ErrSCIMInvalidToken](err); ok {
|
|
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 {
|
|
if scimErr, ok := errors.AsType[scimerrors.ScimError](err); ok {
|
|
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)
|
|
}
|