mirror of
https://github.com/zitadel/zitadel.git
synced 2025-08-12 01:47:33 +00:00
feat: create user scim v2 endpoint (#9132)
# Which Problems Are Solved - Adds infrastructure code (basic implementation, error handling, middlewares, ...) to implement the SCIM v2 interface - Adds support for the user create SCIM v2 endpoint # How the Problems Are Solved - Adds support for the user create SCIM v2 endpoint under `POST /scim/v2/{orgID}/Users` # Additional Context Part of #8140
This commit is contained in:
61
internal/api/scim/resources/resource_handler.go
Normal file
61
internal/api/scim/resources/resource_handler.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package resources
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/api/http"
|
||||
"github.com/zitadel/zitadel/internal/api/scim/schemas"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
)
|
||||
|
||||
type ResourceHandler[T ResourceHolder] interface {
|
||||
ResourceNameSingular() schemas.ScimResourceTypeSingular
|
||||
ResourceNamePlural() schemas.ScimResourceTypePlural
|
||||
SchemaType() schemas.ScimSchemaType
|
||||
NewResource() T
|
||||
|
||||
Create(ctx context.Context, resource T) (T, error)
|
||||
}
|
||||
|
||||
type Resource struct {
|
||||
Schemas []schemas.ScimSchemaType `json:"schemas"`
|
||||
Meta *ResourceMeta `json:"meta"`
|
||||
}
|
||||
|
||||
type ResourceMeta struct {
|
||||
ResourceType schemas.ScimResourceTypeSingular `json:"resourceType"`
|
||||
Created time.Time `json:"created"`
|
||||
LastModified time.Time `json:"lastModified"`
|
||||
Version string `json:"version"`
|
||||
Location string `json:"location"`
|
||||
}
|
||||
|
||||
type ResourceHolder interface {
|
||||
GetResource() *Resource
|
||||
}
|
||||
|
||||
func buildResource[T ResourceHolder](ctx context.Context, handler ResourceHandler[T], details *domain.ObjectDetails) *Resource {
|
||||
created := details.CreationDate.UTC()
|
||||
if created.IsZero() {
|
||||
created = details.EventDate.UTC()
|
||||
}
|
||||
|
||||
return &Resource{
|
||||
Schemas: []schemas.ScimSchemaType{handler.SchemaType()},
|
||||
Meta: &ResourceMeta{
|
||||
ResourceType: handler.ResourceNameSingular(),
|
||||
Created: created,
|
||||
LastModified: details.EventDate.UTC(),
|
||||
Version: strconv.FormatUint(details.Sequence, 10),
|
||||
Location: buildLocation(ctx, handler, details.ID),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func buildLocation[T ResourceHolder](ctx context.Context, handler ResourceHandler[T], id string) string {
|
||||
return http.DomainContext(ctx).Origin() + path.Join(schemas.HandlerPrefix, authz.GetCtxData(ctx).OrgID, string(handler.ResourceNamePlural()), id)
|
||||
}
|
69
internal/api/scim/resources/resource_handler_adapter.go
Normal file
69
internal/api/scim/resources/resource_handler_adapter.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package resources
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/scim/schemas"
|
||||
"github.com/zitadel/zitadel/internal/api/scim/serrors"
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
)
|
||||
|
||||
type ResourceHandlerAdapter[T ResourceHolder] struct {
|
||||
handler ResourceHandler[T]
|
||||
}
|
||||
|
||||
type ListRequest struct {
|
||||
// Count An integer indicating the desired maximum number of query results per page. OPTIONAL.
|
||||
Count uint64 `json:"count" schema:"count"`
|
||||
|
||||
// StartIndex An integer indicating the 1-based index of the first query result. Optional.
|
||||
StartIndex uint64 `json:"startIndex" schema:"startIndex"`
|
||||
}
|
||||
|
||||
type ListResponse[T any] struct {
|
||||
Schemas []schemas.ScimSchemaType `json:"schemas"`
|
||||
ItemsPerPage uint64 `json:"itemsPerPage"`
|
||||
TotalResults uint64 `json:"totalResults"`
|
||||
StartIndex uint64 `json:"startIndex"`
|
||||
Resources []T `json:"Resources"` // according to the rfc this is the only field in PascalCase...
|
||||
}
|
||||
|
||||
func NewResourceHandlerAdapter[T ResourceHolder](handler ResourceHandler[T]) *ResourceHandlerAdapter[T] {
|
||||
return &ResourceHandlerAdapter[T]{
|
||||
handler,
|
||||
}
|
||||
}
|
||||
|
||||
func (adapter *ResourceHandlerAdapter[T]) Create(r *http.Request) (T, error) {
|
||||
entity, err := adapter.readEntityFromBody(r)
|
||||
if err != nil {
|
||||
return entity, err
|
||||
}
|
||||
|
||||
return adapter.handler.Create(r.Context(), entity)
|
||||
}
|
||||
|
||||
func (adapter *ResourceHandlerAdapter[T]) readEntityFromBody(r *http.Request) (T, error) {
|
||||
entity := adapter.handler.NewResource()
|
||||
err := json.NewDecoder(r.Body).Decode(entity)
|
||||
if err != nil {
|
||||
if zerrors.IsZitadelError(err) {
|
||||
return entity, err
|
||||
}
|
||||
|
||||
return entity, serrors.ThrowInvalidSyntax(zerrors.ThrowInvalidArgumentf(nil, "SCIM-ucrjson", "Could not deserialize json: %v", err.Error()))
|
||||
}
|
||||
|
||||
resource := entity.GetResource()
|
||||
if resource == nil {
|
||||
return entity, serrors.ThrowInvalidSyntax(zerrors.ThrowInvalidArgument(nil, "SCIM-xxrjson", "Could not get resource, is the schema correct?"))
|
||||
}
|
||||
|
||||
if !slices.Contains(resource.Schemas, adapter.handler.SchemaType()) {
|
||||
return entity, serrors.ThrowInvalidSyntax(zerrors.ThrowInvalidArgumentf(nil, "SCIM-xxrschema", "Expected schema %v is not provided", adapter.handler.SchemaType()))
|
||||
}
|
||||
|
||||
return entity, nil
|
||||
}
|
146
internal/api/scim/resources/user.go
Normal file
146
internal/api/scim/resources/user.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package resources
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
scim_config "github.com/zitadel/zitadel/internal/api/scim/config"
|
||||
schemas2 "github.com/zitadel/zitadel/internal/api/scim/schemas"
|
||||
"github.com/zitadel/zitadel/internal/command"
|
||||
"github.com/zitadel/zitadel/internal/crypto"
|
||||
"github.com/zitadel/zitadel/internal/query"
|
||||
)
|
||||
|
||||
type UsersHandler struct {
|
||||
command *command.Commands
|
||||
query *query.Queries
|
||||
userCodeAlg crypto.EncryptionAlgorithm
|
||||
config *scim_config.Config
|
||||
}
|
||||
|
||||
type ScimUser struct {
|
||||
*Resource
|
||||
ID string `json:"id"`
|
||||
ExternalID string `json:"externalId,omitempty"`
|
||||
UserName string `json:"userName,omitempty"`
|
||||
Name *ScimUserName `json:"name,omitempty"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
NickName string `json:"nickName,omitempty"`
|
||||
ProfileUrl *schemas2.HttpURL `json:"profileUrl,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
PreferredLanguage language.Tag `json:"preferredLanguage,omitempty"`
|
||||
Locale string `json:"locale,omitempty"`
|
||||
Timezone string `json:"timezone,omitempty"`
|
||||
Active bool `json:"active,omitempty"`
|
||||
Emails []*ScimEmail `json:"emails,omitempty"`
|
||||
PhoneNumbers []*ScimPhoneNumber `json:"phoneNumbers,omitempty"`
|
||||
Password *schemas2.WriteOnlyString `json:"password,omitempty"`
|
||||
Ims []*ScimIms `json:"ims,omitempty"`
|
||||
Addresses []*ScimAddress `json:"addresses,omitempty"`
|
||||
Photos []*ScimPhoto `json:"photos,omitempty"`
|
||||
Entitlements []*ScimEntitlement `json:"entitlements,omitempty"`
|
||||
Roles []*ScimRole `json:"roles,omitempty"`
|
||||
}
|
||||
|
||||
type ScimEntitlement struct {
|
||||
Value string `json:"value,omitempty"`
|
||||
Display string `json:"display,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Primary bool `json:"primary,omitempty"`
|
||||
}
|
||||
|
||||
type ScimRole struct {
|
||||
Value string `json:"value,omitempty"`
|
||||
Display string `json:"display,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Primary bool `json:"primary,omitempty"`
|
||||
}
|
||||
|
||||
type ScimPhoto struct {
|
||||
Value schemas2.HttpURL `json:"value"`
|
||||
Display string `json:"display,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Primary bool `json:"primary,omitempty"`
|
||||
}
|
||||
|
||||
type ScimAddress struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
StreetAddress string `json:"streetAddress,omitempty"`
|
||||
Locality string `json:"locality,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
PostalCode string `json:"postalCode,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
Formatted string `json:"formatted,omitempty"`
|
||||
Primary bool `json:"primary,omitempty"`
|
||||
}
|
||||
|
||||
type ScimIms struct {
|
||||
Value string `json:"value"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type ScimEmail struct {
|
||||
Value string `json:"value"`
|
||||
Primary bool `json:"primary"`
|
||||
}
|
||||
|
||||
type ScimPhoneNumber struct {
|
||||
Value string `json:"value"`
|
||||
Primary bool `json:"primary"`
|
||||
}
|
||||
|
||||
type ScimUserName 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"`
|
||||
}
|
||||
|
||||
func NewUsersHandler(
|
||||
command *command.Commands,
|
||||
query *query.Queries,
|
||||
userCodeAlg crypto.EncryptionAlgorithm,
|
||||
config *scim_config.Config) ResourceHandler[*ScimUser] {
|
||||
return &UsersHandler{command, query, userCodeAlg, config}
|
||||
}
|
||||
|
||||
func (h *UsersHandler) ResourceNameSingular() schemas2.ScimResourceTypeSingular {
|
||||
return schemas2.UserResourceType
|
||||
}
|
||||
|
||||
func (h *UsersHandler) ResourceNamePlural() schemas2.ScimResourceTypePlural {
|
||||
return schemas2.UsersResourceType
|
||||
}
|
||||
|
||||
func (u *ScimUser) GetResource() *Resource {
|
||||
return u.Resource
|
||||
}
|
||||
|
||||
func (h *UsersHandler) NewResource() *ScimUser {
|
||||
return new(ScimUser)
|
||||
}
|
||||
|
||||
func (h *UsersHandler) SchemaType() schemas2.ScimSchemaType {
|
||||
return schemas2.IdUser
|
||||
}
|
||||
|
||||
func (h *UsersHandler) Create(ctx context.Context, user *ScimUser) (*ScimUser, error) {
|
||||
orgID := authz.GetCtxData(ctx).OrgID
|
||||
addHuman, err := h.mapToAddHuman(ctx, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = h.command.AddUserHuman(ctx, orgID, addHuman, true, h.userCodeAlg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user.ID = addHuman.Details.ID
|
||||
user.Resource = buildResource(ctx, h, addHuman.Details)
|
||||
return user, err
|
||||
}
|
81
internal/api/scim/resources/user_mapping.go
Normal file
81
internal/api/scim/resources/user_mapping.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package resources
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/command"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
)
|
||||
|
||||
func (h *UsersHandler) mapToAddHuman(ctx context.Context, scimUser *ScimUser) (*command.AddHuman, error) {
|
||||
// zitadel has its own state mechanism
|
||||
// ignore scimUser.Active
|
||||
human := &command.AddHuman{
|
||||
Username: scimUser.UserName,
|
||||
NickName: scimUser.NickName,
|
||||
DisplayName: scimUser.DisplayName,
|
||||
Email: h.mapPrimaryEmail(scimUser),
|
||||
Phone: h.mapPrimaryPhone(scimUser),
|
||||
}
|
||||
|
||||
md, err := h.mapMetadataToCommands(ctx, scimUser)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
human.Metadata = md
|
||||
|
||||
if scimUser.Password != nil {
|
||||
human.Password = scimUser.Password.String()
|
||||
scimUser.Password = nil
|
||||
}
|
||||
|
||||
if scimUser.Name != nil {
|
||||
human.FirstName = scimUser.Name.GivenName
|
||||
human.LastName = scimUser.Name.FamilyName
|
||||
|
||||
// the direct mapping displayName => displayName has priority
|
||||
// over the formatted name assignment
|
||||
if human.DisplayName == "" {
|
||||
human.DisplayName = scimUser.Name.Formatted
|
||||
}
|
||||
}
|
||||
|
||||
if err := domain.LanguageIsDefined(scimUser.PreferredLanguage); err != nil {
|
||||
human.PreferredLanguage = language.English
|
||||
scimUser.PreferredLanguage = language.English
|
||||
}
|
||||
|
||||
return human, nil
|
||||
}
|
||||
|
||||
func (h *UsersHandler) mapPrimaryEmail(scimUser *ScimUser) command.Email {
|
||||
for _, email := range scimUser.Emails {
|
||||
if !email.Primary {
|
||||
continue
|
||||
}
|
||||
|
||||
return command.Email{
|
||||
Address: domain.EmailAddress(email.Value),
|
||||
Verified: h.config.EmailVerified,
|
||||
}
|
||||
}
|
||||
|
||||
return command.Email{}
|
||||
}
|
||||
|
||||
func (h *UsersHandler) mapPrimaryPhone(scimUser *ScimUser) command.Phone {
|
||||
for _, phone := range scimUser.PhoneNumbers {
|
||||
if !phone.Primary {
|
||||
continue
|
||||
}
|
||||
|
||||
return command.Phone{
|
||||
Number: domain.PhoneNumber(phone.Value),
|
||||
Verified: h.config.PhoneVerified,
|
||||
}
|
||||
}
|
||||
|
||||
return command.Phone{}
|
||||
}
|
150
internal/api/scim/resources/user_metadata.go
Normal file
150
internal/api/scim/resources/user_metadata.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package resources
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/zitadel/logging"
|
||||
"golang.org/x/text/language"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/scim/metadata"
|
||||
"github.com/zitadel/zitadel/internal/api/scim/schemas"
|
||||
"github.com/zitadel/zitadel/internal/api/scim/serrors"
|
||||
"github.com/zitadel/zitadel/internal/command"
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
)
|
||||
|
||||
func (h *UsersHandler) mapMetadataToCommands(ctx context.Context, user *ScimUser) ([]*command.AddMetadataEntry, error) {
|
||||
md := make([]*command.AddMetadataEntry, 0, len(metadata.ScimUserRelevantMetadataKeys))
|
||||
for _, key := range metadata.ScimUserRelevantMetadataKeys {
|
||||
value, err := getValueForMetadataKey(user, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(value) > 0 {
|
||||
md = append(md, &command.AddMetadataEntry{
|
||||
Key: string(metadata.ScopeKey(ctx, key)),
|
||||
Value: value,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return md, nil
|
||||
}
|
||||
|
||||
func getValueForMetadataKey(user *ScimUser, key metadata.Key) ([]byte, error) {
|
||||
value := getRawValueForMetadataKey(user, key)
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
switch key {
|
||||
// json values
|
||||
case metadata.KeyEntitlements:
|
||||
fallthrough
|
||||
case metadata.KeyIms:
|
||||
fallthrough
|
||||
case metadata.KeyPhotos:
|
||||
fallthrough
|
||||
case metadata.KeyAddresses:
|
||||
fallthrough
|
||||
case metadata.KeyRoles:
|
||||
return json.Marshal(value)
|
||||
|
||||
// http url values
|
||||
case metadata.KeyProfileUrl:
|
||||
return []byte(value.(*schemas.HttpURL).String()), nil
|
||||
|
||||
// raw values
|
||||
case metadata.KeyProvisioningDomain:
|
||||
fallthrough
|
||||
case metadata.KeyExternalId:
|
||||
fallthrough
|
||||
case metadata.KeyMiddleName:
|
||||
fallthrough
|
||||
case metadata.KeyHonorificSuffix:
|
||||
fallthrough
|
||||
case metadata.KeyHonorificPrefix:
|
||||
fallthrough
|
||||
case metadata.KeyTitle:
|
||||
fallthrough
|
||||
case metadata.KeyLocale:
|
||||
fallthrough
|
||||
case metadata.KeyTimezone:
|
||||
valueStr := value.(string)
|
||||
if valueStr == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return []byte(valueStr), validateValueForMetadataKey(valueStr, key)
|
||||
}
|
||||
|
||||
logging.Panicf("Unknown metadata key %s", key)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func validateValueForMetadataKey(v string, key metadata.Key) error {
|
||||
//nolint:exhaustive
|
||||
switch key {
|
||||
case metadata.KeyLocale:
|
||||
if _, err := language.Parse(v); err != nil {
|
||||
return serrors.ThrowInvalidValue(zerrors.ThrowInvalidArgument(err, "SCIM-MD11", "Could not parse locale"))
|
||||
}
|
||||
return nil
|
||||
case metadata.KeyTimezone:
|
||||
if _, err := time.LoadLocation(v); err != nil {
|
||||
return serrors.ThrowInvalidValue(zerrors.ThrowInvalidArgument(err, "SCIM-MD12", "Could not parse timezone"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getRawValueForMetadataKey(user *ScimUser, key metadata.Key) interface{} {
|
||||
switch key {
|
||||
case metadata.KeyIms:
|
||||
return user.Ims
|
||||
case metadata.KeyPhotos:
|
||||
return user.Photos
|
||||
case metadata.KeyAddresses:
|
||||
return user.Addresses
|
||||
case metadata.KeyEntitlements:
|
||||
return user.Entitlements
|
||||
case metadata.KeyRoles:
|
||||
return user.Roles
|
||||
case metadata.KeyMiddleName:
|
||||
if user.Name == nil {
|
||||
return ""
|
||||
}
|
||||
return user.Name.MiddleName
|
||||
case metadata.KeyHonorificPrefix:
|
||||
if user.Name == nil {
|
||||
return ""
|
||||
}
|
||||
return user.Name.HonorificPrefix
|
||||
case metadata.KeyHonorificSuffix:
|
||||
if user.Name == nil {
|
||||
return ""
|
||||
}
|
||||
return user.Name.HonorificSuffix
|
||||
case metadata.KeyExternalId:
|
||||
return user.ExternalID
|
||||
case metadata.KeyProfileUrl:
|
||||
return user.ProfileUrl
|
||||
case metadata.KeyTitle:
|
||||
return user.Title
|
||||
case metadata.KeyLocale:
|
||||
return user.Locale
|
||||
case metadata.KeyTimezone:
|
||||
return user.Timezone
|
||||
case metadata.KeyProvisioningDomain:
|
||||
break
|
||||
}
|
||||
|
||||
logging.Panicf("Unknown or unsupported metadata key %s", key)
|
||||
return nil
|
||||
}
|
Reference in New Issue
Block a user