Skip to content

Commit 620ee28

Browse files
Merge pull request #128 from limosa-io/scim-attribute-fixes
Enhance SCIM response handling by adding support for excluded attribu…
2 parents da40db7 + 1e21d02 commit 620ee28

File tree

6 files changed

+337
-45
lines changed

6 files changed

+337
-45
lines changed

.github/workflows/release.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
tags:
6+
- 'v*'
7+
8+
permissions:
9+
contents: write
10+
11+
jobs:
12+
create-release:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- name: Checkout repository
16+
uses: actions/checkout@v4
17+
18+
- name: Create GitHub release
19+
env:
20+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
21+
run: |
22+
gh release create "$GITHUB_REF_NAME" \
23+
--generate-notes

src/Helper.php

Lines changed: 170 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ public static function getAuthUserClass()
2626
*
2727
* @param unknown $object
2828
*/
29-
public static function prepareReturn(Arrayable $object, ResourceType $resourceType = null, array $attributes = [])
29+
public static function prepareReturn(Arrayable $object, ResourceType $resourceType = null, array $attributes = [], array $excludedAttributes = [])
3030
{
3131
$result = null;
3232

@@ -35,7 +35,7 @@ public static function prepareReturn(Arrayable $object, ResourceType $resourceTy
3535
$result = [];
3636

3737
foreach ($object as $key => $value) {
38-
$result[] = self::objectToSCIMArray($value, $resourceType, $attributes);
38+
$result[] = self::objectToSCIMArray($value, $resourceType, $attributes, $excludedAttributes);
3939
}
4040
}
4141
}
@@ -44,18 +44,33 @@ public static function prepareReturn(Arrayable $object, ResourceType $resourceTy
4444
$result = $object;
4545
}
4646

47+
if (is_array($result) && !empty($excludedAttributes)) {
48+
$defaultSchema = $resourceType?->getMapping()->getDefaultSchema();
49+
$result = self::applyExcludedAttributes($result, $excludedAttributes, $defaultSchema);
50+
}
51+
4752
return $result;
4853
}
4954

50-
public static function objectToSCIMArray($object, ResourceType $resourceType = null, array $attributes = [])
55+
public static function objectToSCIMArray($object, ResourceType $resourceType = null, array $attributes = [], array $excludedAttributes = [])
5156
{
5257
if($resourceType == null){
53-
return $object instanceof Arrayable ? $object->toArray() : $object;
58+
$result = $object instanceof Arrayable ? $object->toArray() : $object;
59+
60+
if (is_array($result) && !empty($excludedAttributes)) {
61+
$result = self::applyExcludedAttributes($result, $excludedAttributes);
62+
}
63+
64+
return $result;
5465
}
5566

5667
$mapping = $resourceType->getMapping();
5768
$result = $mapping->read($object, $attributes)->value;
5869

70+
if (!empty($excludedAttributes)) {
71+
$result = self::applyExcludedAttributes($result, $excludedAttributes, $mapping->getDefaultSchema());
72+
}
73+
5974
if (config('scim.omit_main_schema_in_return')) {
6075
$defaultSchema = collect($mapping->getDefaultSchema())->first();
6176

@@ -90,9 +105,39 @@ public static function getResourceObjectVersion($object)
90105
* @param unknown $object
91106
* @param ResourceType $resourceType
92107
*/
93-
public static function objectToSCIMResponse(Model $object, ResourceType $resourceType = null)
108+
public static function objectToSCIMResponse(Model $object, ResourceType $resourceType = null, array $attributes = [], array $excludedAttributes = [])
94109
{
95-
return response(self::objectToSCIMArray($object, $resourceType))->header('ETag', self::getResourceObjectVersion($object));
110+
$response = response(self::objectToSCIMArray($object, $resourceType, $attributes, $excludedAttributes))
111+
->header('ETag', self::getResourceObjectVersion($object));
112+
113+
if ($resourceType !== null) {
114+
$resourceTypeName = $resourceType->getName();
115+
116+
if ($resourceTypeName === null) {
117+
$routeResourceType = request()?->route('resourceType');
118+
119+
if ($routeResourceType instanceof ResourceType) {
120+
$resourceTypeName = $routeResourceType->getName();
121+
} elseif (is_string($routeResourceType)) {
122+
$resourceTypeName = $routeResourceType;
123+
}
124+
}
125+
126+
if ($resourceTypeName !== null) {
127+
$response->header(
128+
'Location',
129+
route(
130+
'scim.resource',
131+
[
132+
'resourceType' => $resourceTypeName,
133+
'resourceObject' => $object->getKey(),
134+
]
135+
)
136+
);
137+
}
138+
}
139+
140+
return $response;
96141
}
97142

98143
/**
@@ -132,6 +177,125 @@ function ($query) use ($term, $resourceType) {
132177
}
133178
}
134179

180+
protected static function applyExcludedAttributes(array $resource, array $excludedAttributes, $defaultSchema = null): array
181+
{
182+
foreach ($excludedAttributes as $reference) {
183+
$reference = trim($reference);
184+
185+
if ($reference === '') {
186+
continue;
187+
}
188+
189+
[$schema, $segments] = self::splitAttributeReference($reference, $defaultSchema);
190+
191+
if (empty($segments)) {
192+
continue;
193+
}
194+
195+
if ($schema !== null) {
196+
if (!isset($resource[$schema]) || !is_array($resource[$schema])) {
197+
continue;
198+
}
199+
200+
self::removeAttributePath($resource[$schema], $segments);
201+
202+
if (is_array($resource[$schema]) && empty($resource[$schema])) {
203+
unset($resource[$schema]);
204+
}
205+
206+
continue;
207+
}
208+
209+
self::removeAttributePath($resource, $segments);
210+
}
211+
212+
return $resource;
213+
}
214+
215+
protected static function splitAttributeReference(string $reference, $defaultSchema = null): array
216+
{
217+
$schema = null;
218+
$attributePart = $reference;
219+
220+
if (str_starts_with($reference, 'urn:')) {
221+
$lastColon = strrpos($reference, ':');
222+
223+
if ($lastColon !== false) {
224+
$schema = substr($reference, 0, $lastColon);
225+
$attributePart = substr($reference, $lastColon + 1);
226+
}
227+
}
228+
229+
$attributePart = trim($attributePart);
230+
231+
if ($schema === null) {
232+
$firstSegment = $attributePart;
233+
234+
if (($dotPosition = strpos($attributePart, '.')) !== false) {
235+
$firstSegment = substr($attributePart, 0, $dotPosition);
236+
}
237+
238+
if (!in_array($firstSegment, ['schemas', 'meta', 'id'], true) && $defaultSchema !== null) {
239+
$schema = $defaultSchema;
240+
}
241+
}
242+
243+
$segments = $attributePart === '' ? [] : explode('.', $attributePart);
244+
245+
return [$schema, $segments];
246+
}
247+
248+
protected static function removeAttributePath(&$node, array $segments, int $depth = 0): void
249+
{
250+
if (!is_array($node)) {
251+
return;
252+
}
253+
254+
if (self::isList($node)) {
255+
foreach ($node as &$element) {
256+
self::removeAttributePath($element, $segments, $depth);
257+
}
258+
259+
return;
260+
}
261+
262+
$key = $segments[$depth] ?? null;
263+
264+
if ($key === null || !array_key_exists($key, $node)) {
265+
return;
266+
}
267+
268+
if ($depth === count($segments) - 1) {
269+
unset($node[$key]);
270+
return;
271+
}
272+
273+
self::removeAttributePath($node[$key], $segments, $depth + 1);
274+
275+
if (is_array($node[$key]) && empty($node[$key])) {
276+
unset($node[$key]);
277+
}
278+
}
279+
280+
protected static function isList(array $value): bool
281+
{
282+
if (function_exists('array_is_list')) {
283+
return array_is_list($value);
284+
}
285+
286+
$expectedKey = 0;
287+
288+
foreach ($value as $key => $unused) {
289+
if ($key !== $expectedKey) {
290+
return false;
291+
}
292+
293+
$expectedKey++;
294+
}
295+
296+
return true;
297+
}
298+
135299
public static function getFlattenKey($parts, $schemas)
136300
{
137301
$result = "";

src/Http/Controllers/BulkController.php

Lines changed: 70 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,18 @@ class BulkController extends Controller
2121
public function processBulkRequest(Request $request)
2222
{
2323

24+
$originalRequest = $request;
25+
2426
// get the content size in bytes from raw content (not entirely accurate, but good enough for now)
25-
$contentSize = mb_strlen($request->getContent(), '8bit');
27+
$contentSize = mb_strlen($originalRequest->getContent(), '8bit');
2628

2729
if($contentSize > static::MAX_PAYLOAD_SIZE){
2830
throw (new SCIMException('Payload too large!'))->setCode(413)->setScimType('tooLarge');
2931
}
3032

31-
$validator = Validator::make($request->input(), [
33+
$validator = Validator::make($originalRequest->input(), [
3234
'schemas' => 'required|array',
3335
'schemas.*' => 'required|string|in:urn:ietf:params:scim:api:messages:2.0:BulkRequest',
34-
// TODO: implement failOnErrors
3536
'failOnErrors' => 'nullable|int',
3637
'Operations' => 'required|array',
3738
'Operations.*.method' => 'required|string|in:POST,PUT,PATCH,DELETE',
@@ -45,25 +46,38 @@ public function processBulkRequest(Request $request)
4546
throw (new SCIMException('Invalid data!'))->setCode(400)->setScimType('invalidSyntax')->setErrors($e);
4647
}
4748

48-
$operations = $request->input('Operations');
49+
$operations = $originalRequest->input('Operations');
4950

5051
if(count($operations) > static::MAX_OPERATIONS){
5152
throw (new SCIMException('Too many operations!'))->setCode(413)->setScimType('tooLarge');
5253
}
5354

5455
$bulkIdMapping = [];
5556
$responses = [];
57+
$errorCount = 0;
58+
59+
$failOnErrors = $originalRequest->input('failOnErrors');
60+
$failOnErrors = is_numeric($failOnErrors) ? (int)$failOnErrors : null;
61+
62+
if ($failOnErrors !== null && $failOnErrors < 1) {
63+
$failOnErrors = null;
64+
}
5665

5766
// Remove everything till the last occurence of Bulk, e.g. /scim/v2/Bulk should become /scim/v2/
58-
$prefix = substr($request->path(), 0, strrpos($request->path(), '/Bulk'));
67+
$prefix = substr($originalRequest->path(), 0, strrpos($originalRequest->path(), '/Bulk'));
5968

60-
foreach ($operations as $operation) {
69+
foreach ($operations as $index => $operation) {
6170

6271
$method = $operation['method'];
6372
$bulkId = $operation['bulkId'] ?? null;
73+
$data = $operation['data'] ?? [];
74+
75+
if (!is_array($data)) {
76+
$data = [];
77+
}
6478

6579
// Call internal Laravel route based on method, path and data
66-
$encoded = json_encode($operation['data'] ?? []);
80+
$encoded = json_encode($data);
6781
$encoded = str_replace(array_keys($bulkIdMapping), array_values($bulkIdMapping), $encoded);
6882
$path = str_replace(array_keys($bulkIdMapping), array_values($bulkIdMapping), $operation['path']);
6983

@@ -72,19 +86,34 @@ public function processBulkRequest(Request $request)
7286
throw (new SCIMException('Invalid path!'))->setCode(400)->setScimType('invalidPath');
7387
}
7488

75-
$request = Request::create(
89+
$operationRequest = Request::create(
7690
$prefix . $path,
77-
$operation['method'],
78-
server: [
79-
'HTTP_Authorization' => $request->header('Authorization'),
80-
'CONTENT_TYPE' => 'application/scim+json',
81-
],
91+
$method,
92+
parameters: [],
93+
cookies: $originalRequest->cookies->all(),
94+
files: [],
95+
server: array_replace(
96+
$originalRequest->server->all(),
97+
[
98+
'HTTP_Authorization' => $originalRequest->header('Authorization'),
99+
'CONTENT_TYPE' => 'application/scim+json',
100+
'HTTP_CONTENT_TYPE' => 'application/scim+json',
101+
]
102+
),
82103
content: $encoded
83104
);
84105

106+
if ($originalRequest->getUserResolver()) {
107+
$operationRequest->setUserResolver($originalRequest->getUserResolver());
108+
}
109+
110+
if ($originalRequest->getRouteResolver()) {
111+
$operationRequest->setRouteResolver($originalRequest->getRouteResolver());
112+
}
113+
85114
// run request and get response
86115
/** @var \Illuminate\Http\Response */
87-
$response = app()->handle($request);
116+
$response = app()->handle($operationRequest);
88117
// Get the JSON content of the response
89118
$jsonContent = $response->getContent();
90119
// Decode the JSON content
@@ -98,14 +127,39 @@ public function processBulkRequest(Request $request)
98127
$bulkIdMapping['bulkId:' . $bulkId] = $id;
99128
}
100129

130+
$status = $response->getStatusCode();
131+
132+
if ($status >= 400) {
133+
$errorCount++;
134+
}
135+
101136
$responses[] = array_filter([
102137
"location" => $responseData?->meta?->location ?? null,
103138
"method" => $method,
104139
"bulkId" => $bulkId,
105140
"version" => $responseData?->meta?->version ?? null,
106-
"status" => $response->getStatusCode(),
107-
"response" => $response->getStatusCode() >= 400 ? $responseData : null,
141+
"status" => $status,
142+
"response" => $status >= 400 ? $responseData : null,
108143
]);
144+
145+
if ($failOnErrors !== null && $errorCount >= $failOnErrors) {
146+
$remaining = array_slice($operations, $index + 1);
147+
148+
foreach ($remaining as $remainingOperation) {
149+
$responses[] = array_filter([
150+
'method' => $remainingOperation['method'],
151+
'bulkId' => $remainingOperation['bulkId'] ?? null,
152+
'status' => 424,
153+
'response' => [
154+
'schemas' => ['urn:ietf:params:scim:api:messages:2.0:Error'],
155+
'scimType' => 'cancelled',
156+
'detail' => 'Operation cancelled because failOnErrors threshold was reached.',
157+
],
158+
]);
159+
}
160+
161+
break;
162+
}
109163
}
110164

111165
// Return a response indicating the successful processing of the SCIM BULK request

0 commit comments

Comments
 (0)