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:
Lars
2025-01-09 12:46:36 +01:00
committed by GitHub
parent 829f4543da
commit e621224ab2
44 changed files with 2412 additions and 48 deletions

View 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)
}

View 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
}

View 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
}

View 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{}
}

View 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
}