Add SCIM handler draft
Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
4
go.mod
4
go.mod
@@ -13,6 +13,7 @@ require (
|
||||
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d
|
||||
github.com/chromedp/chromedp v0.14.2
|
||||
github.com/crewjam/saml v0.5.1
|
||||
github.com/elimity-com/scim v0.0.0-20240320110924-172bf2aee9c8
|
||||
github.com/go-chi/chi/v5 v5.2.4
|
||||
github.com/go-chi/cors v1.2.2
|
||||
github.com/jackc/pgx/v5 v5.8.0
|
||||
@@ -57,6 +58,8 @@ require (
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/di-wu/parser v0.2.2 // indirect
|
||||
github.com/di-wu/xsd-datetime v1.0.0 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
@@ -95,6 +98,7 @@ require (
|
||||
github.com/richardlehane/mscfb v1.0.4 // indirect
|
||||
github.com/richardlehane/msoleps v1.0.4 // indirect
|
||||
github.com/russellhaering/goxmldsig v1.5.0 // indirect
|
||||
github.com/scim2/filter-parser/v2 v2.2.0 // indirect
|
||||
github.com/sosodev/duration v1.3.1 // indirect
|
||||
github.com/spf13/cobra v1.10.1 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
|
||||
8
go.sum
8
go.sum
@@ -64,6 +64,12 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo=
|
||||
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
|
||||
github.com/di-wu/parser v0.2.2 h1:I9oHJ8spBXOeL7Wps0ffkFFFiXJf/pk7NX9lcAMqRMU=
|
||||
github.com/di-wu/parser v0.2.2/go.mod h1:SLp58pW6WamdmznrVRrw2NTyn4wAvT9rrEFynKX7nYo=
|
||||
github.com/di-wu/xsd-datetime v1.0.0 h1:vZoGNkbzpBNoc+JyfVLEbutNDNydYV8XwHeV7eUJoxI=
|
||||
github.com/di-wu/xsd-datetime v1.0.0/go.mod h1:i3iEhrP3WchwseOBeIdW/zxeoleXTOzx1WyDXgdmOww=
|
||||
github.com/elimity-com/scim v0.0.0-20240320110924-172bf2aee9c8 h1:0+BTyxIYgiVAry/P5s8R4dYuLkhB9Nhso8ogFWNr4IQ=
|
||||
github.com/elimity-com/scim v0.0.0-20240320110924-172bf2aee9c8/go.mod h1:JkjcmqbLW+khwt2fmBPJFBhx2zGZ8XobRZ+O0VhlwWo=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4=
|
||||
@@ -184,6 +190,8 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
|
||||
github.com/russellhaering/goxmldsig v1.5.0 h1:AU2UkkYIUOTyZRbe08XMThaOCelArgvNfYapcmSjBNw=
|
||||
github.com/russellhaering/goxmldsig v1.5.0/go.mod h1:x98CjQNFJcWfMxeOrMnMKg70lvDP6tE0nTaeUnjXDmk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/scim2/filter-parser/v2 v2.2.0 h1:QGadEcsmypxg8gYChRSM2j1edLyE/2j72j+hdmI4BJM=
|
||||
github.com/scim2/filter-parser/v2 v2.2.0/go.mod h1:jWnkDToqX/Y0ugz0P5VvpVEUKcWcyHHj+X+je9ce5JA=
|
||||
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
||||
github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4=
|
||||
|
||||
@@ -387,6 +387,76 @@ LEFT JOIN
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Membership) LoadByEmailAndOrganization(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
email mail.Addr,
|
||||
organizationID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
WITH mbr AS (
|
||||
SELECT
|
||||
am.id,
|
||||
am.identity_id,
|
||||
am.organization_id,
|
||||
am.role,
|
||||
am.source,
|
||||
am.created_at,
|
||||
am.updated_at
|
||||
FROM
|
||||
iam_memberships am
|
||||
JOIN
|
||||
identities i ON am.identity_id = i.id
|
||||
WHERE
|
||||
i.email_address = @email
|
||||
AND am.organization_id = @organization_id
|
||||
AND %s
|
||||
)
|
||||
SELECT
|
||||
mbr.id,
|
||||
mbr.identity_id,
|
||||
mbr.organization_id,
|
||||
mbr.role,
|
||||
mbr.source,
|
||||
COALESCE(mp.full_name, i.full_name, '') as full_name,
|
||||
i.email_address,
|
||||
mbr.created_at,
|
||||
mbr.updated_at
|
||||
FROM
|
||||
mbr
|
||||
JOIN
|
||||
identities i ON mbr.identity_id = i.id
|
||||
LEFT JOIN
|
||||
iam_membership_profiles mp ON mp.membership_id = mbr.id
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"email": email,
|
||||
"organization_id": organizationID,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query membership by email: %w", err)
|
||||
}
|
||||
|
||||
membership, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Membership])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot collect membership: %w", err)
|
||||
}
|
||||
|
||||
*m = membership
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Membership) Update(ctx context.Context, conn pg.Conn, scope Scoper) error {
|
||||
query := `
|
||||
UPDATE
|
||||
|
||||
91
pkg/iam/scim/errors.go
Normal file
91
pkg/iam/scim/errors.go
Normal file
@@ -0,0 +1,91 @@
|
||||
// Copyright (c) 2025 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 scim
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
)
|
||||
|
||||
type ErrSCIMConfigurationNotFound struct {
|
||||
ID gid.GID
|
||||
}
|
||||
|
||||
func (e *ErrSCIMConfigurationNotFound) Error() string {
|
||||
return fmt.Sprintf("SCIM configuration %s not found", e.ID)
|
||||
}
|
||||
|
||||
func NewSCIMConfigurationNotFoundError(id gid.GID) *ErrSCIMConfigurationNotFound {
|
||||
return &ErrSCIMConfigurationNotFound{ID: id}
|
||||
}
|
||||
|
||||
type ErrSCIMConfigurationAlreadyExists struct {
|
||||
OrganizationID gid.GID
|
||||
}
|
||||
|
||||
func (e *ErrSCIMConfigurationAlreadyExists) Error() string {
|
||||
return fmt.Sprintf("SCIM configuration already exists for organization %s", e.OrganizationID)
|
||||
}
|
||||
|
||||
func NewSCIMConfigurationAlreadyExistsError(organizationID gid.GID) *ErrSCIMConfigurationAlreadyExists {
|
||||
return &ErrSCIMConfigurationAlreadyExists{OrganizationID: organizationID}
|
||||
}
|
||||
|
||||
type ErrSCIMUserNotFound struct {
|
||||
ID gid.GID
|
||||
}
|
||||
|
||||
func (e *ErrSCIMUserNotFound) Error() string {
|
||||
return fmt.Sprintf("SCIM user %s not found", e.ID)
|
||||
}
|
||||
|
||||
func NewSCIMUserNotFoundError(id gid.GID) *ErrSCIMUserNotFound {
|
||||
return &ErrSCIMUserNotFound{ID: id}
|
||||
}
|
||||
|
||||
type ErrSCIMInvalidToken struct{}
|
||||
|
||||
func (e *ErrSCIMInvalidToken) Error() string {
|
||||
return "invalid SCIM bearer token"
|
||||
}
|
||||
|
||||
func NewSCIMInvalidTokenError() *ErrSCIMInvalidToken {
|
||||
return &ErrSCIMInvalidToken{}
|
||||
}
|
||||
|
||||
type ErrSCIMInvalidRequest struct {
|
||||
Detail string
|
||||
}
|
||||
|
||||
func (e *ErrSCIMInvalidRequest) Error() string {
|
||||
return fmt.Sprintf("invalid SCIM request: %s", e.Detail)
|
||||
}
|
||||
|
||||
func NewSCIMInvalidRequestError(detail string) *ErrSCIMInvalidRequest {
|
||||
return &ErrSCIMInvalidRequest{Detail: detail}
|
||||
}
|
||||
|
||||
type ErrSCIMUserAlreadyExists struct {
|
||||
Email string
|
||||
}
|
||||
|
||||
func (e *ErrSCIMUserAlreadyExists) Error() string {
|
||||
return fmt.Sprintf("user with email %s already exists in this organization", e.Email)
|
||||
}
|
||||
|
||||
func NewSCIMUserAlreadyExistsError(email string) *ErrSCIMUserAlreadyExists {
|
||||
return &ErrSCIMUserAlreadyExists{Email: email}
|
||||
}
|
||||
72
pkg/iam/scim/schema.go
Normal file
72
pkg/iam/scim/schema.go
Normal file
@@ -0,0 +1,72 @@
|
||||
// Copyright (c) 2025 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 scim
|
||||
|
||||
import (
|
||||
"github.com/elimity-com/scim/optional"
|
||||
"github.com/elimity-com/scim/schema"
|
||||
)
|
||||
|
||||
// UserSchema returns the SCIM User schema definition
|
||||
func UserSchema() schema.Schema {
|
||||
return schema.Schema{
|
||||
ID: schema.UserSchema,
|
||||
Name: optional.NewString("User"),
|
||||
Description: optional.NewString("User Account"),
|
||||
Attributes: []schema.CoreAttribute{
|
||||
schema.SimpleCoreAttribute(schema.SimpleStringParams(schema.StringParams{
|
||||
Name: "userName",
|
||||
Required: true,
|
||||
Uniqueness: schema.AttributeUniquenessServer(),
|
||||
})),
|
||||
schema.SimpleCoreAttribute(schema.SimpleStringParams(schema.StringParams{
|
||||
Name: "displayName",
|
||||
})),
|
||||
schema.ComplexCoreAttribute(schema.ComplexParams{
|
||||
Name: "name",
|
||||
SubAttributes: []schema.SimpleParams{
|
||||
schema.SimpleStringParams(schema.StringParams{
|
||||
Name: "formatted",
|
||||
}),
|
||||
schema.SimpleStringParams(schema.StringParams{
|
||||
Name: "familyName",
|
||||
}),
|
||||
schema.SimpleStringParams(schema.StringParams{
|
||||
Name: "givenName",
|
||||
}),
|
||||
},
|
||||
}),
|
||||
schema.SimpleCoreAttribute(schema.SimpleBooleanParams(schema.BooleanParams{
|
||||
Name: "active",
|
||||
})),
|
||||
schema.ComplexCoreAttribute(schema.ComplexParams{
|
||||
Name: "emails",
|
||||
MultiValued: true,
|
||||
SubAttributes: []schema.SimpleParams{
|
||||
schema.SimpleStringParams(schema.StringParams{
|
||||
Name: "value",
|
||||
}),
|
||||
schema.SimpleStringParams(schema.StringParams{
|
||||
Name: "type",
|
||||
}),
|
||||
schema.SimpleBooleanParams(schema.BooleanParams{
|
||||
Name: "primary",
|
||||
}),
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
739
pkg/iam/scim/service.go
Normal file
739
pkg/iam/scim/service.go
Normal file
@@ -0,0 +1,739 @@
|
||||
// Copyright (c) 2025 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 scim
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/elimity-com/scim"
|
||||
"github.com/elimity-com/scim/optional"
|
||||
"go.gearno.de/kit/log"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/mail"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
)
|
||||
|
||||
type (
|
||||
Service struct {
|
||||
pg *pg.Client
|
||||
logger *log.Logger
|
||||
}
|
||||
)
|
||||
|
||||
func NewService(
|
||||
pg *pg.Client,
|
||||
logger *log.Logger,
|
||||
) *Service {
|
||||
return &Service{
|
||||
pg: pg,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// HashToken creates a FIPS 140 compliant SHA-256 hash of the token
|
||||
func HashToken(token string) []byte {
|
||||
hash := sha256.Sum256([]byte(token))
|
||||
return hash[:]
|
||||
}
|
||||
|
||||
// GenerateToken creates a cryptographically secure random token
|
||||
func GenerateToken() (string, error) {
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", fmt.Errorf("cannot generate random token: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
// ValidateToken validates a bearer token and returns the SCIM configuration
|
||||
func (s *Service) ValidateToken(ctx context.Context, token string) (*coredata.SCIMConfiguration, error) {
|
||||
hashedToken := HashToken(token)
|
||||
config := &coredata.SCIMConfiguration{}
|
||||
|
||||
err := s.pg.WithConn(ctx, func(conn pg.Conn) error {
|
||||
err := config.LoadByHashedToken(ctx, conn, hashedToken)
|
||||
if err != nil {
|
||||
if err == coredata.ErrResourceNotFound {
|
||||
return NewSCIMInvalidTokenError()
|
||||
}
|
||||
return fmt.Errorf("cannot load SCIM configuration: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// CreateUser creates a new user via SCIM provisioning
|
||||
func (s *Service) CreateUser(
|
||||
ctx context.Context,
|
||||
config *coredata.SCIMConfiguration,
|
||||
attributes scim.ResourceAttributes,
|
||||
ipAddress net.IP,
|
||||
) (*coredata.Membership, error) {
|
||||
user := ParseUserFromAttributes(attributes)
|
||||
email := user.GetPrimaryEmail()
|
||||
if email == "" {
|
||||
return nil, NewSCIMInvalidRequestError("userName or email is required")
|
||||
}
|
||||
|
||||
emailAddr, err := mail.ParseAddr(email)
|
||||
if err != nil {
|
||||
return nil, NewSCIMInvalidRequestError("invalid email format")
|
||||
}
|
||||
|
||||
fullName := user.GetFullName()
|
||||
now := time.Now()
|
||||
|
||||
var membership *coredata.Membership
|
||||
|
||||
scope := coredata.NewScopeFromObjectID(config.OrganizationID)
|
||||
|
||||
err = s.pg.WithTx(ctx, func(tx pg.Conn) error {
|
||||
// Check if identity exists
|
||||
identity := &coredata.Identity{}
|
||||
err := identity.LoadByEmail(ctx, tx, emailAddr)
|
||||
|
||||
if err == coredata.ErrResourceNotFound {
|
||||
// Create new identity
|
||||
identity = &coredata.Identity{
|
||||
ID: gid.New(gid.NilTenant, coredata.IdentityEntityType),
|
||||
EmailAddress: emailAddr,
|
||||
FullName: fullName,
|
||||
HashedPassword: nil,
|
||||
EmailAddressVerified: false,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
err = identity.Insert(ctx, tx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot insert identity: %w", err)
|
||||
}
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("cannot load identity: %w", err)
|
||||
}
|
||||
|
||||
// Check if membership exists
|
||||
membership = &coredata.Membership{}
|
||||
err = membership.LoadByIdentityAndOrg(ctx, tx, scope, identity.ID, config.OrganizationID)
|
||||
|
||||
if err == coredata.ErrResourceNotFound {
|
||||
// Create new membership
|
||||
membership = &coredata.Membership{
|
||||
ID: gid.New(config.OrganizationID.TenantID(), coredata.MembershipEntityType),
|
||||
IdentityID: identity.ID,
|
||||
OrganizationID: config.OrganizationID,
|
||||
Role: coredata.MembershipRoleViewer,
|
||||
Source: coredata.MembershipSourceSCIM,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
err = membership.Insert(ctx, tx, scope)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot insert membership: %w", err)
|
||||
}
|
||||
|
||||
// Create membership profile
|
||||
membershipProfile := &coredata.MembershipProfile{
|
||||
ID: gid.New(membership.ID.TenantID(), coredata.MembershipProfileEntityType),
|
||||
MembershipID: membership.ID,
|
||||
FullName: fullName,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
err = membershipProfile.Insert(ctx, tx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot insert membership profile: %w", err)
|
||||
}
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("cannot load membership: %w", err)
|
||||
} else {
|
||||
// Update existing membership source to SCIM
|
||||
membership.Source = coredata.MembershipSourceSCIM
|
||||
membership.UpdatedAt = now
|
||||
|
||||
err = membership.Update(ctx, tx, scope)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot update membership: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Log SCIM event
|
||||
event := s.createEvent(config, "POST", "/Users", membership.ID, ipAddress, 201, nil)
|
||||
err = event.Insert(ctx, tx, scope)
|
||||
if err != nil {
|
||||
s.logger.ErrorCtx(ctx, "cannot log SCIM event", log.Error(err))
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return membership, nil
|
||||
}
|
||||
|
||||
// GetUser gets a user by membership ID
|
||||
func (s *Service) GetUser(
|
||||
ctx context.Context,
|
||||
config *coredata.SCIMConfiguration,
|
||||
membershipID gid.GID,
|
||||
ipAddress net.IP,
|
||||
) (*coredata.Membership, *coredata.Identity, *coredata.MembershipProfile, error) {
|
||||
scope := coredata.NewScopeFromObjectID(config.OrganizationID)
|
||||
|
||||
var membership *coredata.Membership
|
||||
var identity *coredata.Identity
|
||||
var profile *coredata.MembershipProfile
|
||||
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
membership = &coredata.Membership{}
|
||||
err := membership.LoadByID(ctx, conn, scope, membershipID)
|
||||
if err != nil {
|
||||
if err == coredata.ErrResourceNotFound {
|
||||
return NewSCIMUserNotFoundError(membershipID)
|
||||
}
|
||||
return fmt.Errorf("cannot load membership: %w", err)
|
||||
}
|
||||
|
||||
// Verify membership belongs to this organization
|
||||
if membership.OrganizationID != config.OrganizationID {
|
||||
return NewSCIMUserNotFoundError(membershipID)
|
||||
}
|
||||
|
||||
identity = &coredata.Identity{}
|
||||
err = identity.LoadByID(ctx, conn, membership.IdentityID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot load identity: %w", err)
|
||||
}
|
||||
|
||||
profile = &coredata.MembershipProfile{}
|
||||
err = profile.LoadByMembershipID(ctx, conn, scope, membershipID)
|
||||
if err != nil && err != coredata.ErrResourceNotFound {
|
||||
return fmt.Errorf("cannot load membership profile: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
return membership, identity, profile, nil
|
||||
}
|
||||
|
||||
// ListUsers lists all users in an organization, with optional filter support
|
||||
func (s *Service) ListUsers(
|
||||
ctx context.Context,
|
||||
config *coredata.SCIMConfiguration,
|
||||
filter *UserFilter,
|
||||
startIndex int,
|
||||
count int,
|
||||
ipAddress net.IP,
|
||||
) ([]*coredata.Membership, int, error) {
|
||||
scope := coredata.NewScopeFromObjectID(config.OrganizationID)
|
||||
|
||||
var memberships coredata.Memberships
|
||||
var totalCount int
|
||||
|
||||
err := s.pg.WithConn(ctx, func(conn pg.Conn) error {
|
||||
// If we have a userName filter, query by email directly
|
||||
if filter != nil && filter.UserName != nil {
|
||||
emailAddr, err := mail.ParseAddr(*filter.UserName)
|
||||
if err != nil {
|
||||
// Invalid email format - return empty result
|
||||
totalCount = 0
|
||||
return nil
|
||||
}
|
||||
|
||||
membership := &coredata.Membership{}
|
||||
err = membership.LoadByEmailAndOrganization(ctx, conn, scope, emailAddr, config.OrganizationID)
|
||||
if err == coredata.ErrResourceNotFound {
|
||||
totalCount = 0
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot load membership by email: %w", err)
|
||||
}
|
||||
|
||||
memberships = append(memberships, membership)
|
||||
totalCount = 1
|
||||
return nil
|
||||
}
|
||||
|
||||
// No filter - return all memberships with pagination
|
||||
var err error
|
||||
totalCount, err = memberships.CountByOrganizationID(ctx, conn, scope, config.OrganizationID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot count memberships: %w", err)
|
||||
}
|
||||
|
||||
orderBy := page.OrderBy[coredata.MembershipOrderField]{
|
||||
Field: coredata.MembershipOrderFieldCreatedAt,
|
||||
Direction: page.OrderDirectionDesc,
|
||||
}
|
||||
cursor := page.NewCursor(count, nil, page.Head, orderBy)
|
||||
|
||||
err = memberships.LoadByOrganizationID(ctx, conn, scope, config.OrganizationID, cursor)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot load memberships: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return memberships, totalCount, nil
|
||||
}
|
||||
|
||||
// ReplaceUser replaces a user via SCIM PUT
|
||||
// Returns the membership, a boolean indicating if user was deactivated, and an error
|
||||
func (s *Service) ReplaceUser(
|
||||
ctx context.Context,
|
||||
config *coredata.SCIMConfiguration,
|
||||
membershipID gid.GID,
|
||||
attributes scim.ResourceAttributes,
|
||||
ipAddress net.IP,
|
||||
) (*coredata.Membership, bool, error) {
|
||||
user := ParseUserFromReplaceAttributes(attributes)
|
||||
return s.updateUser(ctx, config, membershipID, user, "PUT", ipAddress)
|
||||
}
|
||||
|
||||
// PatchUser patches a user via SCIM PATCH
|
||||
// Returns the membership, a boolean indicating if user was deactivated, and an error
|
||||
func (s *Service) PatchUser(
|
||||
ctx context.Context,
|
||||
config *coredata.SCIMConfiguration,
|
||||
membershipID gid.GID,
|
||||
operations []scim.PatchOperation,
|
||||
ipAddress net.IP,
|
||||
) (*coredata.Membership, bool, error) {
|
||||
user := ParseUserFromPatchOperations(operations)
|
||||
return s.updateUser(ctx, config, membershipID, user, "PATCH", ipAddress)
|
||||
}
|
||||
|
||||
func (s *Service) updateUser(
|
||||
ctx context.Context,
|
||||
config *coredata.SCIMConfiguration,
|
||||
membershipID gid.GID,
|
||||
user *User,
|
||||
method string,
|
||||
ipAddress net.IP,
|
||||
) (*coredata.Membership, bool, error) {
|
||||
scope := coredata.NewScopeFromObjectID(config.OrganizationID)
|
||||
now := time.Now()
|
||||
|
||||
var membership *coredata.Membership
|
||||
var deactivated bool
|
||||
|
||||
err := s.pg.WithTx(ctx, func(tx pg.Conn) error {
|
||||
membership = &coredata.Membership{}
|
||||
err := membership.LoadByID(ctx, tx, scope, membershipID)
|
||||
if err != nil {
|
||||
if err == coredata.ErrResourceNotFound {
|
||||
return NewSCIMUserNotFoundError(membershipID)
|
||||
}
|
||||
return fmt.Errorf("cannot load membership: %w", err)
|
||||
}
|
||||
|
||||
// Verify membership belongs to this organization
|
||||
if membership.OrganizationID != config.OrganizationID {
|
||||
return NewSCIMUserNotFoundError(membershipID)
|
||||
}
|
||||
|
||||
// Handle deactivation - Okta sends PATCH with active=false to deprovision users
|
||||
if user.Active != nil && !*user.Active {
|
||||
err = membership.Delete(ctx, tx, scope, membershipID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot delete membership: %w", err)
|
||||
}
|
||||
|
||||
deactivated = true
|
||||
|
||||
// Log SCIM event for deactivation
|
||||
event := s.createEvent(config, method, fmt.Sprintf("/Users/%s", membershipID), membershipID, ipAddress, 200, nil)
|
||||
err = event.Insert(ctx, tx, scope)
|
||||
if err != nil {
|
||||
s.logger.ErrorCtx(ctx, "cannot log SCIM event", log.Error(err))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update membership source to SCIM if not already
|
||||
if membership.Source != coredata.MembershipSourceSCIM {
|
||||
membership.Source = coredata.MembershipSourceSCIM
|
||||
membership.UpdatedAt = now
|
||||
|
||||
err = membership.Update(ctx, tx, scope)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot update membership: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update membership profile
|
||||
profile := &coredata.MembershipProfile{}
|
||||
err = profile.LoadByMembershipID(ctx, tx, scope, membershipID)
|
||||
if err == nil {
|
||||
fullName := user.GetFullName()
|
||||
if fullName != "" {
|
||||
profile.FullName = fullName
|
||||
profile.UpdatedAt = now
|
||||
|
||||
err = profile.Update(ctx, tx, scope)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot update membership profile: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Log SCIM event
|
||||
event := s.createEvent(config, method, fmt.Sprintf("/Users/%s", membershipID), membership.ID, ipAddress, 200, nil)
|
||||
err = event.Insert(ctx, tx, scope)
|
||||
if err != nil {
|
||||
s.logger.ErrorCtx(ctx, "cannot log SCIM event", log.Error(err))
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
return membership, deactivated, nil
|
||||
}
|
||||
|
||||
// DeleteUser removes a user's membership from the organization
|
||||
func (s *Service) DeleteUser(
|
||||
ctx context.Context,
|
||||
config *coredata.SCIMConfiguration,
|
||||
membershipID gid.GID,
|
||||
ipAddress net.IP,
|
||||
) error {
|
||||
scope := coredata.NewScopeFromObjectID(config.OrganizationID)
|
||||
|
||||
return s.pg.WithTx(ctx, func(tx pg.Conn) error {
|
||||
membership := &coredata.Membership{}
|
||||
err := membership.LoadByID(ctx, tx, scope, membershipID)
|
||||
if err != nil {
|
||||
if err == coredata.ErrResourceNotFound {
|
||||
return NewSCIMUserNotFoundError(membershipID)
|
||||
}
|
||||
return fmt.Errorf("cannot load membership: %w", err)
|
||||
}
|
||||
|
||||
// Verify membership belongs to this organization
|
||||
if membership.OrganizationID != config.OrganizationID {
|
||||
return NewSCIMUserNotFoundError(membershipID)
|
||||
}
|
||||
|
||||
err = membership.Delete(ctx, tx, scope, membershipID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot delete membership: %w", err)
|
||||
}
|
||||
|
||||
// Log SCIM event
|
||||
event := s.createEvent(config, "DELETE", fmt.Sprintf("/Users/%s", membershipID), membershipID, ipAddress, 204, nil)
|
||||
err = event.Insert(ctx, tx, scope)
|
||||
if err != nil {
|
||||
s.logger.ErrorCtx(ctx, "cannot log SCIM event", log.Error(err))
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// LogEvent logs a SCIM event
|
||||
func (s *Service) LogEvent(
|
||||
ctx context.Context,
|
||||
config *coredata.SCIMConfiguration,
|
||||
method string,
|
||||
path string,
|
||||
membershipID *gid.GID,
|
||||
ipAddress net.IP,
|
||||
statusCode int,
|
||||
errorMessage *string,
|
||||
) {
|
||||
scope := coredata.NewScopeFromObjectID(config.OrganizationID)
|
||||
|
||||
var mID gid.GID
|
||||
if membershipID != nil {
|
||||
mID = *membershipID
|
||||
}
|
||||
|
||||
event := s.createEvent(config, method, path, mID, ipAddress, statusCode, errorMessage)
|
||||
|
||||
err := s.pg.WithConn(ctx, func(conn pg.Conn) error {
|
||||
return event.Insert(ctx, conn, scope)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
s.logger.ErrorCtx(ctx, "cannot log SCIM event", log.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) createEvent(
|
||||
config *coredata.SCIMConfiguration,
|
||||
method string,
|
||||
path string,
|
||||
membershipID gid.GID,
|
||||
ipAddress net.IP,
|
||||
statusCode int,
|
||||
errorMessage *string,
|
||||
) *coredata.SCIMEvent {
|
||||
event := &coredata.SCIMEvent{
|
||||
ID: gid.New(config.OrganizationID.TenantID(), coredata.SCIMEventEntityType),
|
||||
OrganizationID: config.OrganizationID,
|
||||
SCIMConfigurationID: config.ID,
|
||||
Method: method,
|
||||
Path: path,
|
||||
StatusCode: statusCode,
|
||||
ErrorMessage: errorMessage,
|
||||
IPAddress: ipAddress,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if membershipID != gid.Nil {
|
||||
event.MembershipID = &membershipID
|
||||
}
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
// ParseUserFromAttributes extracts a User from SCIM resource attributes
|
||||
func ParseUserFromAttributes(attributes scim.ResourceAttributes) *User {
|
||||
userName, _ := attributes["userName"].(string)
|
||||
displayName, _ := attributes["displayName"].(string)
|
||||
|
||||
var givenName, familyName string
|
||||
if name, ok := attributes["name"].(map[string]interface{}); ok {
|
||||
givenName, _ = name["givenName"].(string)
|
||||
familyName, _ = name["familyName"].(string)
|
||||
}
|
||||
|
||||
// Get email from emails array or use userName
|
||||
email := userName
|
||||
if emails, ok := attributes["emails"].([]interface{}); ok && len(emails) > 0 {
|
||||
for _, e := range emails {
|
||||
if emailMap, ok := e.(map[string]interface{}); ok {
|
||||
if primary, _ := emailMap["primary"].(bool); primary {
|
||||
if value, ok := emailMap["value"].(string); ok {
|
||||
email = value
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// If no primary email found, use the first one
|
||||
if email == userName {
|
||||
if emailMap, ok := emails[0].(map[string]interface{}); ok {
|
||||
if value, ok := emailMap["value"].(string); ok {
|
||||
email = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build full name
|
||||
fullName := displayName
|
||||
if fullName == "" {
|
||||
fullName = strings.TrimSpace(givenName + " " + familyName)
|
||||
}
|
||||
if fullName == "" {
|
||||
fullName = userName
|
||||
}
|
||||
|
||||
user := &User{
|
||||
UserName: userName,
|
||||
DisplayName: displayName,
|
||||
Name: &Name{
|
||||
GivenName: givenName,
|
||||
FamilyName: familyName,
|
||||
Formatted: fullName,
|
||||
},
|
||||
Emails: []Email{
|
||||
{
|
||||
Value: email,
|
||||
Type: "work",
|
||||
Primary: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
// ParseUserFromReplaceAttributes extracts a User from SCIM replace attributes
|
||||
func ParseUserFromReplaceAttributes(attributes scim.ResourceAttributes) *User {
|
||||
displayName, _ := attributes["displayName"].(string)
|
||||
|
||||
var givenName, familyName string
|
||||
if name, ok := attributes["name"].(map[string]interface{}); ok {
|
||||
givenName, _ = name["givenName"].(string)
|
||||
familyName, _ = name["familyName"].(string)
|
||||
}
|
||||
|
||||
fullName := displayName
|
||||
if fullName == "" {
|
||||
fullName = strings.TrimSpace(givenName + " " + familyName)
|
||||
}
|
||||
|
||||
active := true
|
||||
if a, ok := attributes["active"].(bool); ok {
|
||||
active = a
|
||||
}
|
||||
|
||||
return &User{
|
||||
DisplayName: fullName,
|
||||
Active: &active,
|
||||
Name: &Name{
|
||||
GivenName: givenName,
|
||||
FamilyName: familyName,
|
||||
Formatted: fullName,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ParseUserFromPatchOperations extracts a User from SCIM patch operations
|
||||
func ParseUserFromPatchOperations(operations []scim.PatchOperation) *User {
|
||||
user := &User{}
|
||||
for _, op := range operations {
|
||||
if strings.EqualFold(op.Op, "replace") || strings.EqualFold(op.Op, "add") {
|
||||
path := ""
|
||||
if op.Path != nil {
|
||||
path = op.Path.String()
|
||||
}
|
||||
switch strings.ToLower(path) {
|
||||
case "active":
|
||||
if active, ok := op.Value.(bool); ok {
|
||||
user.Active = &active
|
||||
}
|
||||
case "displayname":
|
||||
if name, ok := op.Value.(string); ok {
|
||||
user.DisplayName = name
|
||||
}
|
||||
case "name.givenname":
|
||||
if user.Name == nil {
|
||||
user.Name = &Name{}
|
||||
}
|
||||
if name, ok := op.Value.(string); ok {
|
||||
user.Name.GivenName = name
|
||||
}
|
||||
case "name.familyname":
|
||||
if user.Name == nil {
|
||||
user.Name = &Name{}
|
||||
}
|
||||
if name, ok := op.Value.(string); ok {
|
||||
user.Name.FamilyName = name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
// MembershipToResource converts a Membership to a SCIM resource
|
||||
func MembershipToResource(m *coredata.Membership) scim.Resource {
|
||||
return MembershipToResourceWithActive(m, true)
|
||||
}
|
||||
|
||||
// MembershipToResourceWithActive converts a Membership to a SCIM resource with a custom active state
|
||||
func MembershipToResourceWithActive(m *coredata.Membership, active bool) scim.Resource {
|
||||
created := m.CreatedAt
|
||||
modified := m.UpdatedAt
|
||||
return scim.Resource{
|
||||
ID: m.ID.String(),
|
||||
ExternalID: optional.NewString(m.ID.String()),
|
||||
Attributes: scim.ResourceAttributes{
|
||||
"userName": m.EmailAddress.String(),
|
||||
"displayName": m.FullName,
|
||||
"active": active,
|
||||
"name": map[string]interface{}{
|
||||
"formatted": m.FullName,
|
||||
},
|
||||
"emails": []map[string]interface{}{
|
||||
{
|
||||
"value": m.EmailAddress.String(),
|
||||
"type": "work",
|
||||
"primary": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
Meta: scim.Meta{
|
||||
Created: &created,
|
||||
LastModified: &modified,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// MembershipToResourceFull converts a Membership with full identity and profile to a SCIM resource
|
||||
func MembershipToResourceFull(m *coredata.Membership, identity *coredata.Identity, profile *coredata.MembershipProfile) scim.Resource {
|
||||
fullName := identity.FullName
|
||||
if profile != nil && profile.FullName != "" {
|
||||
fullName = profile.FullName
|
||||
}
|
||||
|
||||
created := m.CreatedAt
|
||||
modified := m.UpdatedAt
|
||||
return scim.Resource{
|
||||
ID: m.ID.String(),
|
||||
ExternalID: optional.NewString(m.ID.String()),
|
||||
Attributes: scim.ResourceAttributes{
|
||||
"userName": identity.EmailAddress.String(),
|
||||
"displayName": fullName,
|
||||
"active": true,
|
||||
"name": map[string]interface{}{
|
||||
"formatted": fullName,
|
||||
},
|
||||
"emails": []map[string]interface{}{
|
||||
{
|
||||
"value": identity.EmailAddress.String(),
|
||||
"type": "work",
|
||||
"primary": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
Meta: scim.Meta{
|
||||
Created: &created,
|
||||
LastModified: &modified,
|
||||
},
|
||||
}
|
||||
}
|
||||
333
pkg/iam/scim/types.go
Normal file
333
pkg/iam/scim/types.go
Normal file
@@ -0,0 +1,333 @@
|
||||
// Copyright (c) 2025 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 scim
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
scimfilter "github.com/scim2/filter-parser/v2"
|
||||
)
|
||||
|
||||
// UserFilter represents filter criteria for listing SCIM users
|
||||
type UserFilter struct {
|
||||
// UserName filters by userName (email) with exact match
|
||||
UserName *string
|
||||
}
|
||||
|
||||
// ErrUnsupportedFilter is returned when a SCIM filter uses unsupported operators or attributes
|
||||
type ErrUnsupportedFilter struct {
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (e *ErrUnsupportedFilter) Error() string {
|
||||
return fmt.Sprintf("unsupported filter: %s", e.Reason)
|
||||
}
|
||||
|
||||
// ParseUserFilter converts a SCIM filter AST expression to a UserFilter.
|
||||
// Returns (nil, nil) if no filter is provided.
|
||||
// Returns an error if the filter uses unsupported operators or attributes.
|
||||
func ParseUserFilter(expr scimfilter.Expression) (*UserFilter, error) {
|
||||
if expr == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
filter := &UserFilter{}
|
||||
|
||||
switch e := expr.(type) {
|
||||
case *scimfilter.AttributeExpression:
|
||||
if err := parseAttributeExpression(e, filter); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case *scimfilter.LogicalExpression:
|
||||
if e.Operator != scimfilter.AND {
|
||||
return nil, &ErrUnsupportedFilter{Reason: fmt.Sprintf("logical operator '%s' is not supported, only 'and' is supported", e.Operator)}
|
||||
}
|
||||
if err := parseLogicalExpression(e, filter); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case *scimfilter.NotExpression:
|
||||
return nil, &ErrUnsupportedFilter{Reason: "NOT expressions are not supported"}
|
||||
case *scimfilter.ValuePath:
|
||||
return nil, &ErrUnsupportedFilter{Reason: "value path expressions are not supported"}
|
||||
default:
|
||||
return nil, &ErrUnsupportedFilter{Reason: "unknown filter expression type"}
|
||||
}
|
||||
|
||||
return filter, nil
|
||||
}
|
||||
|
||||
func parseAttributeExpression(e *scimfilter.AttributeExpression, filter *UserFilter) error {
|
||||
// Only support "eq" operator
|
||||
if e.Operator != scimfilter.EQ {
|
||||
return &ErrUnsupportedFilter{Reason: fmt.Sprintf("operator '%s' is not supported, only 'eq' is supported", e.Operator)}
|
||||
}
|
||||
|
||||
// Get the attribute name (lowercase for comparison)
|
||||
attrName := strings.ToLower(e.AttributePath.AttributeName)
|
||||
|
||||
// Extract the string value
|
||||
value, ok := e.CompareValue.(string)
|
||||
if !ok {
|
||||
return &ErrUnsupportedFilter{Reason: "filter value must be a string"}
|
||||
}
|
||||
|
||||
switch attrName {
|
||||
case "username":
|
||||
filter.UserName = &value
|
||||
default:
|
||||
return &ErrUnsupportedFilter{Reason: fmt.Sprintf("attribute '%s' is not supported for filtering, only 'userName' is supported", e.AttributePath.AttributeName)}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseLogicalExpression(e *scimfilter.LogicalExpression, filter *UserFilter) error {
|
||||
// Process left expression
|
||||
if left, ok := e.Left.(*scimfilter.AttributeExpression); ok {
|
||||
if err := parseAttributeExpression(left, filter); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return &ErrUnsupportedFilter{Reason: "nested logical expressions are not supported"}
|
||||
}
|
||||
|
||||
// Process right expression
|
||||
if right, ok := e.Right.(*scimfilter.AttributeExpression); ok {
|
||||
if err := parseAttributeExpression(right, filter); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return &ErrUnsupportedFilter{Reason: "nested logical expressions are not supported"}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SCIM 2.0 User Resource
|
||||
// https://datatracker.ietf.org/doc/html/rfc7643#section-4.1
|
||||
type User struct {
|
||||
Schemas []string `json:"schemas"`
|
||||
ID string `json:"id,omitempty"`
|
||||
ExternalID string `json:"externalId,omitempty"`
|
||||
UserName string `json:"userName"`
|
||||
Name *Name `json:"name,omitempty"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
Emails []Email `json:"emails,omitempty"`
|
||||
Active *bool `json:"active,omitempty"`
|
||||
Meta *Meta `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
type Name struct {
|
||||
Formatted string `json:"formatted,omitempty"`
|
||||
FamilyName string `json:"familyName,omitempty"`
|
||||
GivenName string `json:"givenName,omitempty"`
|
||||
MiddleName string `json:"middleName,omitempty"`
|
||||
HonorificPrefix string `json:"honorificPrefix,omitempty"`
|
||||
HonorificSuffix string `json:"honorificSuffix,omitempty"`
|
||||
}
|
||||
|
||||
type Email struct {
|
||||
Value string `json:"value"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Primary bool `json:"primary,omitempty"`
|
||||
}
|
||||
|
||||
type Meta struct {
|
||||
ResourceType string `json:"resourceType,omitempty"`
|
||||
Created string `json:"created,omitempty"`
|
||||
LastModified string `json:"lastModified,omitempty"`
|
||||
Location string `json:"location,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
}
|
||||
|
||||
// SCIM 2.0 List Response
|
||||
// https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2
|
||||
type ListResponse struct {
|
||||
Schemas []string `json:"schemas"`
|
||||
TotalResults int `json:"totalResults"`
|
||||
StartIndex int `json:"startIndex,omitempty"`
|
||||
ItemsPerPage int `json:"itemsPerPage,omitempty"`
|
||||
Resources []User `json:"Resources"`
|
||||
}
|
||||
|
||||
// SCIM 2.0 Error Response
|
||||
// https://datatracker.ietf.org/doc/html/rfc7644#section-3.12
|
||||
type ErrorResponse struct {
|
||||
Schemas []string `json:"schemas"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Status string `json:"status"`
|
||||
ScimType string `json:"scimType,omitempty"`
|
||||
}
|
||||
|
||||
// SCIM 2.0 Patch Operation
|
||||
// https://datatracker.ietf.org/doc/html/rfc7644#section-3.5.2
|
||||
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"`
|
||||
}
|
||||
|
||||
// SCIM 2.0 Service Provider Config
|
||||
// https://datatracker.ietf.org/doc/html/rfc7643#section-5
|
||||
type ServiceProviderConfig struct {
|
||||
Schemas []string `json:"schemas"`
|
||||
DocumentationUri string `json:"documentationUri,omitempty"`
|
||||
Patch Supported `json:"patch"`
|
||||
Bulk BulkSupported `json:"bulk"`
|
||||
Filter FilterSupported `json:"filter"`
|
||||
ChangePassword Supported `json:"changePassword"`
|
||||
Sort Supported `json:"sort"`
|
||||
Etag Supported `json:"etag"`
|
||||
AuthenticationSchemes []AuthScheme `json:"authenticationSchemes"`
|
||||
Meta *Meta `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
type Supported struct {
|
||||
Supported bool `json:"supported"`
|
||||
}
|
||||
|
||||
type BulkSupported struct {
|
||||
Supported bool `json:"supported"`
|
||||
MaxOperations int `json:"maxOperations"`
|
||||
MaxPayloadSize int `json:"maxPayloadSize"`
|
||||
}
|
||||
|
||||
type FilterSupported struct {
|
||||
Supported bool `json:"supported"`
|
||||
MaxResults int `json:"maxResults"`
|
||||
}
|
||||
|
||||
type AuthScheme struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
SpecUri string `json:"specUri,omitempty"`
|
||||
DocumentationUri string `json:"documentationUri,omitempty"`
|
||||
Primary bool `json:"primary,omitempty"`
|
||||
}
|
||||
|
||||
// SCIM 2.0 Schemas response
|
||||
type SchemasResponse struct {
|
||||
Schemas []string `json:"schemas"`
|
||||
TotalResults int `json:"totalResults"`
|
||||
Resources []Schema `json:"Resources"`
|
||||
}
|
||||
|
||||
type Schema struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Attributes []SchemaAttribute `json:"attributes,omitempty"`
|
||||
Meta *Meta `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
type SchemaAttribute struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
MultiValued bool `json:"multiValued"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Required bool `json:"required"`
|
||||
CaseExact bool `json:"caseExact,omitempty"`
|
||||
Mutability string `json:"mutability,omitempty"`
|
||||
Returned string `json:"returned,omitempty"`
|
||||
Uniqueness string `json:"uniqueness,omitempty"`
|
||||
SubAttributes []SchemaAttribute `json:"subAttributes,omitempty"`
|
||||
}
|
||||
|
||||
// SCIM Schema URIs
|
||||
const (
|
||||
SchemaURIUser = "urn:ietf:params:scim:schemas:core:2.0:User"
|
||||
SchemaURIListResponse = "urn:ietf:params:scim:api:messages:2.0:ListResponse"
|
||||
SchemaURIError = "urn:ietf:params:scim:api:messages:2.0:Error"
|
||||
SchemaURIPatchOp = "urn:ietf:params:scim:api:messages:2.0:PatchOp"
|
||||
SchemaURIServiceProviderConfig = "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"
|
||||
SchemaURISchema = "urn:ietf:params:scim:schemas:core:2.0:Schema"
|
||||
)
|
||||
|
||||
func NewUser() *User {
|
||||
return &User{
|
||||
Schemas: []string{SchemaURIUser},
|
||||
}
|
||||
}
|
||||
|
||||
func NewListResponse(users []User, totalResults int) *ListResponse {
|
||||
return &ListResponse{
|
||||
Schemas: []string{SchemaURIListResponse},
|
||||
TotalResults: totalResults,
|
||||
StartIndex: 1,
|
||||
ItemsPerPage: len(users),
|
||||
Resources: users,
|
||||
}
|
||||
}
|
||||
|
||||
func NewErrorResponse(status int, detail string, scimType string) *ErrorResponse {
|
||||
return &ErrorResponse{
|
||||
Schemas: []string{SchemaURIError},
|
||||
Detail: detail,
|
||||
Status: fmt.Sprintf("%d", status),
|
||||
ScimType: scimType,
|
||||
}
|
||||
}
|
||||
|
||||
func (u *User) GetPrimaryEmail() string {
|
||||
for _, email := range u.Emails {
|
||||
if email.Primary {
|
||||
return email.Value
|
||||
}
|
||||
}
|
||||
if len(u.Emails) > 0 {
|
||||
return u.Emails[0].Value
|
||||
}
|
||||
return u.UserName
|
||||
}
|
||||
|
||||
func (u *User) GetFullName() string {
|
||||
if u.DisplayName != "" {
|
||||
return u.DisplayName
|
||||
}
|
||||
if u.Name != nil {
|
||||
if u.Name.Formatted != "" {
|
||||
return u.Name.Formatted
|
||||
}
|
||||
parts := []string{}
|
||||
if u.Name.GivenName != "" {
|
||||
parts = append(parts, u.Name.GivenName)
|
||||
}
|
||||
if u.Name.FamilyName != "" {
|
||||
parts = append(parts, u.Name.FamilyName)
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
return join(parts, " ")
|
||||
}
|
||||
}
|
||||
return u.UserName
|
||||
}
|
||||
|
||||
func join(parts []string, sep string) string {
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
result := parts[0]
|
||||
for i := 1; i < len(parts); i++ {
|
||||
result += sep + parts[i]
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"go.probo.inc/probo/pkg/filemanager"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/iam/saml"
|
||||
"go.probo.inc/probo/pkg/iam/scim"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
@@ -41,6 +42,7 @@ type (
|
||||
SessionService *SessionService
|
||||
AuthService *AuthService
|
||||
SAMLService *saml.Service
|
||||
SCIMService *scim.Service
|
||||
APIKeyService *APIKeyService
|
||||
Authorizer *Authorizer
|
||||
|
||||
@@ -119,6 +121,8 @@ func NewService(
|
||||
}
|
||||
svc.SAMLService = samlService
|
||||
|
||||
svc.SCIMService = scim.NewService(svc.pg, cfg.Logger.Named("scim"))
|
||||
|
||||
svc.samlDomainVerifier = NewSAMLDomainVerifier(
|
||||
pgClient,
|
||||
cfg.Logger,
|
||||
|
||||
@@ -179,11 +179,11 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
router.Use(cors.Handler(corsOpts))
|
||||
|
||||
router.Mount("/console/v1", s.consoleHandler)
|
||||
router.Mount("/connect/v1", s.connectHandler)
|
||||
router.Mount("/trust/v1", s.compliancePageHandler)
|
||||
router.Mount("/mcp/v1", s.mcpHandler)
|
||||
router.Mount("/slack/v1", s.slackHandler)
|
||||
router.Mount("/console/v1", http.StripPrefix("/console/v1", s.consoleHandler))
|
||||
router.Mount("/connect/v1", http.StripPrefix("/connect/v1", s.connectHandler))
|
||||
router.Mount("/trust/v1", http.StripPrefix("/trust/v1", s.compliancePageHandler))
|
||||
router.Mount("/mcp/v1", http.StripPrefix("/mcp/v1", s.mcpHandler))
|
||||
router.Mount("/slack/v1", http.StripPrefix("/slack/v1", s.slackHandler))
|
||||
|
||||
router.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ package connect_v1
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/99designs/gqlgen/graphql"
|
||||
@@ -63,6 +64,7 @@ func NewMux(logger *log.Logger, svc *iam.Service, cookieConfig securecookie.Conf
|
||||
apiKeyMiddleware := NewAPIKeyMiddleware(svc, tokenSecret)
|
||||
graphqlHandler := NewGraphQLHandler(svc, logger, baseURL, cookieConfig)
|
||||
samlHandler := NewSAMLHandler(svc, cookieConfig, baseURL, logger)
|
||||
scimHandler := NewSCIMHandler(svc, logger.Named("scim"))
|
||||
|
||||
router := r.With(sessionMiddleware, apiKeyMiddleware)
|
||||
|
||||
@@ -71,6 +73,10 @@ func NewMux(logger *log.Logger, svc *iam.Service, cookieConfig securecookie.Conf
|
||||
router.Post("/saml/2.0/consume", samlHandler.ConsumeHandler)
|
||||
router.Get("/saml/2.0/{samlConfigID}", samlHandler.LoginHandler)
|
||||
|
||||
// SCIM 2.0 endpoints - these use their own bearer token authentication
|
||||
scimServer := NewSCIMServer(scimHandler)
|
||||
r.Mount("/scim/2.0", http.StripPrefix("/scim/2.0", scimHandler.BearerTokenMiddleware(scimServer)))
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
|
||||
301
pkg/server/api/connect/v1/scim_handler.go
Normal file
301
pkg/server/api/connect/v1/scim_handler.go
Normal file
@@ -0,0 +1,301 @@
|
||||
// Copyright (c) 2025 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"
|
||||
"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"
|
||||
)
|
||||
|
||||
type SCIMHandler struct {
|
||||
iam *iam.Service
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
func NewSCIMHandler(iam *iam.Service, logger *log.Logger) *SCIMHandler {
|
||||
return &SCIMHandler{iam: iam, logger: logger}
|
||||
}
|
||||
|
||||
// Context key for SCIM configuration
|
||||
type scimCtxKey struct{ name string }
|
||||
|
||||
var scimConfigCtxKey = &scimCtxKey{name: "scim_config"}
|
||||
|
||||
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 {
|
||||
resourceTypes := []scim.ResourceType{
|
||||
{
|
||||
ID: optional.NewString("User"),
|
||||
Name: "User",
|
||||
Endpoint: "/Users",
|
||||
Description: optional.NewString("User Account"),
|
||||
Schema: scimservice.UserSchema(),
|
||||
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))
|
||||
})
|
||||
}
|
||||
|
||||
// scimResourceHandler implements the elimity-com/scim ResourceHandler interface
|
||||
type scimResourceHandler struct {
|
||||
handler *SCIMHandler
|
||||
}
|
||||
|
||||
func (h *scimResourceHandler) Create(r *http.Request, attributes scim.ResourceAttributes) (scim.Resource, error) {
|
||||
ctx := r.Context()
|
||||
config := scimConfigFromContext(ctx)
|
||||
|
||||
membership, err := h.handler.iam.SCIMService.CreateUser(ctx, config, attributes, getIPAddress(r))
|
||||
if err != nil {
|
||||
var invalidReq *scimservice.ErrSCIMInvalidRequest
|
||||
var alreadyExists *scimservice.ErrSCIMUserAlreadyExists
|
||||
|
||||
if errors.As(err, &invalidReq) {
|
||||
return scim.Resource{}, scimerrors.ScimErrorBadRequest(invalidReq.Detail)
|
||||
}
|
||||
|
||||
if errors.As(err, &alreadyExists) {
|
||||
return scim.Resource{}, scimerrors.ScimErrorUniqueness
|
||||
}
|
||||
|
||||
h.handler.logger.ErrorCtx(ctx, "cannot create user", log.Error(err))
|
||||
return scim.Resource{}, scimerrors.ScimErrorInternal
|
||||
}
|
||||
|
||||
return scimservice.MembershipToResource(membership), nil
|
||||
}
|
||||
|
||||
func (h *scimResourceHandler) Get(r *http.Request, id string) (scim.Resource, error) {
|
||||
ctx := r.Context()
|
||||
config := scimConfigFromContext(ctx)
|
||||
|
||||
membershipID, err := gid.ParseGID(id)
|
||||
if err != nil {
|
||||
return scim.Resource{}, scimerrors.ScimErrorResourceNotFound(id)
|
||||
}
|
||||
|
||||
membership, identity, profile, err := h.handler.iam.SCIMService.GetUser(ctx, config, membershipID, getIPAddress(r))
|
||||
if err != nil {
|
||||
var notFound *scimservice.ErrSCIMUserNotFound
|
||||
if errors.As(err, ¬Found) {
|
||||
return scim.Resource{}, scimerrors.ScimErrorResourceNotFound(id)
|
||||
}
|
||||
|
||||
h.handler.logger.ErrorCtx(ctx, "cannot get user", log.Error(err))
|
||||
return scim.Resource{}, scimerrors.ScimErrorInternal
|
||||
}
|
||||
|
||||
return scimservice.MembershipToResourceFull(membership, identity, profile), nil
|
||||
}
|
||||
|
||||
func (h *scimResourceHandler) GetAll(r *http.Request, params scim.ListRequestParams) (scim.Page, error) {
|
||||
ctx := r.Context()
|
||||
config := scimConfigFromContext(ctx)
|
||||
|
||||
if err := params.FilterValidator.Validate(); err != nil {
|
||||
return scim.Page{}, scimerrors.ScimErrorBadRequest(err.Error())
|
||||
}
|
||||
|
||||
// Parse SCIM filter AST into our filter type
|
||||
filter, err := scimservice.ParseUserFilter(params.FilterValidator.GetFilter())
|
||||
if err != nil {
|
||||
var unsupportedFilter *scimservice.ErrUnsupportedFilter
|
||||
if errors.As(err, &unsupportedFilter) {
|
||||
return scim.Page{}, scimerrors.ScimErrorBadRequest(err.Error())
|
||||
}
|
||||
h.handler.logger.ErrorCtx(ctx, "cannot parse filter", log.Error(err))
|
||||
return scim.Page{}, scimerrors.ScimErrorInternal
|
||||
}
|
||||
|
||||
memberships, totalCount, err := h.handler.iam.SCIMService.ListUsers(ctx, config, filter, params.StartIndex, params.Count, getIPAddress(r))
|
||||
if err != nil {
|
||||
h.handler.logger.ErrorCtx(ctx, "cannot list users", log.Error(err))
|
||||
return scim.Page{}, scimerrors.ScimErrorInternal
|
||||
}
|
||||
|
||||
resources := make([]scim.Resource, 0, len(memberships))
|
||||
for _, m := range memberships {
|
||||
resources = append(resources, scimservice.MembershipToResource(m))
|
||||
}
|
||||
|
||||
return scim.Page{
|
||||
TotalResults: totalCount,
|
||||
Resources: resources,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *scimResourceHandler) Replace(r *http.Request, id string, attributes scim.ResourceAttributes) (scim.Resource, error) {
|
||||
ctx := r.Context()
|
||||
config := scimConfigFromContext(ctx)
|
||||
|
||||
membershipID, err := gid.ParseGID(id)
|
||||
if err != nil {
|
||||
return scim.Resource{}, scimerrors.ScimErrorResourceNotFound(id)
|
||||
}
|
||||
|
||||
membership, deactivated, err := h.handler.iam.SCIMService.ReplaceUser(ctx, config, membershipID, attributes, getIPAddress(r))
|
||||
if err != nil {
|
||||
var notFound *scimservice.ErrSCIMUserNotFound
|
||||
if errors.As(err, ¬Found) {
|
||||
return scim.Resource{}, scimerrors.ScimErrorResourceNotFound(id)
|
||||
}
|
||||
|
||||
h.handler.logger.ErrorCtx(ctx, "cannot update user", log.Error(err))
|
||||
return scim.Resource{}, scimerrors.ScimErrorInternal
|
||||
}
|
||||
|
||||
if deactivated {
|
||||
return scimservice.MembershipToResourceWithActive(membership, false), nil
|
||||
}
|
||||
|
||||
return scimservice.MembershipToResource(membership), nil
|
||||
}
|
||||
|
||||
func (h *scimResourceHandler) Patch(r *http.Request, id string, operations []scim.PatchOperation) (scim.Resource, error) {
|
||||
ctx := r.Context()
|
||||
config := scimConfigFromContext(ctx)
|
||||
|
||||
membershipID, err := gid.ParseGID(id)
|
||||
if err != nil {
|
||||
return scim.Resource{}, scimerrors.ScimErrorResourceNotFound(id)
|
||||
}
|
||||
|
||||
membership, deactivated, err := h.handler.iam.SCIMService.PatchUser(ctx, config, membershipID, operations, getIPAddress(r))
|
||||
if err != nil {
|
||||
var notFound *scimservice.ErrSCIMUserNotFound
|
||||
if errors.As(err, ¬Found) {
|
||||
return scim.Resource{}, scimerrors.ScimErrorResourceNotFound(id)
|
||||
}
|
||||
|
||||
h.handler.logger.ErrorCtx(ctx, "cannot patch user", log.Error(err))
|
||||
return scim.Resource{}, scimerrors.ScimErrorInternal
|
||||
}
|
||||
|
||||
if deactivated {
|
||||
return scimservice.MembershipToResourceWithActive(membership, false), nil
|
||||
}
|
||||
|
||||
return scimservice.MembershipToResource(membership), nil
|
||||
}
|
||||
|
||||
func (h *scimResourceHandler) Delete(r *http.Request, id string) error {
|
||||
ctx := r.Context()
|
||||
config := scimConfigFromContext(ctx)
|
||||
|
||||
membershipID, err := gid.ParseGID(id)
|
||||
if err != nil {
|
||||
return scimerrors.ScimErrorResourceNotFound(id)
|
||||
}
|
||||
|
||||
err = h.handler.iam.SCIMService.DeleteUser(ctx, config, membershipID, getIPAddress(r))
|
||||
if err != nil {
|
||||
var notFound *scimservice.ErrSCIMUserNotFound
|
||||
if errors.As(err, ¬Found) {
|
||||
return scimerrors.ScimErrorResourceNotFound(id)
|
||||
}
|
||||
|
||||
h.handler.logger.ErrorCtx(ctx, "cannot delete user", log.Error(err))
|
||||
return scimerrors.ScimErrorInternal
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getIPAddress(r *http.Request) net.IP {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return ip
|
||||
}
|
||||
|
||||
return net.IPv4(127, 0, 0, 1)
|
||||
}
|
||||
@@ -112,7 +112,7 @@ func NewServer(cfg Config) (*Server, error) {
|
||||
}
|
||||
|
||||
func (s *Server) setupRoutes() {
|
||||
s.router.Mount("/api", s.apiServer)
|
||||
s.router.Mount("/api", http.StripPrefix("/api", s.apiServer))
|
||||
|
||||
s.router.Route("/trust/{slugOrId}", func(r chi.Router) {
|
||||
r.Use(s.loadTrustCenterBySlugOrID)
|
||||
|
||||
Reference in New Issue
Block a user