Skip to content

Commit

Permalink
Merge pull request #565 from RoboJackets/ross/grouper
Browse files Browse the repository at this point in the history
Add Grouper Support
  • Loading branch information
kberzinch authored Sep 16, 2024
2 parents e5e0247 + 1cb39e1 commit 6494c9b
Show file tree
Hide file tree
Showing 10 changed files with 276 additions and 5 deletions.
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,10 @@ AUTODESK_LIBRARY_ENABLED=
AUTODESK_LIBRARY_EMAIL=
AUTODESK_LIBRARY_PASSWORD=
AUTODESK_LIBRARY_HUB_ID=

GROUPER_ENABLED=
GROUPER_SERVER=
GROUPER_USERNAME=
GROUPER_PASSWORD=
GROUPER_FOLDER_BASE_PATH=
GROUPER_MANUAL_GROUPS=
5 changes: 5 additions & 0 deletions app/Http/Controllers/SyncController.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use App\Jobs\SyncClickUp;
use App\Jobs\SyncGitHub;
use App\Jobs\SyncGoogleGroups;
use App\Jobs\SyncGrouper;
use App\Jobs\SyncKeycloak;
use App\Jobs\SyncNextcloud;
use App\Jobs\SyncSUMS;
Expand Down Expand Up @@ -166,6 +167,10 @@ public function sync(Request $request): JsonResponse
);
}

if (config('grouper.enabled') === true) {
SyncGrouper::dispatch($request->username, $request->is_access_active, $request->teams);
}

return response()->json('queued', 202);
}
}
102 changes: 102 additions & 0 deletions app/Jobs/SyncGrouper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php

declare(strict_types=1);

namespace App\Jobs;

use App\Services\Grouper;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;

class SyncGrouper extends SyncJob
{
/**
* The queue this job will run on.
*
* @var string
*/
public $queue = 'grouper';

/**
* Create a new job instance.
*
* @param string $username The user's GT username
* @param bool $is_access_active Whether the user should have access to systems
* @param array<string> $teams The names of the teams the user is in
*/
protected function __construct(string $username, bool $is_access_active, array $teams)
{
parent::__construct($username, '', '', $is_access_active, $teams);
}

/**
* Execute the job.
*/
public function handle(): void
{
$membershipResponse = Grouper::getGroupMembershipsForUser($this->username);
$memberships = property_exists($membershipResponse->WsGetMembershipsResults, 'wsMemberships')
? collect($membershipResponse->WsGetMembershipsResults->wsMemberships) :
Collection::empty();
$filteredMemberships = $memberships->where('membershipType', 'immediate');
$userGroupFullNames = $filteredMemberships->pluck('groupName');
$this->debug('User is a direct member of Grouper groups: '.implode(', ', $userGroupFullNames->toArray()));

$allGroupsResponse = Cache::remember('grouper_groups', 900, static fn (): object => Grouper::getGroups());
$allGroups = collect($allGroupsResponse->WsFindGroupsResults->groupResults)->filter(
static fn (object $group): bool => ! in_array(
$group->displayExtension,
(array) config('grouper.manual_groups'),
true
)
);

$userTeams = array_map('strtolower', $this->teams);
$desiredGroups = $allGroups->filter(
static fn (object $group): bool => in_array(strtolower($group->displayExtension), $userTeams, true)
);

foreach ($allGroups as $group) {
$this->debug('Processing '.$group->name);
$shouldBeGroupMember = $desiredGroups->contains($group);
$currentGroupMember = $userGroupFullNames->contains($group->name);

if ($this->is_access_active && $shouldBeGroupMember && ! $currentGroupMember) {
// User should be, but is not currently, in the group
$this->debug('Adding user to group '.$group->name);
Grouper::addUserToGroup($group->displayExtension, $this->username);
$this->info('Added user to group '.$group->name);
} elseif ($this->is_access_active && $shouldBeGroupMember) {
// User should be, and is currently, in the group. No action required.
$this->debug('User is already a direct member of group '.$group->name);
} elseif (
(! $this->is_access_active && $currentGroupMember) ||
($this->is_access_active && ! $shouldBeGroupMember && $currentGroupMember)
) {
// Remove the user, either because their access is inactive, or they otherwise shouldn't be a member
$this->debug('Removing user from group '.$group->name);
Grouper::removeUserFromGroup($group->displayExtension, $this->username);
$this->info('Removed user from group '.$group->name);
} elseif ($this->is_access_active && ! $shouldBeGroupMember && ! $currentGroupMember) {
// User is access active, but is not and should not be a member of the group
$this->debug('User is not a direct member of group '.$group->name);
}
}
}

private function debug(string $message): void
{
Log::debug($this->jobDetails().$message);
}

private function info(string $message): void
{
Log::info($this->jobDetails().$message);
}

private function jobDetails(): string
{
return self::class.' GT='.$this->username.' ';
}
}
102 changes: 102 additions & 0 deletions app/Services/Grouper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php

declare(strict_types=1);

// phpcs:disable SlevomatCodingStandard.TypeHints.TypeHintDeclaration.MissingTraversableReturnTypeHintSpecification
// phpcs:disable Generic.NamingConventions.CamelCapsFunctionName.ScopeNotCamelCaps

namespace App\Services;

use GuzzleHttp\Client;

class Grouper extends Service
{
/**
* A Guzzle client configured for Grouper.
*/
private static ?Client $client = null;

public static function addUserToGroup(string $group_name, string $username): void
{
$response = self::client()->post(
'groups/'.config('grouper.folder_base_path').':'.$group_name.'/members',
[
'json' => [
'WsRestAddMemberLiteRequest' => [
'subjectId' => $username,
'groupName' => config('grouper.folder_base_path').':'.$group_name,
],
],
]
);
self::expectStatusCodes($response, 200, 201);
}

public static function removeUserFromGroup(string $group_name, string $username): void
{
$response = self::client()->post(
'groups/'.config(
'grouper.folder_base_path'
).':'.$group_name.'/members/sources/gted-accounts/subjectId/'.$username,
[
'json' => ['WsRestDeleteMemberLiteRequest' => new \stdClass()],
]
);
self::expectStatusCodes($response, 200);
}

public static function getGroupMembershipsForUser(string $username): object
{
$response = self::client()->get('subjects/'.$username.'/memberships');

self::expectStatusCodes($response, 200);

return self::decodeToObject($response);
}

/**
* Returns all groups in the RoboJackets Grouper hierarchy.
*/
public static function getGroups(): object
{
$response = self::client()->post(
'groups',
[
'json' => [
'WsRestFindGroupsLiteRequest' => [
'stemName' => config('grouper.folder_base_path'),
'queryFilterType' => 'FIND_BY_STEM_NAME',
],
],
]
);

self::expectStatusCodes($response, 200);

return self::decodeToObject($response);
}

/**
* Return a client configured for Grouper.
*
* The load balancer in front of Grouper isn't sending a complete (or correct) certificate chain
* as of 2024/09/15, so we have to manually specify the intermediate certificate from InCommon.
*/
public static function client(): Client
{
self::$client = new Client(
[
'base_uri' => 'https://'.config('grouper.server').'/grouper-ws/servicesRest/v4_0_000/',
'headers' => [
'User-Agent' => 'RoboJacketsJEDI/'.config('sentry.release'),
],
'auth' => [config('grouper.username'), config('grouper.password')],
'allow_redirects' => false,
'http_errors' => true,
'verify' => '/app/storage/certs/InCommon_RSA_Server_CA_2.cer',
]
);

return self::$client;
}
}
12 changes: 12 additions & 0 deletions config/grouper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

return [
'enabled' => env('GROUPER_ENABLED', false),
'server' => env('GROUPER_SERVER'),
'username' => env('GROUPER_USERNAME'),
'password' => env('GROUPER_PASSWORD'),
'folder_base_path' => env('GROUPER_FOLDER_BASE_PATH'),
'manual_groups' => explode(',', env('GROUPER_MANUAL_GROUPS', '')),
];
1 change: 1 addition & 0 deletions config/horizon.php
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@
'sums',
'wordpress',
'keycloak',
'grouper',
],
'balance' => 'simple',
'processes' => 1,
Expand Down
9 changes: 5 additions & 4 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ services:
jedi:
build:
context: .
network: host
target: backend-uncompressed
network_mode: host
ports:
- "8000:8000"
environment:
APP_NAME: JEDI Local
APP_ENV: local
Expand All @@ -18,17 +18,18 @@ services:
BROADCAST_CONNECTION: log
CACHE_STORE: array
SESSION_DRIVER: file
QUEUE_DRIVER: sync
QUEUE_CONNECTION: sync
CAS_MASQUERADE: ltesting3
CAS_HOSTNAME: sso-test.gatech.edu
CAS_REAL_HOSTS: sso-test.gatech.edu
CAS_CLIENT_SERVICE: http://127.0.0.1:8000
CAS_LOGOUT_URL: http://127.0.0.1:8000
SCOUT_DRIVER: collection
MAIL_MAILER: log
SLACK_ENDPOINT:
command: >-
/bin/sh -euxc "export APP_KEY=$$(php artisan key:generate --show --verbose) &&
touch $${DB_DATABASE} &&
php artisan migrate --no-interaction &&
php artisan tinker --no-interaction --verbose --execute \"\\App\\Models\\User::upsert(['username' => 'ltesting3', 'admin' => true], uniqueBy: ['username'], update: ['admin']); echo \\App\\Models\\User::where('username', '=', 'ltesting3')->sole()->createToken('local testing')->plainTextToken;\" &&
exec php artisan serve"
exec php artisan serve --host=0.0.0.0"
5 changes: 5 additions & 0 deletions phpcs.xml
Original file line number Diff line number Diff line change
Expand Up @@ -364,4 +364,9 @@
<property name="spacingBeforeFirst" value="0"/>
</properties>
</rule>
<rule ref="Squiz.WhiteSpace.OperatorSpacing">
<properties>
<property name="ignoreNewlines" value="true"/>
</properties>
</rule>
</ruleset>
2 changes: 1 addition & 1 deletion phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ parameters:
- '#Access to an undefined property object::\$[a-zA-Z_]+\.#'
- '#Anonymous function should return string but returns mixed\.#'
- '#Argument of an invalid type mixed supplied for foreach, only iterables are supported\.#'
- '#Cannot access offset ''[a-z_]+'' on mixed\.#'
- '#Cannot access offset ''[A-Za-z_]+'' on mixed\.#'
- '#Cannot access offset ''project_manager_of…'' on mixed\.#'
- '#Cannot access property \$[a-z_]+ on App\\Models\\User\|null\.#'
- '#Cannot access property \$[a-zA-Z]+ on mixed\.#'
Expand Down
36 changes: 36 additions & 0 deletions storage/certs/InCommon_RSA_Server_CA_2.cer
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
-----BEGIN CERTIFICATE-----
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
-----END CERTIFICATE-----

0 comments on commit 6494c9b

Please sign in to comment.