feat: add scim v2 service provider configuration endpoints (#9258)

# Which Problems Are Solved
* Adds support for the service provider configuration SCIM v2 endpoints

# How the Problems Are Solved
* Adds support for the service provider configuration SCIM v2 endpoints
  * `GET /scim/v2/{orgId}/ServiceProviderConfig`
  * `GET /scim/v2/{orgId}/ResourceTypes`
  * `GET /scim/v2/{orgId}/ResourceTypes/{name}`
  * `GET /scim/v2/{orgId}/Schemas`
  * `GET /scim/v2/{orgId}/Schemas/{id}`

# Additional Context
Part of #8140

Co-authored-by: Stefan Benz <46600784+stebenz@users.noreply.github.com>
This commit is contained in:
Lars
2025-01-29 19:11:12 +01:00
committed by GitHub
parent b6841251b1
commit e15094cdea
21 changed files with 2073 additions and 116 deletions

View File

@@ -0,0 +1,183 @@
package schemas
import (
"fmt"
"reflect"
"slices"
"strings"
"time"
"golang.org/x/text/language"
)
type SchemaBuilderArgs struct {
ID ScimSchemaType
Name ScimResourceTypeSingular
EndpointName ScimResourceTypePlural
Description string
Resource any
}
type fieldSchemaInfo struct {
Ignore bool
Required bool
CaseExact bool
Unique bool
}
var (
timeType = reflect.TypeOf(time.Time{})
languageTagType = reflect.TypeOf(language.Tag{})
httpURLType = reflect.TypeOf(HttpURL{})
writeOnlyStringType = reflect.TypeOf(WriteOnlyString(""))
)
func BuildSchema(args SchemaBuilderArgs) *ResourceSchema {
return &ResourceSchema{
Resource: &Resource{
Schemas: []ScimSchemaType{IdSchema},
ID: string(args.ID),
Meta: &ResourceMeta{
ResourceType: SchemaResourceType,
},
},
ID: args.ID,
Name: args.Name,
PluralName: args.EndpointName,
Description: args.Description,
Attributes: buildSchemaAttributes(reflect.TypeOf(args.Resource)),
}
}
func buildSchemaAttributes(fieldType reflect.Type) []*SchemaAttribute {
if fieldType.Kind() == reflect.Ptr {
fieldType = fieldType.Elem()
}
if fieldType.Kind() != reflect.Struct {
return nil
}
attributes := make([]*SchemaAttribute, 0, fieldType.NumField())
for i := 0; i < fieldType.NumField(); i++ {
field := fieldType.Field(i)
attribute := buildAttribute(field)
if attribute != nil {
attributes = append(attributes, attribute)
}
}
return attributes
}
func buildAttribute(field reflect.StructField) *SchemaAttribute {
info := getFieldSchemaInfo(field)
if info.Ignore {
return nil
}
fieldType := getFieldType(field)
attribute := &SchemaAttribute{
Name: getFieldJsonName(field),
Description: "For details see RFC7643",
Type: getFieldAttributeType(fieldType),
MultiValued: isFieldMultiValued(field),
Required: info.Required,
CaseExact: info.CaseExact,
Mutability: SchemaAttributeMutabilityReadWrite,
Returned: SchemaAttributeReturnedAlways,
Uniqueness: SchemaAttributeUniquenessNone,
}
if attribute.Type == SchemaAttributeTypeComplex {
attribute.SubAttributes = buildSchemaAttributes(fieldType)
}
if fieldType == writeOnlyStringType {
attribute.Returned = SchemaAttributeReturnedNever
attribute.Mutability = SchemaAttributeMutabilityWriteOnly
}
if info.Unique {
attribute.Uniqueness = SchemaAttributeUniquenessServer
}
return attribute
}
func isFieldMultiValued(field reflect.StructField) bool {
if field.Type.Kind() != reflect.Ptr {
return field.Type.Kind() == reflect.Slice
}
return field.Type.Elem().Kind() == reflect.Slice
}
func getFieldAttributeType(fieldType reflect.Type) SchemaAttributeType {
switch fieldType.Kind() { //nolint:exhaustive
case reflect.String:
return SchemaAttributeTypeString
case reflect.Bool:
return SchemaAttributeTypeBoolean
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return SchemaAttributeTypeInteger
case reflect.Float32, reflect.Float64:
return SchemaAttributeTypeDecimal
case reflect.Struct:
switch fieldType {
case timeType:
return SchemaAttributeTypeDateTime
case writeOnlyStringType, languageTagType, httpURLType:
return SchemaAttributeTypeString
default:
return SchemaAttributeTypeComplex
}
default:
panic(fmt.Sprintf("unsupported field type: %v", fieldType.Kind()))
}
}
func getFieldType(field reflect.StructField) reflect.Type {
fieldType := field.Type
if fieldType.Kind() == reflect.Ptr {
fieldType = fieldType.Elem()
}
if fieldType.Kind() == reflect.Slice || fieldType.Kind() == reflect.Array {
fieldType = fieldType.Elem()
if fieldType.Kind() == reflect.Ptr {
fieldType = fieldType.Elem()
}
}
return fieldType
}
func getFieldSchemaInfo(field reflect.StructField) *fieldSchemaInfo {
tag := field.Tag.Get("scim")
tagOptions := strings.Split(tag, ",")
return &fieldSchemaInfo{
Ignore: slices.Contains(tagOptions, "ignoreInSchema"),
Required: slices.Contains(tagOptions, "required"),
CaseExact: !slices.Contains(tagOptions, "caseInsensitive"),
Unique: slices.Contains(tagOptions, "unique"),
}
}
func getFieldJsonName(field reflect.StructField) string {
jsonTag := field.Tag.Get("json")
// Skip fields explicitly excluded
if jsonTag == "-" {
return ""
}
// use field name as default
if jsonTag == "" {
return field.Name
}
// strip other options such as omitempty
return strings.Split(jsonTag, ",")[0]
}

View File

@@ -1,5 +1,14 @@
package schemas
import (
"context"
"path"
"time"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/http"
)
type ScimSchemaType string
type ScimResourceTypeSingular string
type ScimResourceTypePlural string
@@ -9,17 +18,147 @@ const (
idPrefixCore = "urn:ietf:params:scim:schemas:core:2.0:"
idPrefixZitadelMessages = "urn:ietf:params:scim:api:zitadel:messages:2.0:"
IdUser ScimSchemaType = idPrefixCore + "User"
IdListResponse ScimSchemaType = idPrefixMessages + "ListResponse"
IdPatchOperation ScimSchemaType = idPrefixMessages + "PatchOp"
IdSearchRequest ScimSchemaType = idPrefixMessages + "SearchRequest"
IdBulkRequest ScimSchemaType = idPrefixMessages + "BulkRequest"
IdBulkResponse ScimSchemaType = idPrefixMessages + "BulkResponse"
IdError ScimSchemaType = idPrefixMessages + "Error"
IdZitadelErrorDetail ScimSchemaType = idPrefixZitadelMessages + "ErrorDetail"
IdUser ScimSchemaType = idPrefixCore + "User"
IdServiceProviderConfig ScimSchemaType = idPrefixCore + "ServiceProviderConfig"
IdResourceType ScimSchemaType = idPrefixCore + "ResourceType"
IdSchema ScimSchemaType = idPrefixCore + "Schema"
IdListResponse ScimSchemaType = idPrefixMessages + "ListResponse"
IdPatchOperation ScimSchemaType = idPrefixMessages + "PatchOp"
IdSearchRequest ScimSchemaType = idPrefixMessages + "SearchRequest"
IdBulkRequest ScimSchemaType = idPrefixMessages + "BulkRequest"
IdBulkResponse ScimSchemaType = idPrefixMessages + "BulkResponse"
IdError ScimSchemaType = idPrefixMessages + "Error"
IdZitadelErrorDetail ScimSchemaType = idPrefixZitadelMessages + "ErrorDetail"
UserResourceType ScimResourceTypeSingular = "User"
UsersResourceType ScimResourceTypePlural = "Users"
ServiceProviderConfigResourceType ScimResourceTypeSingular = "ServiceProviderConfig"
ServiceProviderConfigsResourceType ScimResourceTypePlural = "ServiceProviderConfig"
SchemaResourceType ScimResourceTypeSingular = "Schema"
SchemasResourceType ScimResourceTypePlural = "Schemas"
ResourceTypesResourceType ScimResourceTypePlural = "ResourceTypes"
HandlerPrefix = "/scim/v2"
)
type Resource struct {
ID string `json:"-"`
Schemas []ScimSchemaType `json:"schemas"`
Meta *ResourceMeta `json:"meta"`
}
type ResourceMeta struct {
ResourceType ScimResourceTypeSingular `json:"resourceType"`
Created *time.Time `json:"created,omitempty"`
LastModified *time.Time `json:"lastModified,omitempty"`
Version string `json:"version,omitempty"`
Location string `json:"location,omitempty"`
}
type ResourceType struct {
*Resource
ID ScimResourceTypeSingular `json:"id"`
Name ScimResourceTypeSingular `json:"name"`
Endpoint ScimResourceTypePlural `json:"endpoint"`
Schema ScimSchemaType `json:"schema"`
Description string `json:"description"`
}
type ResourceSchema struct {
*Resource
ID ScimSchemaType `json:"id"`
Name ScimResourceTypeSingular `json:"name"`
PluralName ScimResourceTypePlural `json:"-"`
Description string `json:"description,omitempty"`
Attributes []*SchemaAttribute `json:"attributes"`
}
type SchemaAttribute struct {
Name string `json:"name"`
Description string `json:"description"`
Type SchemaAttributeType `json:"type"`
SubAttributes []*SchemaAttribute `json:"subAttributes,omitempty"`
MultiValued bool `json:"multiValued"`
Required bool `json:"required"`
CaseExact bool `json:"caseExact"`
Mutability SchemaAttributeMutability `json:"mutability"`
Returned SchemaAttributeReturned `json:"returned"`
Uniqueness SchemaAttributeUniqueness `json:"uniqueness"`
}
type SchemaAttributeType string
const (
SchemaAttributeTypeString SchemaAttributeType = "string"
SchemaAttributeTypeBoolean SchemaAttributeType = "boolean"
SchemaAttributeTypeDecimal SchemaAttributeType = "decimal"
SchemaAttributeTypeInteger SchemaAttributeType = "integer"
SchemaAttributeTypeDateTime SchemaAttributeType = "dateTime"
SchemaAttributeTypeComplex SchemaAttributeType = "complex"
)
type SchemaAttributeMutability string
const (
SchemaAttributeMutabilityReadWrite SchemaAttributeMutability = "readWrite"
SchemaAttributeMutabilityWriteOnly SchemaAttributeMutability = "writeOnly"
)
type SchemaAttributeReturned string
const (
SchemaAttributeReturnedAlways SchemaAttributeReturned = "always"
SchemaAttributeReturnedNever SchemaAttributeReturned = "never"
)
type SchemaAttributeUniqueness string
const (
SchemaAttributeUniquenessNone SchemaAttributeUniqueness = "none"
SchemaAttributeUniquenessServer SchemaAttributeUniqueness = "server"
)
func (s *ResourceType) GetSchemas() []ScimSchemaType {
return s.Resource.Schemas
}
func (s *ResourceType) GetResource() *Resource {
return s.Resource
}
func (s *ResourceSchema) GetSchemas() []ScimSchemaType {
return s.Resource.Schemas
}
func (s *ResourceSchema) GetResource() *Resource {
return s.Resource
}
func (s *ResourceSchema) ToResourceType(ctx context.Context, orgID string) *ResourceType {
return &ResourceType{
Resource: &Resource{
Schemas: []ScimSchemaType{IdResourceType},
ID: string(s.Name),
Meta: &ResourceMeta{
ResourceType: s.Name,
Location: BuildLocationWithOrg(ctx, orgID, ResourceTypesResourceType, string(s.Name)),
},
},
ID: s.Name,
Name: s.Name,
Endpoint: s.PluralName,
Schema: s.ID,
Description: s.Description,
}
}
func BuildLocationForResource(ctx context.Context, resourceName ScimResourceTypePlural, id string) string {
return BuildLocationWithOrg(ctx, authz.GetCtxData(ctx).OrgID, resourceName, id)
}
func BuildLocationWithOrg(ctx context.Context, orgID string, resourceName ScimResourceTypePlural, id string) string {
return http.DomainContext(ctx).Origin() + path.Join(HandlerPrefix, orgID, string(resourceName), id)
}

View File

@@ -7,11 +7,6 @@ import "encoding/json"
// This increases security to really ensure this is never sent to a client.
type WriteOnlyString string
func NewWriteOnlyString(s string) *WriteOnlyString {
wos := WriteOnlyString(s)
return &wos
}
func (s *WriteOnlyString) MarshalJSON() ([]byte, error) {
return []byte("null"), nil
}