feat: patch user scim v2 endpoint (#9219)

# Which Problems Are Solved
* Adds support for the patch user SCIM v2 endpoint

# How the Problems Are Solved
* Adds support for the patch user SCIM v2 endpoint under `PATCH
/scim/v2/{orgID}/Users/{id}`

# Additional Context
Part of #8140
This commit is contained in:
Lars
2025-01-27 13:36:07 +01:00
committed by GitHub
parent ec5f18c168
commit 189f9770c6
31 changed files with 3601 additions and 125 deletions

View File

@@ -0,0 +1,273 @@
package patch
import (
"encoding/json"
"reflect"
"slices"
"strings"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/api/scim/resources/filter"
"github.com/zitadel/zitadel/internal/api/scim/schemas"
"github.com/zitadel/zitadel/internal/api/scim/serrors"
"github.com/zitadel/zitadel/internal/zerrors"
)
type OperationRequest struct {
Schemas []schemas.ScimSchemaType `json:"Schemas"`
Operations []*Operation `json:"Operations"`
}
type Operation struct {
Operation OperationType `json:"op"`
Path *filter.Path `json:"path"`
Value json.RawMessage `json:"value"`
valueIsArray bool
}
type OperationCollection []*Operation
type OperationType string
const (
OperationTypeAdd OperationType = "add"
OperationTypeRemove OperationType = "remove"
OperationTypeReplace OperationType = "replace"
fieldNamePrimary = "Primary"
fieldNameValue = "Value"
)
type ResourcePatcher interface {
FilterEvaluator() *filter.Evaluator
Added(attributePath []string) error
Replaced(attributePath []string) error
Removed(attributePath []string) error
}
func (req *OperationRequest) Validate() error {
if !slices.Contains(req.Schemas, schemas.IdPatchOperation) {
return serrors.ThrowInvalidSyntax(zerrors.ThrowInvalidArgumentf(nil, "SCIM-xy1schema", "Expected schema %v is not provided", schemas.IdPatchOperation))
}
for _, op := range req.Operations {
if err := op.validate(); err != nil {
return err
}
}
return nil
}
func (op *Operation) validate() error {
if !op.Operation.isValid() {
return serrors.ThrowInvalidValue(zerrors.ThrowInvalidArgumentf(nil, "SCIM-opty1", "Patch op %s not supported", op.Operation))
}
// json deserialization initializes this field if an empty string is provided
// to not special case this in the further code,
// set it to nil here.
if op.Path.IsZero() {
op.Path = nil
}
op.valueIsArray = strings.HasPrefix(strings.TrimPrefix(string(op.Value), " "), "[")
return nil
}
func (ops OperationCollection) Apply(patcher ResourcePatcher, value interface{}) error {
for _, op := range ops {
if err := op.validate(); err != nil {
return err
}
if err := op.apply(patcher, value); err != nil {
return err
}
}
return nil
}
func (op *Operation) apply(patcher ResourcePatcher, value interface{}) error {
switch op.Operation {
case OperationTypeRemove:
return applyRemovePatch(patcher, op, value)
case OperationTypeReplace:
return applyReplacePatch(patcher, op, value)
case OperationTypeAdd:
return applyAddPatch(patcher, op, value)
}
return zerrors.ThrowInvalidArgumentf(nil, "SCIM-opty3", "SCIM patch: Invalid operation %v", op.Operation)
}
func (o OperationType) isValid() bool {
switch o {
case OperationTypeAdd, OperationTypeRemove, OperationTypeReplace:
return true
default:
return false
}
}
func flattenAndApplyPatchOperations(patcher ResourcePatcher, op *Operation, value interface{}) error {
ops, err := flattenPatchOperations(op)
if err != nil {
return err
}
for _, flattenedOperation := range ops {
if err = flattenedOperation.apply(patcher, value); err != nil {
return err
}
}
return nil
}
// flattenPatchOperations flattens patch operations without a path
// it converts an op { "operation": "add", "value": { "path1": "value1", "path2": "value2" } }
// into [ { "operation": "add", "path": "path1", "value": "value1" }, { "operation": "add", "path": "path2", "value": "value2" } ]
func flattenPatchOperations(op *Operation) ([]*Operation, error) {
if op.Path != nil {
panic("Only operations without a path can be flattened")
}
patches := map[string]json.RawMessage{}
if err := json.Unmarshal(op.Value, &patches); err != nil {
logging.WithError(err).Error("SCIM: Invalid patch value while flattening")
return nil, zerrors.ThrowInvalidArgument(err, "SCIM-ioyl1", "Invalid patch value")
}
result := make([]*Operation, 0, len(patches))
for path, value := range patches {
result = append(result, &Operation{
Operation: op.Operation,
Path: &filter.Path{
AttrPath: &filter.AttrPath{
AttrName: path,
},
},
Value: value,
valueIsArray: strings.HasPrefix(string(value), "["),
})
}
return result, nil
}
// unmarshalPatchValuesSlice unmarshal the raw json value (a scalar value, object or array) into a new slice
func unmarshalPatchValuesSlice(elementTypePtr reflect.Type, value json.RawMessage, valueIsArray bool) (reflect.Value, error) {
if elementTypePtr.Kind() != reflect.Ptr {
logging.Panicf("elementType must be a pointer to a struct, but is %s", elementTypePtr.Name())
return reflect.Value{}, nil
}
if !valueIsArray {
newElement := reflect.New(elementTypePtr.Elem())
if err := unmarshalPatchValue(value, newElement); err != nil {
return reflect.Value{}, err
}
newSlice := reflect.MakeSlice(reflect.SliceOf(elementTypePtr), 1, 1)
newSlice.Index(0).Set(newElement)
return newSlice, nil
}
newSlicePtr := reflect.New(reflect.SliceOf(elementTypePtr))
newSlice := newSlicePtr.Elem()
if err := json.Unmarshal(value, newSlicePtr.Interface()); err != nil {
logging.WithError(err).Error("SCIM: Invalid patch values")
return reflect.Value{}, zerrors.ThrowInvalidArgument(err, "SCIM-opxx8", "Invalid patch values")
}
return newSlice, nil
}
func unmarshalPatchValue(newValue json.RawMessage, targetElement reflect.Value) error {
if targetElement.Kind() != reflect.Ptr {
targetElement = targetElement.Addr()
}
if targetElement.IsNil() {
targetElement.Set(reflect.New(targetElement.Type().Elem()))
}
if err := json.Unmarshal(newValue, targetElement.Interface()); err != nil {
logging.WithError(err).Error("SCIM: Invalid patch value")
return zerrors.ThrowInvalidArgument(err, "SCIM-opty9", "Invalid patch value")
}
return nil
}
// ensureSinglePrimary ensures the modification on a slice results in max one primary element.
// modifiedSlice contains the patched slice.
// modifiedElementsSlice contains only the modified elements.
// if a new element has Primary set to true and an existing is also Primary, the existing Primary flag is set to false.
// returns an error if multiple modifiedElements have a primary value of true.
func ensureSinglePrimary(modifiedSlice reflect.Value, modifiedElementsSlice []reflect.Value, modifiedElementIndexes map[int]bool) error {
if len(modifiedElementsSlice) == 0 {
return nil
}
hasPrimary, err := isAnyPrimary(modifiedElementsSlice)
if err != nil || !hasPrimary {
return err
}
for i := 0; i < modifiedSlice.Len(); i++ {
if mod, ok := modifiedElementIndexes[i]; ok && mod {
continue
}
sliceElement := modifiedSlice.Index(i)
if sliceElement.Kind() == reflect.Ptr {
sliceElement = sliceElement.Elem()
}
sliceElementPrimaryField := sliceElement.FieldByName(fieldNamePrimary)
if !sliceElementPrimaryField.IsValid() || !sliceElementPrimaryField.Bool() {
continue
}
sliceElementPrimaryField.SetBool(false)
// we can stop at the first primary,
// since there can only be one primary in a slice.
return nil
}
return nil
}
func isAnyPrimary(elements []reflect.Value) (bool, error) {
foundPrimary := false
for _, element := range elements {
if !isPrimary(element) {
continue
}
if foundPrimary {
return true, zerrors.ThrowInvalidArgument(nil, "SCIM-1d23", "Cannot add multiple primary values in one patch operation")
}
foundPrimary = true
}
return foundPrimary, nil
}
func isPrimary(element reflect.Value) bool {
if element.Kind() == reflect.Ptr {
element = element.Elem()
}
if element.Kind() != reflect.Struct {
return false
}
primaryField := element.FieldByName(fieldNamePrimary)
return primaryField.IsValid() && primaryField.Bool()
}

View File

@@ -0,0 +1,112 @@
package patch
import (
"reflect"
"github.com/zitadel/zitadel/internal/api/scim/resources/filter"
"github.com/zitadel/zitadel/internal/api/scim/serrors"
"github.com/zitadel/zitadel/internal/zerrors"
)
func applyAddPatch(patcher ResourcePatcher, op *Operation, value interface{}) error {
if op.Path == nil {
return flattenAndApplyPatchOperations(patcher, op, value)
}
result, err := patcher.FilterEvaluator().Evaluate(reflect.ValueOf(value), op.Path)
if err != nil {
return serrors.ThrowInvalidPath(zerrors.ThrowInvalidArgument(err, "SCIM-opzz8", "Failed to evaluate path"))
}
evaluationResult, ok := result.(*filter.SimpleValueEvaluationResult)
if !ok {
return serrors.ThrowInvalidPath(zerrors.ThrowInvalidArgument(nil, "SCIM-opty8", "Filtered paths are not allowed for add patch operations"))
}
if evaluationResult.Value.Kind() != reflect.Slice {
return applyReplacePatchSimple(patcher, evaluationResult, op.Value, op.valueIsArray)
}
elementType := evaluationResult.Value.Type().Elem()
newElementsSlice, err := unmarshalPatchValuesSlice(elementType, op.Value, op.valueIsArray)
if err != nil {
return err
}
oldLen := evaluationResult.Value.Len()
newSlice := reflect.MakeSlice(reflect.SliceOf(elementType), oldLen, oldLen+newElementsSlice.Len())
// copy over existing values
reflect.Copy(newSlice, evaluationResult.Value)
// according to the RFC only "new" values should be added
// existing values should be replaced
newSlice, modifiedIndexes := addOrReplaceByValue(newSlice, newElementsSlice)
evaluationResult.Value.Set(newSlice)
if err = ensureSinglePrimaryAdded(evaluationResult.Value, newElementsSlice, modifiedIndexes); err != nil {
return err
}
return patcher.Added(evaluationResult.PathSegments)
}
func ensureSinglePrimaryAdded(resultSlice, newSlice reflect.Value, modifiedIndexes map[int]bool) error {
modifiedValues := make([]reflect.Value, newSlice.Len())
for i := 0; i < newSlice.Len(); i++ {
modifiedValues[i] = newSlice.Index(i)
}
return ensureSinglePrimary(resultSlice, modifiedValues, modifiedIndexes)
}
func addOrReplaceByValue(entries, newEntries reflect.Value) (reflect.Value, map[int]bool) {
modifiedIndexes := make(map[int]bool, newEntries.Len())
if entries.Len() == 0 {
for i := 0; i < newEntries.Len(); i++ {
modifiedIndexes[i] = true
}
return newEntries, modifiedIndexes
}
valueField := entries.Index(0).Elem().FieldByName(fieldNameValue)
if !valueField.IsValid() || valueField.Kind() != reflect.String {
entriesLen := entries.Len()
for i := 0; i < newEntries.Len(); i++ {
modifiedIndexes[i+entriesLen] = true
}
return reflect.AppendSlice(entries, newEntries), modifiedIndexes
}
existingValueIndexes := make(map[string]int, entries.Len())
for i := 0; i < entries.Len(); i++ {
value := entries.Index(i).Elem().FieldByName(fieldNameValue).String()
if _, ok := existingValueIndexes[value]; ok {
continue
}
existingValueIndexes[value] = i
}
entriesLen := entries.Len()
for i := 0; i < newEntries.Len(); i++ {
newEntry := newEntries.Index(i)
value := newEntry.Elem().FieldByName(fieldNameValue).String()
index, valueExists := existingValueIndexes[value]
// according to the rfc if the value already exists it should be replaced
if valueExists {
entries.Index(index).Set(newEntry)
modifiedIndexes[index] = true
continue
}
entries = reflect.Append(entries, newEntry)
modifiedIndexes[entriesLen] = true
entriesLen++
}
return entries, modifiedIndexes
}

View File

@@ -0,0 +1,73 @@
package patch
import (
"reflect"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/api/scim/resources/filter"
"github.com/zitadel/zitadel/internal/api/scim/serrors"
"github.com/zitadel/zitadel/internal/zerrors"
)
func applyRemovePatch(patcher ResourcePatcher, op *Operation, value interface{}) error {
// the root cannot be removed
if op.Path == nil {
logging.Info("SCIM: remove patch without path")
return serrors.ThrowNoTarget(zerrors.ThrowInvalidArgument(nil, "SCIM-ozzy54", "Remove patch without path is not supported"))
}
result, err := patcher.FilterEvaluator().Evaluate(reflect.ValueOf(value), op.Path)
if err != nil {
return serrors.ThrowInvalidPath(zerrors.ThrowInvalidArgument(err, "SCIM-sd41", "Failed to evaluate path"))
}
switch filterResult := result.(type) {
case *filter.SimpleValueEvaluationResult:
return applyRemovePatchSimple(patcher, filterResult)
case *filter.FilteredValuesEvaluationResult:
return applyRemovePatchFiltered(patcher, filterResult)
}
logging.Errorf("SCIM remove patch: unsupported filter type %T", result)
return serrors.ThrowInvalidPath(zerrors.ThrowInvalidArgument(err, "SCIM-12syw", "Invalid patch path"))
}
func applyRemovePatchSimple(patcher ResourcePatcher, filterResult *filter.SimpleValueEvaluationResult) error {
filterResult.Value.Set(reflect.Zero(filterResult.Value.Type()))
return patcher.Removed(filterResult.PathSegments)
}
func applyRemovePatchFiltered(patcher ResourcePatcher, filterResult *filter.FilteredValuesEvaluationResult) error {
if len(filterResult.Matches) == 0 {
return nil
}
// if a subattribute is selected, set that one to nil instead of removing the elements from the slice
if len(filterResult.PathSegments) > 1 {
for _, match := range filterResult.Matches {
match.Value.Set(reflect.Zero(match.Value.Type()))
}
return patcher.Removed(filterResult.PathSegments)
}
slice := filterResult.Source
sliceLen := slice.Len()
// if all elements are matched, set the field to nil
if sliceLen == len(filterResult.Matches) {
filterResult.Source.Set(reflect.Zero(slice.Type()))
return patcher.Removed(filterResult.PathSegments)
}
// start at the very last matched value to keep correct indexing
for i := len(filterResult.Matches) - 1; i >= 0; i-- {
match := filterResult.Matches[i]
slice = reflect.AppendSlice(slice.Slice(0, match.SourceIndex), slice.Slice(match.SourceIndex+1, sliceLen))
sliceLen--
}
filterResult.Source.Set(slice)
return patcher.Removed(filterResult.PathSegments)
}

View File

@@ -0,0 +1,105 @@
package patch
import (
"encoding/json"
"reflect"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/api/scim/resources/filter"
"github.com/zitadel/zitadel/internal/api/scim/serrors"
"github.com/zitadel/zitadel/internal/zerrors"
)
func applyReplacePatch(patcher ResourcePatcher, op *Operation, value interface{}) error {
if op.Path == nil {
return flattenAndApplyPatchOperations(patcher, op, value)
}
result, err := patcher.FilterEvaluator().Evaluate(reflect.ValueOf(value), op.Path)
if err != nil {
return serrors.ThrowInvalidPath(zerrors.ThrowInvalidArgument(err, "SCIM-i2o3", "Failed to evaluate path"))
}
switch filterResult := result.(type) {
case *filter.SimpleValueEvaluationResult:
return applyReplacePatchSimple(patcher, filterResult, op.Value, op.valueIsArray)
case *filter.FilteredValuesEvaluationResult:
return applyReplacePatchFiltered(patcher, filterResult, op.Value)
}
logging.Errorf("SCIM replace patch: unsupported filter type %T", result)
return serrors.ThrowInvalidPath(zerrors.ThrowInvalidArgument(err, "SCIM-optu9", "Invalid patch path"))
}
func applyReplacePatchSimple(patcher ResourcePatcher, evaluationResult *filter.SimpleValueEvaluationResult, newValueRaw json.RawMessage, valueIsArray bool) error {
// patch value is an array
// or it is a scalar or an object but the target is a slice
// unmarshal it as a slice and set it on the target, clearing all existing entries
if valueIsArray || evaluationResult.Value.Kind() == reflect.Slice {
return applyReplacePatchSimpleSlice(patcher, evaluationResult, newValueRaw, valueIsArray)
}
// patch value and target is a scalar or object
if err := unmarshalPatchValue(newValueRaw, evaluationResult.Value); err != nil {
return err
}
return patcher.Replaced(evaluationResult.PathSegments)
}
func applyReplacePatchSimpleSlice(patcher ResourcePatcher, evaluationResult *filter.SimpleValueEvaluationResult, newValueRaw json.RawMessage, valueIsArray bool) error {
if evaluationResult.Value.Kind() != reflect.Slice {
return zerrors.ThrowInvalidArgument(nil, "SCIM-E345X", "Cannot apply array patch value to single value attribute")
}
values, err := unmarshalPatchValuesSlice(evaluationResult.Value.Type().Elem(), newValueRaw, valueIsArray)
if err != nil {
return err
}
evaluationResult.Value.Set(values)
modifiedIndexes := make(map[int]bool, values.Len())
for i := 0; i < values.Len(); i++ {
modifiedIndexes[i] = true
}
if err = ensureSinglePrimaryAdded(values, values, modifiedIndexes); err != nil {
return err
}
return patcher.Replaced(evaluationResult.PathSegments)
}
func applyReplacePatchFiltered(patcher ResourcePatcher, result *filter.FilteredValuesEvaluationResult, newValueRaw json.RawMessage) error {
if len(result.Matches) == 0 {
return serrors.ThrowNoTarget(zerrors.ThrowInvalidArgument(nil, "SCIM-4513", "Path evaluation resulted in no matches"))
}
for _, match := range result.Matches {
if err := unmarshalPatchValue(newValueRaw, match.Value); err != nil {
return err
}
}
if err := ensureSinglePrimaryBasedOnMatches(result.Source, result.Matches); err != nil {
return err
}
return patcher.Replaced(result.PathSegments)
}
func ensureSinglePrimaryBasedOnMatches(slice reflect.Value, matches []*filter.FilteredValuesEvaluationResultMatch) error {
if len(matches) == 0 {
return nil
}
modifiedElements := make([]reflect.Value, 0, len(matches))
modifiedIndexes := make(map[int]bool, len(matches))
for _, match := range matches {
modifiedElements = append(modifiedElements, match.Element)
modifiedIndexes[match.SourceIndex] = true
}
return ensureSinglePrimary(slice, modifiedElements, modifiedIndexes)
}