diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..f00d481 --- /dev/null +++ b/.htaccess @@ -0,0 +1,3 @@ +RewriteEngine on +RewriteRule ^$ public/ [L] +RewriteRule (.*) public/$1 [L] \ No newline at end of file diff --git a/Makefile b/Makefile index 3eec3a1..7435db1 100644 --- a/Makefile +++ b/Makefile @@ -33,12 +33,7 @@ lint: .PHONY: api_test api_test: -# If we pass the PFA_API_TEST variable with value 1, we can run the PFA API tests -ifeq ($(PFA_API_TEST),1) - newman run test/postman/scim-opf-pfa.postman_collection.json -e test/postman/scim-env.postman_environment.json -else newman run test/postman/scim-opf.postman_collection.json -e test/postman/scim-env.postman_environment.json -endif .PHONY: unit_test unit_test: diff --git a/README.md b/README.md index 72cd51d..c7a192f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,33 @@ # scim-server-php -**scim-server-php** is a PHP library making it easy to implement [SCIM v2.0](https://datatracker.ietf.org/wg/scim/documents/) server endpoints for various systems. +This is the Open Provisioning Framework project by audriga which makes use of the [SCIM](http://www.simplecloud.info/) protocol. + +--- + +# Table of Contents +1. [Info](#info) +1. [Related projects](#related-projects) +1. [Capabilities](#capabilities) +1. [Prerequisites](#prerequisites) +1. [Usage](#usage) + 1. [Get it as a composer dependency](#get-it-as-a-composer-dependency) + 1. [Try out the embedded mock server](#try-out-the-embedded-mock-server) + 1. [Enable JWT authentication](#enable-jwt-authentication) + 1. [Use scim-server-php for your own project](#use-scim-server-php-for-your-own-project) + 1. [SCIM resources](#scim-resources) + 1. [SCIM server](#scim-server) + 1. [Authentication/Authorization](#authenticationauthorization) + 1. [Define your authentication/authorization logic](#define-your-authenticationauthorization-logic) + 1. [Define your authentication/authorization middleware](#define-your-authenticationauthorization-middleware) + 1. [Add your authentication/authorization middleware to the SCIM server](#add-your-authenticationauthorization-middleware-to-the-scim-server) + 1. [Full example](#full-example) +1. [Acknowledgements](#acknowledgements) + +--- + +## Info + +**scim-server-php** is a PHP library which makes it easy to implement [SCIM v2.0](https://datatracker.ietf.org/wg/scim/documents/) server endpoints for various systems. It is built on the following IETF approved RFCs: [RFC7642](https://datatracker.ietf.org/doc/html/rfc7642), [RFC7643](https://datatracker.ietf.org/doc/html/rfc7643) and [RFC7644](https://datatracker.ietf.org/doc/html/rfc7644) @@ -9,74 +36,146 @@ This is a **work in progress** project. It already works pretty well but some fe The **scim-server-php** project currently includes the following: * A SCIM 2.0 server core library -* A [Postfix Admin](https://github.com/postfixadmin/postfixadmin) SCIM API +* An integrated Mock SCIM server based on a SQLite database. -**scim-server-php** also comes with an integrated Mock SCIM server based on a SQLite database. +## Related projects -## SCIM 2.0 server core library +* A [Postfix Admin](https://github.com/postfixadmin/postfixadmin) SCIM API based on **scim-server-php** is available at https://github.com/audriga/postfixadmin-scim-api +* The [Nextcloud SCIM](https://lab.libreho.st/libre.sh/scim/scimserviceprovider) application provides a SCIM API to [NextCloud](https://nextcloud.com/) and uses **scim-server-php** for its SCIM resource models + +## Capabilities This library provides: * Standard SCIM resources implementations (*Core User*, *Enterprise User* and *Groups*) * Custom SCIM resource *Provisioning User* implementation -* Standard CRUD operation on above SCIM resources +* Custom SCIM resource *Domain* implementation +* Standard CRUD operations on above SCIM resources * A HTTP server handling requests and responses on defined endpoints, based on the [Slim](https://www.slimframework.com/) framework -* A very simple JWT implementation - * When enabled, a JWT token is generated on the `/jwt` endpoint. You **must** therefore protect this endpoint. +* A simple JWT implementation * When enabled, this JWT token needs to be provided in all requests using the Bearer schema (`Authorization: Bearer `) + * You can generate a token with the script located at `bin/generate_jwt.php` + * The secret you use *must* be also defined in your `config/config.php` file * An easily reusable code architecture for implementing SCIM servers -## Postfix Admin SCIM API - -The [Postfix Admin](https://github.com/postfixadmin/postfixadmin) API enables SCIM server capabilities for [Postfix Admin](https://github.com/postfixadmin/postfixadmin). It uses the core library above. - -It supports standard GET, POST, PUT and DELETE operations on SCIM *Provisioning User* resources, which are translated in the corresponding operations on the [Postfix Admin](https://github.com/postfixadmin/postfixadmin) mailboxes. - -Example (null values removed for readability): - -``` -$ curl https://my.postfix.admin.url/Users/aaaa@bli.fr -H 'Authorization: Bearer ' -{ - "schemas":[ - "urn:ietf:params:scim:schemas:core:2.0:User", - "urn:audriga:params:scim:schemas:extension:provisioning:2.0:User" - ], - "id":"aaaa@bli.fr", - "meta":{ - "resourceType":"User", - "created":"2022-05-27 12:45:08", - "location":"https://my.postfix.admin.url/Users/aaaa@bli.fr", - "updated":"2022-06-15 13:07:30" - }, - "userName":"aaaa@bli.fr", - "name":{ - "formatted":"Aaaa" - }, - "displayName":"Aaaa", - "active":"1", - "urn:audriga:params:scim:schemas:extension:provisioning:2.0:User":{ - "sizeQuota":51200000 - } -} -``` +Note that you can of course use the standard and custom SCIM resources implementations with your own HTTP server if you don't want to use the one provided by **scim-server-php**. ## Prerequisites * **scim-server-php** requires PHP 7.4 * Dependencies are managed with [composer](https://getcomposer.org/) -## Installation -### Local installation -* Run `make install` to automatically install dependencies +## Usage -### Configuration -* To switch from the Mock SCIM server to the Postfix Admin SCIM API, you simply need to adapt the `public/index.php` file (include **one** of the following): +### Get it as a [composer](https://getcomposer.org/) dependency + +* You can add the following to your `composer.json` file to get it with [composer](https://getcomposer.org/) + +``` + "repositories": { + "scim": { + "type": "vcs", + "url": "git@bitbucket.org:audriga/scim-server-php.git" + } + }, + "require": { + "audriga/scim-server-php": "dev-master" + }, +``` + +* We plan to publish to [packagist](https://packagist.org/) in the future + +### Try out the embedded mock server + +* To help you use and understand this library, a mock server is provided +* Clone this repository +* Run `make install` to automatically install dependencies and setup a mock database +* Run `make start-server` to start a local mock SCIM server accessible on `localhost:8888` +* Send your first SCIM requests! For example, try out `curl http://localhost:8888/Users` +* It supports all basic CRUD operations on SCIM Core Users and Groups + +#### Enable JWT authentication + +* A very simple JWT authentication is provided +* Enable it for the embedded mock server by uncommenting the 2 following lines in `public/index.php` and restart it ``` -// Set up system-specific dependencies -$dependencies = require dirname(__DIR__) . '/src/Dependencies/mock-dependencies.php'; // include that line if you want to use the integrated mock SCIM server -$dependencies = require dirname(__DIR__) . '/src/Dependencies/pfa-dependencies.php'; // include that line if you want to use the Postfix Admin SCIM API +$scimServerPhpAuthMiddleware = 'AuthMiddleware'; +$scimServer->setMiddleware(array($scimServerPhpAuthMiddleware)); ``` +* You will now need to send a valid JWT token with all your requests to the mock server + * A JWT token will be considered as valid by the mock server if its secret is identical to the secret set in the `jwt` section of `config/config[.default].php` +* To generate a token, use the script located at `bin/generate_jwt.php` + * Note that this script generates a JWT token including a `user` claim set by the `--user` parameter. You can use any value here in the mock server case. + +### Use scim-server-php for your own project + +#### SCIM resources + +* You can directly reuse the SCIM resources implementation from the `src/Models/SCIM/` folder in any PHP project +* Here are the provided resources implementations + * `src/Models/SCIM/Standard/Users/CoreUser.php` implements the Core User resource from the SCIM standard + * `src/Models/SCIM/Standard/Users/EnterpriseUser.php` implements the Enterprise User extension from the SCIM standard + * `src/Models/SCIM/Standard/Groups/CoreGroup.php` implements the Core Group resource from the SCIM standard + * `src/Models/SCIM/Custom/Domains/Domain.php` implements the custom Domain resource + * `src/Models/SCIM/Custom/Users/ProvisioningUser.php` implements the custom Provisioning User extension of the Core User + +#### SCIM server + +* You can use **scim-server-php** to easily create a full-fledged SCIM server for your own data source +* **scim-server-php** uses the [Repository Pattern](https://martinfowler.com/eaaCatalog/repository.html) and the [Adapter Pattern](https://en.wikipedia.org/wiki/Adapter_pattern) in order to be as flexible and portable to different systems for provisioning as possible +* You can use the embedded mock server implementation as an example ;) +* Concretelly, you will need to implement the following for each resource type of your data source + * `Model` classes representing your resources + * See e.g. `src/Models/Mock/MockUsers` + * `DataAccess` classes defining how to access your data source + * See e.g. `src/DataAccess/Users/MockUserDataAccess.php` + * `Adapter` classes, extending `AbstractAdapter` and defining how to convert your resources to/from SCIM resources + * See e.g. `src/Adapters/Users/MockUserAdapter.php` + * `Repository` classes, extending `Opf\Repositories\Repository` and defining the operations available on your resources + * See e.g. `src/Repositories/Users/MockUsersRepository.php` + * If you want to define new SCIM resources, you will also need to implement new `Controllers` (see `src/Controllers`) and SCIM `Model`s (see `src/Models/SCIM`) + +* **scim-server-php** uses [Dependency Injection Container](https://php-di.org/) internally + * Create a `dependencies` file reusing the pattern of `src/Dependencies/mock-dependencies.php` + * The "Auth middleware" and "Authenticators" sections are explained in the [Authentication/Authorization](#authenticationauthorization) section bellow + * Your `Repository` classes will get the corresponding `DataAccess` and `Adapter` classes through the **scim-server-php** container + +* Instantiate a `ScimServer` and feed it with your `dependencies` file as shown in `public/index.php` + * The "Authentication Middleware" section is explained in the [Authentication/Authorization](#authenticationauthorization) section bellow + +### Authentication/Authorization + +#### Define your authentication/authorization logic + +* Authentication is mostly delegated to the system using **scim-server-php** + * A basic JWT based authentication implementation is provided as an example in `src/Util/Authentication/SimpleBearerAuthenticator` + * Define your own `Authenticator` class(es) by implementing the `AuthenticatorInterface` available in `Util/Authentication` + * A script generating a JWT token containing a single `user` claim is provided in `bin/generate_jwt.php` +* Authorization is delegated to the system using **scim-server-php** + +#### Define your authentication/authorization middleware + +* The **scim-server-php** HTTP server is based on the [Slim](https://www.slimframework.com/) framework and reuses its [Middleware](https://www.slimframework.com/docs/v4/concepts/middleware.html) concept +* Authentication and authorization should therefore be implemented as "Middleware(s)" + * This means implementing the `MiddlewareInterface` +* The authentication middleware should then delegate the actual authentication process to your `Authenticator` +* The authorization implementation is up to you + * You can either integrate it in the `Authenticator` (and so, in the authentication middleware) + * Or you can implement an independent authentication middleware +* You can use `src/Middleware/SimpleAuthMiddleware` as an example + +#### Add your authentication/authorization middleware to the SCIM server + +* Add your middleware to your dependencies file +* You can use `src/Dependencies/mock-dependencies.php` as an example +* Note that the mock `SimpleAuthMiddleware` also uses the **scim-server-php** container to gets the authenticator to use + * Hence `src/Dependencies/mock-dependencies.php` defines a `'BearerAuthenticator'` which is then used in `SimpleAuthMiddleware` + +### Full example + +* We advise to use https://github.com/audriga/postfixadmin-scim-api as a full **scim-server-php** implementation example + ## Acknowledgements This software is part of the [Open Provisioning Framework](https://www.audriga.com/en/User_provisioning/Open_Provisioning_Framework) project that has received funding from the European Union's Horizon 2020 research and innovation program under grant agreement No. 871498. \ No newline at end of file diff --git a/bin/generate_jwt.php b/bin/generate_jwt.php new file mode 100755 index 0000000..0434270 --- /dev/null +++ b/bin/generate_jwt.php @@ -0,0 +1,72 @@ +#!/usr/bin/env php + -s= + generate_jwt.php --username= --secret= + generate_jwt.php (-h | --help)\n" + ); +} + +/** + * Generate a JWT for a given user + * + * @param string $username The username of the user we generate a JWT for + * @param string $secret The JWT secret signing key + * @return string The JWT of the user + */ +function generateJwt(string $username, string $secret): string +{ + $jwtPayload = array( + "user" => $username + ); + + return JWT::encode($jwtPayload, $secret, "HS256"); +} + + +// Specify the CLI options, passed to getopt() +$shortOptions = "hu:s:"; +$longOptions = ["help", "username:", "secret:"]; + +// Obtain the CLI args, passed to the script via getopt() +$cliOptions = getopt($shortOptions, $longOptions); + +// If there was some issue with the CLI args, we show the help message +if ($cliOptions === false) { + showUsage(); + exit(1); +} + +// We check if a username was provided +if ( + (isset($cliOptions["u"]) || isset($cliOptions["username"])) + && (isset($cliOptions["s"]) || isset($cliOptions["secret"])) +) { + $username = isset($cliOptions["u"]) ? $cliOptions["u"] : $cliOptions["username"]; + $secret = isset($cliOptions["s"]) ? $cliOptions["s"] : $cliOptions["secret"]; +} else { + // If no username or secret was provided, we let the user know + fwrite(STDERR, "A username and a secret JWT key must be provided\n"); + showUsage(); + exit(1); +} + +$jwt = generateJwt($username, $secret); +fwrite(STDOUT, "$jwt\n"); +exit(0); diff --git a/composer.json b/composer.json index 13bcca4..9cbcaa3 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { - "name": "audriga/scim-opf", - "description": "An open provisioning framework using the SCIM protocol", + "name": "audriga/scim-server-php", + "description": "An open library for SCIM servers implementation", "type": "library", "require": { "slim/slim": "^4.10", @@ -20,8 +20,8 @@ }, "authors": [ { - "name": "Stanimir Bozhilov", - "email": "stanimir@audriga.com" + "name": "audriga", + "email": "opensource@audriga.com" } ], "require-dev": { diff --git a/config/Schema/domainSchema.json b/config/Schema/domainSchema.json new file mode 100644 index 0000000..5fa618a --- /dev/null +++ b/config/Schema/domainSchema.json @@ -0,0 +1,88 @@ +{ + "id": "urn:ietf:params:scim:schema:audriga:core:2.0:Domain", + "name": "Domain", + "description": "Domain", + "attributes": [ + { + "name": "domainName", + "type": "string", + "multiValued": false, + "description": "The name of the domain. REQUIRED.", + "required": true, + "caseExact": false, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "server" + }, + { + "name": "description", + "type": "string", + "multiValued": false, + "description": "A description of the domain. OPTIONAL.", + "required": false, + "caseExact": false, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "maxAliases", + "type": "int", + "multiValued": false, + "description": "The maximum number of aliases of the domain. OPTIONAL.", + "required": false, + "caseExact": false, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "maxMailboxes", + "type": "int", + "multiValued": false, + "description": "The maximum number of mailboxes the domain can have. OPTIONAL.", + "required": false, + "caseExact": false, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "maxQuota", + "type": "int", + "multiValued": false, + "description": "The maximum quota, allowed for mailboxes of the domain (in MB). OPTIONAL.", + "required": false, + "caseExact": false, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "usedQuota", + "type": "int", + "multiValued": false, + "description": "The currently used quota by the mailboxes of the domain (in MB). OPTIONAL.", + "required": false, + "caseExact": false, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "active", + "type": "bool", + "multiValued": false, + "description": "A flag indicating whether the domain is currently active. REQUIRED.", + "required": true, + "caseExact": false, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "none" + } + ], + "meta": { + "resourceType": "Schema", + "location": "/v2/Schemas/urn:ietf:params:scim:schema:audriga:core:2.0:Domain" + } +} \ No newline at end of file diff --git a/config/config.default.php b/config/config.default.php index 3105797..3d58088 100644 --- a/config/config.default.php +++ b/config/config.default.php @@ -2,23 +2,23 @@ return [ 'isInProduction' => false, // Set to true when deploying in production - 'basePath' => null, // If you want to specify a base path for the Slim app, add it here (e.g., '/test/scim') + 'basePath' => '', // If you want to specify a base path for the Slim app, add it here (e.g., '/test/scim') 'supportedResourceTypes' => ['User', 'Group'], // Specify all the supported SCIM ResourceTypes by their names here // SQLite DB settings 'db' => [ 'driver' => 'sqlite', // Type of DB - 'database' => 'db/scim-mock.sqlite' // DB name + 'databaseFile' => 'db/scim-mock.sqlite' // DB name ], - // PFA MySQL DB settings + // MySQL DB settings //'db' => [ - // 'driver' => 'sqlite', // Type of DB + // 'driver' => 'mysql', // Type of DB // 'host' => 'localhost', // DB host // 'port' => '3306', // Port on DB host - // 'database' => 'postfix', // DB name - // 'user' => 'postfix', // DB user - // 'password' => 'postfix123' // DB user's password + // 'database' => 'db_name', // DB name + // 'user' => 'db_user', // DB user + // 'password' => 'db_password' // DB user's password //], // Monolog settings @@ -30,7 +30,6 @@ return [ // Bearer token settings 'jwt' => [ - 'secure' => false, 'secret' => 'secret' ] ]; diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..66ef8f6 --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,4 @@ +RewriteEngine On +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^ index.php [QSA,L] diff --git a/public/index.php b/public/index.php index d5751b2..aa580c1 100644 --- a/public/index.php +++ b/public/index.php @@ -1,78 +1,26 @@ enableCompilation(__DIR__ . '/../var/cache'); -} +// Create a new ScimServer instance and give it the project root +$scimServer = new ScimServer($scimServerPhpRoot); -// Set up a few Slim-related settings -$settings = [ - 'settings' => [ - 'determineRouteBeforeAppMiddleware' => false, - 'displayErrorDetails' => true, // set to false in production - 'addContentLengthHeader' => false, // Allow the web server to send the content-length header - ] -]; -$containerBuilder->addDefinitions($settings); +// Take the config file path and pass it to the scimServer instance +$configFilePath = __DIR__ . '/../config/config.php'; +$scimServer->setConfig($configFilePath); -// Set up common dependencies -$dependencies = require dirname(__DIR__) . '/src/Dependencies/dependencies.php'; -$dependencies($containerBuilder); +// Obtain custom dependencies (if any) and pass them to the scimServer instance +$dependencies = require __DIR__ . '/../src/Dependencies/mock-dependencies.php'; +$scimServer->setDependencies($dependencies); -// Set up system-specific dependencies -$dependencies = require dirname(__DIR__) . '/src/Dependencies/mock-dependencies.php'; -$dependencies($containerBuilder); +// Set the Authentication Middleware configured in the dependencies files above to the scimServer instance +//$scimServerPhpAuthMiddleware = 'AuthMiddleware'; +//$scimServer->setMiddleware(array($scimServerPhpAuthMiddleware)); -// Build PHP-DI Container instance -$container = $containerBuilder->build(); - -// Instantiate the app -AppFactory::setContainer($container); -$app = AppFactory::create(); -$callableResolver = $app->getCallableResolver(); -$responseFactory = $app->getResponseFactory(); - -// Set our app's base path if it's configured -if (isset($config['basePath']) && !empty($config['basePath'])) { - $app->setBasePath($config['basePath']); -} - -// Set up the ORM -$eloquent = require dirname(__DIR__) . '/src/eloquent.php'; -$eloquent($app); - -// Register routes -$routes = require dirname(__DIR__) . '/src/routes.php'; -$routes($app); - -// Add Routing Middleware -$app->addRoutingMiddleware(); -$app->addBodyParsingMiddleware(); - -// Add JWT middleware -$app->addMiddleware($container->get(JwtAuthentication::class)); - -// Instantiate our custom Http error handler that we need further down below -$errorHandler = new HttpErrorHandler($callableResolver, $responseFactory); - -// Add error middleware -$errorMiddleware = $app->addErrorMiddleware( - $config['isInProduction'] ? false : true, - true, - true -); -$errorMiddleware->setDefaultErrorHandler($errorHandler); - -// Run app -$app->run(); +// Start the scimServer +$scimServer->run(); diff --git a/src/Adapters/Groups/MockGroupAdapter.php b/src/Adapters/Groups/MockGroupAdapter.php index 2a6a0eb..e3c195e 100644 --- a/src/Adapters/Groups/MockGroupAdapter.php +++ b/src/Adapters/Groups/MockGroupAdapter.php @@ -3,76 +3,69 @@ namespace Opf\Adapters\Groups; use Opf\Adapters\AbstractAdapter; -use Opf\DataAccess\Groups\MockGroupDataAccess; +use Opf\Models\Mock\MockGroup; +use Opf\Models\SCIM\Standard\Groups\CoreGroup; +use Opf\Models\SCIM\Standard\Meta; +use Opf\Models\SCIM\Standard\MultiValuedAttribute; class MockGroupAdapter extends AbstractAdapter { - /** @var Opf\Models\MockGroup $group */ - private $group; - - public function getGroup() + public function getCoreGroup(?MockGroup $mockGroup): ?CoreGroup { - return $this->group; - } - - public function setGroup(MockGroupDataAccess $group) - { - $this->group = $group; - } - - public function getId() - { - if (isset($this->group->id) && !empty($this->group->id)) { - return $this->group->id; + if (!isset($mockGroup)) { + return null; } + + $coreGroup = new CoreGroup(); + $coreGroup->setId($mockGroup->getId()); + + $coreGroupMeta = new Meta(); + $coreGroupMeta->setResourceType("Group"); + $coreGroupMeta->setCreated($mockGroup->getCreatedAt()); + $coreGroupMeta->setLastModified($mockGroup->getUpdatedAt()); + $coreGroup->setMeta($coreGroupMeta); + + $coreGroup->setDisplayName($mockGroup->getDisplayName()); + + if ($mockGroup->getMembers() !== null && !empty($mockGroup->getMembers())) { + $coreGroupMembers = []; + foreach ($mockGroup->getMembers() as $mockGroupMember) { + $coreGroupMember = new MultiValuedAttribute(); + $coreGroupMember->setValue($mockGroupMember); + $coreGroupMembers[] = $coreGroupMember; + } + + $coreGroup->setMembers($coreGroupMembers); + } + + return $coreGroup; } - public function setId($id) + public function getMockGroup(?CoreGroup $coreGroup): ?MockGroup { - if (isset($id) && !empty($id)) { - $this->group->id = $id; + if (!isset($coreGroup)) { + return null; } - } - public function getCreatedAt() - { - if (isset($this->group->created_at) && !empty($this->group->created_at)) { - return $this->group->created_at; - } - } + $mockGroup = new MockGroup(); + $mockGroup->setId($coreGroup->getId()); - public function setCreatedAt($createdAt) - { - if (isset($createdAt) && !empty($createdAt)) { - $this->group->created_at = $createdAt; + if ($coreGroup->getMeta() !== null) { + $mockGroup->setCreatedAt($coreGroup->getMeta()->getCreated()); + $mockGroup->setUpdatedAt($coreGroup->getMeta()->getLastModified()); } - } - public function getDisplayName() - { - if (isset($this->group->displayName) && !empty($this->group->displayName)) { - return $this->group->displayName; - } - } + $mockGroup->setDisplayName($coreGroup->getDisplayName()); - public function setDisplayName($displayName) - { - if (isset($displayName) && !empty($displayName)) { - $this->group->displayName = $displayName; - } - } + if ($coreGroup->getMembers() !== null && !empty($coreGroup->getMembers())) { + $mockGroupMembers = []; + foreach ($coreGroup->getMembers() as $coreGroupMember) { + $mockGroupMembers[] = $coreGroupMember->getValue(); + } - public function getMembers() - { - if (isset($this->group->members) && !empty($this->group->members)) { - return $this->group->members; + $mockGroup->setMembers($mockGroupMembers); } - } - public function setMembers($members) - { - if (isset($members) && !empty($members)) { - $this->group->members = $members; - } + return $mockGroup; } } diff --git a/src/Adapters/Users/MockUserAdapter.php b/src/Adapters/Users/MockUserAdapter.php index a27064c..d09e43f 100644 --- a/src/Adapters/Users/MockUserAdapter.php +++ b/src/Adapters/Users/MockUserAdapter.php @@ -3,104 +3,54 @@ namespace Opf\Adapters\Users; use Opf\Adapters\AbstractAdapter; -use Opf\DataAccess\Users\MockUserDataAccess; +use Opf\Models\Mock\MockUser; +use Opf\Models\SCIM\Standard\Meta; +use Opf\Models\SCIM\Standard\Users\CoreUser; class MockUserAdapter extends AbstractAdapter { - /** @var Opf\Models\MockUser $user */ - private $user; - - public function getUser() + public function getCoreUser(?MockUser $mockUser): ?CoreUser { - return $this->user; - } - - public function setUser(MockUserDataAccess $user) - { - $this->user = $user; - } - - public function getId() - { - if (isset($this->user->id) && !empty($this->user->id)) { - return $this->user->id; + if (!isset($mockUser)) { + return null; } + + $coreUser = new CoreUser(); + $coreUser->setId($mockUser->getId()); + $coreUser->setExternalId($mockUser->getExternalId()); + + $coreUserMeta = new Meta(); + $coreUserMeta->setResourceType("User"); + $coreUserMeta->setCreated($mockUser->getCreatedAt()); + $coreUserMeta->setLastModified($mockUser->getUpdatedAt()); + $coreUser->setMeta($coreUserMeta); + + $coreUser->setUserName($mockUser->getUserName()); + $coreUser->setActive(boolval($mockUser->getActive())); + $coreUser->setProfileUrl($mockUser->getProfileUrl()); + + return $coreUser; } - public function setId($id) + public function getMockUser(?CoreUser $coreUser): ?MockUser { - if (isset($id) && !empty($id)) { - $this->user->id = $id; + if (!isset($coreUser)) { + return null; } - } - public function getUserName() - { - if (isset($this->user->userName) && !empty($this->user->userName)) { - return $this->user->userName; - } - } + $mockUser = new MockUser(); + $mockUser->setId($coreUser->getId()); - public function setUserName($userName) - { - if (isset($userName) && !empty($userName)) { - $this->user->userName = $userName; + if ($coreUser->getMeta() !== null) { + $mockUser->setCreatedAt($coreUser->getMeta()->getCreated()); + $mockUser->setUpdatedAt($coreUser->getMeta()->getLastModified()); } - } - public function getCreatedAt() - { - if (isset($this->user->created_at) && !empty($this->user->created_at)) { - return $this->user->created_at; - } - } + $mockUser->setUserName($coreUser->getUserName()); + $mockUser->setActive(boolval($coreUser->getActive())); + $mockUser->setExternalId($coreUser->getExternalId()); + $mockUser->setProfileUrl($coreUser->getProfileUrl()); - public function setCreatedAt($createdAt) - { - if (isset($createdAt) && !empty($createdAt)) { - $this->user->created_at = $createdAt; - } - } - - public function getActive() - { - if (isset($this->user->active) && !empty($this->user->active)) { - return boolval($this->user->active); - } - } - - public function setActive($active) - { - if (isset($active) && !empty($active)) { - $this->user->active = $active; - } - } - - public function getExternalId() - { - if (isset($this->user->externalId) && !empty($this->user->externalId)) { - return $this->user->externalId; - } - } - - public function setExternalId($externalId) - { - if (isset($externalId) && !empty($externalId)) { - $this->user->externalId = $externalId; - } - } - - public function getProfileUrl() - { - if (isset($this->user->profileUrl) && !empty($this->user->profileUrl)) { - return $this->user->profileUrl; - } - } - - public function setProfileUrl($profileUrl) - { - if (isset($profileUrl) && !empty($profileUrl)) { - $this->user->profileUrl = $profileUrl; - } + return $mockUser; } } diff --git a/src/Adapters/Users/PfaUserAdapter.php b/src/Adapters/Users/PfaUserAdapter.php deleted file mode 100644 index 15f03ee..0000000 --- a/src/Adapters/Users/PfaUserAdapter.php +++ /dev/null @@ -1,100 +0,0 @@ -setId($pfaUser->getUserName()); - $provisioningUser->setUserName($pfaUser->getUserName()); - - $scimUserMeta = new Meta(); - $scimUserMeta->setResourceType("User"); - $scimUserMeta->setCreated($pfaUser->getCreated()); - $scimUserMeta->setLastModified($pfaUser->getModified()); - $provisioningUser->setMeta($scimUserMeta); - - $name = new Name(); - $name->setFormatted($pfaUser->getName()); - $provisioningUser->setName($name); - $provisioningUser->setDisplayName($pfaUser->getName()); - $provisioningUser->setActive($pfaUser->getActive()); - $scimUserPhoneNumbers = new MultiValuedAttribute(); - $scimUserPhoneNumbers->setValue($pfaUser->getPhone()); - $provisioningUser->setPhoneNumbers(array($scimUserPhoneNumbers)); - - $provisioningUser->setSizeQuota($pfaUser->getQuota()); - - return $provisioningUser; - } - - public function getPfaUser(?ProvisioningUser $provisioningUser): ?PfaUser - { - if ($provisioningUser === null) { - return null; - } - $pfaUser = new PfaUser(); - - if (filter_var($provisioningUser->getUserName(), FILTER_VALIDATE_EMAIL)) { - $pfaUser->setUserName($provisioningUser->getUserName()); - } elseif (filter_var($provisioningUser->getId(), FILTER_VALIDATE_EMAIL)) { - $pfaUser->setUserName($provisioningUser->getId()); - } else { - $pfaUser->setUserName($provisioningUser->getEmails()[0]->getValue()); - } - $pfaUser->setPassword($provisioningUser->getPassword()); - if ($provisioningUser->getName() !== null) { - if (!empty($provisioningUser->getName()->getFormatted())) { - $pfaUser->setName($provisioningUser->getName()->getFormatted()); - } else { - $formattedName = ""; - if (!empty($provisioningUser->getName()->getHonorificPrefix())) { - $formattedName = $formattedName . $provisioningUser->getName()->getHonorificPrefix() . " "; - } - if (!empty($provisioningUser->getName()->getGivenName())) { - $formattedName = $formattedName . $provisioningUser->getName()->getGivenName() . " "; - } - if (!empty($provisioningUser->getName()->getFamilyName())) { - $formattedName = $formattedName . $provisioningUser->getName()->getFamilyName() . " "; - } - if (!empty($provisioningUser->getName()->getHonorificSuffix())) { - $formattedName = $formattedName . $provisioningUser->getName()->getHonorificSuffix(); - } - $formattedName = trim($formattedName); - if (!empty($formattedName)) { - $pfaUser->setName($formattedName); - } else { - $pfaUser->setName($provisioningUser->getDisplayName()); - } - } - } else { - $pfaUser->setName($provisioningUser->getDisplayName()); - } - $pfaUser->setMaildir(Util::getDomainFromEmail($pfaUser->getUserName()) . "/" - . Util::getLocalPartFromEmail($pfaUser->getUserName()) . "/"); - // We default PFA quota to 0 (unlimited) if not set - $pfaUser->setQuota($provisioningUser->getSizeQuota()); - $pfaUser->setLocalPart(Util::getLocalPartFromEmail($pfaUser->getUserName())); - $pfaUser->setDomain(Util::getDomainFromEmail($pfaUser->getUserName())); - $pfaUser->setActive($provisioningUser->getActive()); - if (isset($provisioningUser->getPhoneNumbers()[0])) { - $pfaUser->setPhone($provisioningUser->getPhoneNumbers()[0]->getValue()); - } - - return $pfaUser; - } -} diff --git a/src/Controllers/Domains/CreateDomainAction.php b/src/Controllers/Domains/CreateDomainAction.php new file mode 100644 index 0000000..fb507a5 --- /dev/null +++ b/src/Controllers/Domains/CreateDomainAction.php @@ -0,0 +1,61 @@ +repository = $this->container->get('DomainsRepository'); + } + + public function __invoke(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface + { + $this->logger->info("CREATE Domain"); + $this->logger->info($request->getBody()); + + $uri = $request->getUri(); + $baseUrl = sprintf('%s://%s', $uri->getScheme(), $uri->getAuthority() . $this->basePath); + + try { + $domain = $this->repository->create($request->getParsedBody()); + + if (isset($domain) && !empty($domain)) { + $this->logger->info("Created domain / domain=" . $domain->getId()); + + $scimDomain = $domain->toSCIM(false, $baseUrl); + + $responseBody = json_encode($scimDomain, JSON_UNESCAPED_SLASHES); + $this->logger->info($responseBody); + $response = new Response($status = 201); + $response->getBody()->write($responseBody); + $response = $response->withHeader('Content-Type', 'application/scim+json'); + return $response; + } else { + $this->logger->error("Error creating domain"); + $errorResponseBody = json_encode( + ["Errors" => ["description" => "Error creating domain", "code" => 400]] + ); + $response = new Response($status = 400); + $response->getBody()->write($errorResponseBody); + $response = $response->withHeader('Content-Type', 'application/scim+json'); + return $response; + } + } catch (Exception $e) { + $this->logger->error("Error creating domain: " . $e->getMessage()); + $errorResponseBody = json_encode(["Errors" => ["description" => $e->getMessage(), "code" => 400]]); + $response = new Response($status = 400); + $response->getBody()->write($errorResponseBody); + $response = $response->withHeader('Content-Type', 'application/scim+json'); + return $response; + } + } +} diff --git a/src/Controllers/Domains/DeleteDomainAction.php b/src/Controllers/Domains/DeleteDomainAction.php new file mode 100644 index 0000000..6ad9afc --- /dev/null +++ b/src/Controllers/Domains/DeleteDomainAction.php @@ -0,0 +1,33 @@ +repository = $this->container->get('DomainsRepository'); + } + + public function __invoke(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface + { + $this->logger->info("DELETE Domain"); + $id = $request->getAttribute('id'); + $this->logger->info("ID: " . $id); + $deleteRes = $this->repository->delete($id); + if (!$deleteRes) { + $this->logger->info("Not found"); + return $response->withStatus(404); + } + $this->logger->info("Domain deleted"); + + $response = $response->withHeader('Content-Type', 'application/scim+json'); + return $response->withStatus(204); + } +} diff --git a/src/Controllers/Domains/GetDomainAction.php b/src/Controllers/Domains/GetDomainAction.php new file mode 100644 index 0000000..ac9f73d --- /dev/null +++ b/src/Controllers/Domains/GetDomainAction.php @@ -0,0 +1,42 @@ +repository = $this->container->get('DomainsRepository'); + } + + public function __invoke(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface + { + $this->logger->info("GET Domain"); + $uri = $request->getUri(); + $baseUrl = sprintf('%s://%s', $uri->getScheme(), $uri->getAuthority() . $this->basePath); + + $id = $request->getAttribute('id'); + $this->logger->info("ID: " . $id); + $domain = $this->repository->getOneById($id); + if (!isset($domain) || empty($domain)) { + $this->logger->info("Not found"); + return $response->withStatus(404); + } + + $scimDomain = $domain->toSCIM(false, $baseUrl); + + $responseBody = json_encode($scimDomain, JSON_UNESCAPED_SLASHES); + $this->logger->info($responseBody); + $response = new Response($status = 200); + $response->getBody()->write($responseBody); + $response = $response->withHeader('Content-Type', 'application/scim+json'); + return $response; + } +} diff --git a/src/Controllers/Domains/ListDomainsAction.php b/src/Controllers/Domains/ListDomainsAction.php new file mode 100644 index 0000000..1bdeb1d --- /dev/null +++ b/src/Controllers/Domains/ListDomainsAction.php @@ -0,0 +1,49 @@ +repository = $this->container->get('DomainsRepository'); + } + + public function __invoke(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface + { + $this->logger->info("GET Domains"); + $filter = ''; + if (!empty($request->getQueryParams()['filter'])) { + $this->logger->info("Filter --> " . $request->getQueryParams()['filter']); + $filter = $request->getQueryParams()['filter']; + } + $uri = $request->getUri(); + $baseUrl = sprintf('%s://%s', $uri->getScheme(), $uri->getAuthority() . $this->basePath); + + $domains = []; + $domains = $this->repository->getAll($filter); + + $scimDomains = []; + if (!empty($domains)) { + foreach ($domains as $domain) { + $scimDomains[] = $domain->toSCIM(false, $baseUrl); + } + } + $scimDomainCollection = (new CoreCollection($scimDomains))->toSCIM(false); + + $responseBody = json_encode($scimDomainCollection, JSON_UNESCAPED_SLASHES); + $this->logger->info($responseBody); + $response = new Response($status = 200); + $response->getBody()->write($responseBody); + $response = $response->withHeader('Content-Type', 'application/scim+json'); + return $response; + } +} diff --git a/src/Controllers/Domains/UpdateDomainAction.php b/src/Controllers/Domains/UpdateDomainAction.php new file mode 100644 index 0000000..cecc9f0 --- /dev/null +++ b/src/Controllers/Domains/UpdateDomainAction.php @@ -0,0 +1,69 @@ +repository = $this->container->get('DomainsRepository'); + } + + public function __invoke(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface + { + $this->logger->info("UPDATE Domain"); + $this->logger->info($request->getBody()); + + $uri = $request->getUri(); + $baseUrl = sprintf('%s://%s', $uri->getScheme(), $uri->getAuthority() . $this->basePath); + + $id = $request->getAttribute('id'); + $this->logger->info("ID: " . $id); + + // Try to find a domain with the supplied ID + // and if it doesn't exist, return a 404 + $domain = $this->repository->getOneById($id); + if (!isset($domain) || empty($domain)) { + $this->logger->info("Not found"); + return $response->withStatus(404); + } + + try { + $domain = $this->repository->update($id, $request->getParsedBody()); + if (isset($domain) && !empty($domain)) { + $scimDomain = $domain->toSCIM(false, $baseUrl); + + $responseBody = json_encode($scimDomain, JSON_UNESCAPED_SLASHES); + $this->logger->info($responseBody); + $response = new Response($status = 200); + $response->getBody()->write($responseBody); + $response = $response->withHeader('Content-Type', 'application/scim+json'); + return $response; + } else { + $this->logger->error("Error updating domain"); + $errorResponseBody = json_encode( + ["Errors" => ["decription" => "Error updating domain", "code" => 400]] + ); + $response = new Response($status = 400); + $response->getBody()->write($errorResponseBody); + $response = $response->withHeader('Content-Type', 'application/scim+json'); + return $response; + } + } catch (Exception $e) { + $this->logger->error("Error updating domain: " . $e->getMessage()); + $errorResponseBody = json_encode(["Errors" => ["description" => $e->getMessage(), "code" => 400]]); + $response = new Response($status = 400); + $response->getBody()->write($errorResponseBody); + $response = $response->withHeader('Content-Type', 'application/scim+json'); + return $response; + } + } +} diff --git a/src/Controllers/Groups/ListGroupsAction.php b/src/Controllers/Groups/ListGroupsAction.php index 03549a6..06e6a3f 100644 --- a/src/Controllers/Groups/ListGroupsAction.php +++ b/src/Controllers/Groups/ListGroupsAction.php @@ -20,12 +20,16 @@ final class ListGroupsAction extends Controller public function __invoke(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface { $this->logger->info("GET Groups"); - + $filter = ''; + if (!empty($request->getQueryParams()['filter'])) { + $this->logger->info("Filter --> " . $request->getQueryParams()['filter']); + $filter = $request->getQueryParams()['filter']; + } $uri = $request->getUri(); $baseUrl = sprintf('%s://%s', $uri->getScheme(), $uri->getAuthority() . $this->basePath); $groups = []; - $groups = $this->repository->getAll(); + $groups = $this->repository->getAll($filter); $scimGroups = []; if (!empty($groups)) { diff --git a/src/Controllers/JWT/GenerateJWTAction.php b/src/Controllers/JWT/GenerateJWTAction.php deleted file mode 100644 index 92602fe..0000000 --- a/src/Controllers/JWT/GenerateJWTAction.php +++ /dev/null @@ -1,28 +0,0 @@ - "admin", "password" => "admin"], $settings['secret']); - - $responseBody = json_encode(['Bearer' => $token], JSON_UNESCAPED_SLASHES); - - $response = new Response($status = 200); - $response->getBody()->write($responseBody); - $response = $response->withHeader('Content-Type', 'application/json'); - return $response; - } -} diff --git a/src/Controllers/ServiceProviders/ListResourceTypesAction.php b/src/Controllers/ServiceProviders/ListResourceTypesAction.php index fb562cd..e5c55a9 100644 --- a/src/Controllers/ServiceProviders/ListResourceTypesAction.php +++ b/src/Controllers/ServiceProviders/ListResourceTypesAction.php @@ -21,51 +21,7 @@ final class ListResourceTypesAction extends Controller $uri = $request->getUri(); $baseUrl = sprintf('%s://%s', $uri->getScheme(), $uri->getAuthority() . $this->basePath); - // Check which resource types are supported via the config file and in this method further down below - // make sure to only return those that are indeed supported - $config = Util::getConfigFile(); - $supportedResourceTypes = $config['supportedResourceTypes']; - - $scimResourceTypes = []; - - if (in_array('User', $supportedResourceTypes)) { - $userResourceType = new CoreResourceType(); - $userResourceType->setId("User"); - $userResourceType->setName("User"); - $userResourceType->setEndpoint("/Users"); - $userResourceType->setDescription("User Account"); - $userResourceType->setSchema(Util::USER_SCHEMA); - - if (in_array('EnterpriseUser', $supportedResourceTypes)) { - $enterpriseUserSchemaExtension = new CoreSchemaExtension(); - $enterpriseUserSchemaExtension->setSchema(Util::ENTERPRISE_USER_SCHEMA); - $enterpriseUserSchemaExtension->setRequired(true); - - $userResourceType->setSchemaExtensions(array($enterpriseUserSchemaExtension)); - } - if (in_array('ProvisioningUser', $supportedResourceTypes)) { - $provisioningUserSchemaExtension = new CoreSchemaExtension(); - $provisioningUserSchemaExtension->setSchema(Util::PROVISIONING_USER_SCHEMA); - $provisioningUserSchemaExtension->setRequired(true); - - $userResourceType->setSchemaExtensions(array($provisioningUserSchemaExtension)); - } - - $scimResourceTypes[] = $userResourceType->toSCIM(false, $baseUrl); - } - - if (in_array('Group', $supportedResourceTypes)) { - $groupResourceType = new CoreResourceType(); - $groupResourceType->setId("Group"); - $groupResourceType->setName("Group"); - $groupResourceType->setEndpoint("/Groups"); - $groupResourceType->setDescription("Group"); - $groupResourceType->setSchema("urn:ietf:params:scim:schemas:core:2.0:Group"); - $groupResourceType->setSchemaExtensions([]); - - $scimResourceTypes[] = $groupResourceType->toSCIM(false, $baseUrl); - } - + $scimResourceTypes = Util::getResourceTypes($baseUrl); $scimResourceTypeCollection = (new CoreCollection($scimResourceTypes))->toSCIM(false); $responseBody = json_encode($scimResourceTypeCollection, JSON_UNESCAPED_SLASHES); diff --git a/src/Controllers/ServiceProviders/ListSchemasAction.php b/src/Controllers/ServiceProviders/ListSchemasAction.php index 3de4cb8..1269196 100644 --- a/src/Controllers/ServiceProviders/ListSchemasAction.php +++ b/src/Controllers/ServiceProviders/ListSchemasAction.php @@ -15,20 +15,10 @@ final class ListSchemasAction extends Controller { $this->logger->info("GET Schemas"); - $config = Util::getConfigFile(); - $supportedSchemas = $config['supportedResourceTypes']; - $mandatorySchemas = ['Schema', 'ResourceType']; + $scimSchemas = Util::getSchemas(); - $scimSchemas = []; - - // We store the schemas that the SCIM server supports in separate JSON files - // That's why we try to read them here and add them to $scimSchemas, which - // in turn is then put into the SCIM response body - $pathToSchemasDir = dirname(__DIR__, 3) . '/config/Schema'; - $schemaFiles = scandir($pathToSchemasDir, SCANDIR_SORT_NONE); - - // If scandir() failed (i.e., it returned false), then return 404 (is this spec-compliant?) - if ($schemaFiles === false) { + // If there were no schemas found, return 404 + if (is_null($scimSchemas)) { $this->logger->info("No Schemas found"); $response = new Response($status = 404); $response = $response->withHeader('Content-Type', 'application/scim+json'); @@ -36,18 +26,6 @@ final class ListSchemasAction extends Controller return $response; } - foreach ($schemaFiles as $schemaFile) { - if (!in_array($schemaFile, array('.', '..'))) { - $scimSchemaJsonDecoded = json_decode(file_get_contents($pathToSchemasDir . '/' . $schemaFile), true); - - // Only return schemas that are either mandatory (like the 'Schema' and 'ResourceType' ones) - // or supported by the server - if (in_array($scimSchemaJsonDecoded['name'], array_merge($supportedSchemas, $mandatorySchemas))) { - $scimSchemas[] = $scimSchemaJsonDecoded; - } - } - } - $scimSchemasCollection = (new CoreCollection($scimSchemas))->toSCIM(false); $responseBody = json_encode($scimSchemasCollection, JSON_UNESCAPED_SLASHES); diff --git a/src/Controllers/ServiceProviders/ListServiceProviderConfigurationsAction.php b/src/Controllers/ServiceProviders/ListServiceProviderConfigurationsAction.php index 8cb8e2a..a49f54f 100644 --- a/src/Controllers/ServiceProviders/ListServiceProviderConfigurationsAction.php +++ b/src/Controllers/ServiceProviders/ListServiceProviderConfigurationsAction.php @@ -3,6 +3,7 @@ namespace Opf\Controllers\ServiceProviders; use Opf\Controllers\Controller; +use Opf\Util\Util; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Slim\Psr7\Response; @@ -13,12 +14,9 @@ final class ListServiceProviderConfigurationsAction extends Controller { $this->logger->info("GET ServiceProviderConfigurations"); - $pathToServiceProviderConfigurationFile = - dirname(__DIR__, 3) . '/config/ServiceProviderConfig/serviceProviderConfig.json'; + $scimServiceProviderConfiguration = Util::getServiceProviderConfig(); - $scimServiceProviderConfigurationFile = file_get_contents($pathToServiceProviderConfigurationFile); - - if ($scimServiceProviderConfigurationFile === false) { + if (is_null($scimServiceProviderConfiguration)) { $this->logger->info("No ServiceProviderConfiguration found"); $response = new Response($status = 404); $response = $response->withHeader('Content-Type', 'application/scim+json'); @@ -26,7 +24,7 @@ final class ListServiceProviderConfigurationsAction extends Controller return $response; } - $responseBody = $scimServiceProviderConfigurationFile; + $responseBody = $scimServiceProviderConfiguration; $this->logger->info($responseBody); $response = new Response($status = 200); $response->getBody()->write($responseBody); diff --git a/src/Controllers/Users/ListUsersAction.php b/src/Controllers/Users/ListUsersAction.php index f4a6d1e..9dce252 100644 --- a/src/Controllers/Users/ListUsersAction.php +++ b/src/Controllers/Users/ListUsersAction.php @@ -21,25 +21,17 @@ final class ListUsersAction extends Controller public function __invoke(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface { $this->logger->info("GET Users"); + $filter = ''; if (!empty($request->getQueryParams()['filter'])) { $this->logger->info("Filter --> " . $request->getQueryParams()['filter']); + $filter = $request->getQueryParams()['filter']; } $uri = $request->getUri(); $baseUrl = sprintf('%s://%s', $uri->getScheme(), $uri->getAuthority() . $this->basePath); $userName = null; $users = []; - if (!empty($request->getQueryParams('filter'))) { - $userName = Util::getUserNameFromFilter($request->getQueryParams()['filter']); - if (!empty($userName)) { - $user = $this->repository->getOneByUserName(); - if (isset($user) && !empty($user)) { - $users[] = $user; - } - } - } else { - $users = $this->repository->getAll(); - } + $users = $this->repository->getAll($filter); $scimUsers = []; if (!empty($users)) { diff --git a/src/DataAccess/Groups/MockGroupDataAccess.php b/src/DataAccess/Groups/MockGroupDataAccess.php index 5297390..67d6b3d 100644 --- a/src/DataAccess/Groups/MockGroupDataAccess.php +++ b/src/DataAccess/Groups/MockGroupDataAccess.php @@ -2,59 +2,211 @@ namespace Opf\DataAccess\Groups; -use Illuminate\Database\Eloquent\Model; +use Exception; +use Monolog\Handler\StreamHandler; +use Monolog\Logger; +use Opf\Models\Mock\MockGroup; use Opf\Util\Util; +use PDO; +use PDOException; -class MockGroupDataAccess extends Model +class MockGroupDataAccess { - protected $table = 'groups'; - protected $fillable = ['id', 'displayName', 'members', 'created_at']; - public $incrementing = false; + /** @var PDO */ + private $dbConnection; - public $schemas = ["urn:ietf:params:scim:schemas:core:2.0:Group"]; - private $baseLocation; + /** @var \Monolog\Logger */ + private $logger; - public function fromArray($data) + public function __construct() { - $this->id = isset($data['id']) ? $data['id'] : (isset($this->id) ? $this->id : Util::genUuid()); - $this->displayName = $data['displayName']; - $this->members = is_string($data['members']) ? $data['members'] : implode(",", $data['members']); - $this->created_at = isset($data['created']) ? Util::string2dateTime($data['created']) : new \DateTime('NOW'); + // Instantiate our logger + $this->logger = new Logger(MockGroupDataAccess::class); + $this->logger->pushHandler(new StreamHandler(__DIR__ . '/../../../logs/app.log', Logger::DEBUG)); + + // Try to obtain a DSN via the Util class and complain with an Exception if there's no DSN + $dsn = Util::buildDbDsn(); + if (!isset($dsn)) { + throw new Exception("Can't obtain DSN to connect to DB"); + } + + // Create the DB connection with PDO (no need to pass username or password for mock DB) + $this->dbConnection = new PDO($dsn, null, null); + + // Tell PDO explicitly to throw exceptions on errors, so as to have more info when debugging DB operations + $this->dbConnection->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); } - public function fromSCIM($data) + public function getAll(): ?array { - $this->id = isset($data['id']) ? $data['id'] : (isset($this->id) ? $this->id : Util::genUuid()); - $this->displayName = $data['displayName']; - $this->members = is_string($data['members']) ? $data['members'] : implode(",", $data['members']); - $this->created_at = isset($data['created']) ? Util::string2dateTime($data['created']) : new \DateTime('NOW'); + if (isset($this->dbConnection)) { + $selectStatement = $this->dbConnection->query("SELECT * from groups"); + if ($selectStatement) { + $mockGroups = []; + $mockGroupsRaw = $selectStatement->fetchAll(PDO::FETCH_ASSOC); + foreach ($mockGroupsRaw as $group) { + $mockGroup = new MockGroup(); + $mockGroup->mapFromArray($group); + $mockGroups[] = $mockGroup; + } + return $mockGroups; + } + + $this->logger->error("Couldn't read all groups from mock DB. SELECT query to DB failed"); + return null; + } } - public function toSCIM($encode = true, $baseLocation = 'http://localhost:8888/v1') + public function getOneById($id): ?MockGroup { - $data = [ - 'schemas' => $this->schemas, - 'id' => $this->id, - 'displayName' => $this->displayName, - 'members' => [], - 'meta' => [ - 'created' => Util::dateTime2string($this->created_at), - 'location' => $baseLocation . '/Groups/' . $this->id - ] - ]; + if (isset($id) && !empty($id)) { + if (isset($this->dbConnection)) { + try { + $selectOnePreparedStatement = $this->dbConnection->prepare( + "SELECT * FROM groups WHERE id = ?" + ); - if (!empty($this->members)) { - $data['members'] = explode(',', $this->members); + $selectRes = $selectOnePreparedStatement->execute([$id]); + + if ($selectRes) { + $mockGroupsRaw = $selectOnePreparedStatement->fetchAll(PDO::FETCH_ASSOC); + if ($mockGroupsRaw) { + $mockGroup = new MockGroup(); + $mockGroup->mapFromArray($mockGroupsRaw[0]); + return $mockGroup; + } else { + return null; + } + } else { + return null; + } + } catch (PDOException $e) { + $this->logger->error($e->getMessage()); + } + } } - if (isset($this->updated_at)) { - $data['meta']['updated'] = Util::dateTime2string($this->updated_at); - } + $this->logger->error( + "Argument provided to getOneById in class " . MockGroupDataAccess::class . " is not set or empty" + ); + return null; + } - if ($encode) { - $data = json_encode($data); - } + public function create(MockGroup $groupToCreate): ?MockGroup + { + $dateNow = date('Y-m-d H:i:s'); - return $data; + if (isset($this->dbConnection)) { + try { + $insertStatement = $this->dbConnection->prepare( + "INSERT INTO groups + (id, displayName, members, created_at, updated_at) + VALUES (?, ?, ?, ?, ?)" + ); + + $groupToCreate->setId(Util::genUuid()); + + $insertRes = $insertStatement->execute([ + $groupToCreate->getId(), + $groupToCreate->getDisplayName(), + $groupToCreate->getMembers() !== null && !empty($groupToCreate->getMembers()) + ? $groupToCreate->getMembers() : "", + $dateNow, + $dateNow + ]); + + if ($insertRes) { + $this->logger->info("Created group " . $groupToCreate->getDisplayName()); + return $groupToCreate; + } else { + return null; + } + } catch (PDOException $e) { + $this->logger->error($e->getMessage()); + } + } else { + $this->logger->error("DB connection not available"); + } + $this->logger->error("Error creating group"); + return null; + } + + public function update(string $id, MockGroup $groupToUpdate): ?MockGroup + { + $dateNow = date('Y-m-d H:i:s'); + + if (isset($this->dbConnection)) { + try { + $query = ""; + $values = array(); + + if ($groupToUpdate->getDisplayName() !== null) { + $query = $query . "displayName = ?, "; + $values[] = $groupToUpdate->getDisplayName(); + } + + if ($groupToUpdate->getMembers() !== null) { + $query = $query . "members = ?, "; + + // We need to transform the string array of user IDs to a single string + $values[] = implode(",", $groupToUpdate->getMembers()); + } + + if (empty($query)) { + $this->logger->error("No group properties to update"); + return null; + } + + $query = $query . "updated_at = ? "; + $values[] = $dateNow; + $values[] = $id; + + $updateStatement = $this->dbConnection->prepare( + "UPDATE groups SET " . $query . " WHERE id = ?" + ); + + $updateRes = $updateStatement->execute($values); + + if ($updateRes) { + $this->logger->info("Updated group " . $id); + return $this->getOneById($id); + } else { + $this->logger->error("Error updating group " . $id); + return null; + } + } catch (PDOException $e) { + $this->logger->error($e->getMessage()); + } + } else { + $this->logger->error("Error updating group " . $id . " - DB connection unavailable"); + } + $this->logger->error("Error updating group " . $id); + return null; + } + + public function delete($id): bool + { + if (isset($this->dbConnection)) { + try { + $deleteStatement = $this->dbConnection->prepare( + "DELETE FROM groups WHERE id = ?" + ); + $deleteRes = $deleteStatement->execute([$id]); + + // In case the delete was successful, return true + if ($deleteRes) { + $this->logger->info("Deleted group " . $id); + return true; + } else { + return false; + } + } catch (PDOException $e) { + $this->logger->error($e->getMessage()); + } + } else { + $this->logger->error("Error deleting group " . $id . " - DB connection unavailable"); + } + $this->logger->error("Error deleting group " . $id); + return false; } } diff --git a/src/DataAccess/Users/MockUserDataAccess.php b/src/DataAccess/Users/MockUserDataAccess.php index 59b8e0c..321aac0 100644 --- a/src/DataAccess/Users/MockUserDataAccess.php +++ b/src/DataAccess/Users/MockUserDataAccess.php @@ -2,67 +2,220 @@ namespace Opf\DataAccess\Users; -use Illuminate\Database\Eloquent\Model; +use Exception; +use Monolog\Handler\StreamHandler; +use Monolog\Logger; +use Opf\Models\Mock\MockUser; use Opf\Util\Util; +use PDO; +use PDOException; -class MockUserDataAccess extends Model +class MockUserDataAccess { - protected $table = 'users'; - protected $fillable = ['id', 'userName', 'created_at', 'active', - 'externalId', 'profileUrl']; - public $incrementing = false; + /** @var PDO */ + private $dbConnection; - public $schemas = ["urn:ietf:params:scim:schemas:core:2.0:User"]; - private $baseLocation; + /** @var \Monolog\Logger */ + private $logger; - public function fromArray($data) + public function __construct() { - $this->id = isset($data['id']) ? $data['id'] : (isset($this->id) ? $this->id : Util::genUuid()); - $this->userName = isset($data['userName']) ? $data['userName'] : null; - $this->created_at = isset($data['created']) ? Util::string2dateTime($data['created']) - : (isset($this->created_at) ? $this->created_at : new \DateTime('NOW')); - $this->active = isset($data['active']) ? $data['active'] : true; + // Instantiate our logger + $this->logger = new Logger(MockUserDataAccess::class); + $this->logger->pushHandler(new StreamHandler(__DIR__ . '/../../../logs/app.log', Logger::DEBUG)); - $this->externalId = isset($data['externalId']) ? $data['externalId'] : null; - $this->profileUrl = isset($data['profileUrl']) ? $data['profileUrl'] : null; - } - - public function fromSCIM($data) - { - $this->id = isset($data['id']) ? $data['id'] : (isset($this->id) ? $this->id : Util::genUuid()); - $this->userName = isset($data['userName']) ? $data['userName'] : null; - $this->created_at = isset($data['meta']) && isset($data['meta']['created']) - ? Util::string2dateTime($data['meta']['created']) - : (isset($this->created_at) ? $this->created_at : new \DateTime('NOW')); - $this->active = isset($data['active']) ? $data['active'] : true; - - $this->externalId = isset($data['externalId']) ? $data['externalId'] : null; - $this->profileUrl = isset($data['profileUrl']) ? $data['profileUrl'] : null; - } - - public function toSCIM($encode = true, $baseLocation = 'http://localhost:8888/v1') - { - $data = [ - 'schemas' => $this->schemas, - 'id' => $this->id, - 'externalId' => $this->externalId, - 'meta' => [ - 'created' => Util::dateTime2string($this->created_at), - 'location' => $baseLocation . '/Users/' . $this->id - ], - 'userName' => $this->userName, - 'profileUrl' => $this->profileUrl, - 'active' => (bool) $this->active - ]; - - if (isset($this->updated_at)) { - $data['meta']['updated'] = Util::dateTime2string($this->updated_at); + // Try to obtain a DSN via the Util class and complain with an Exception if there's no DSN + $dsn = Util::buildDbDsn(); + if (!isset($dsn)) { + throw new Exception("Can't obtain DSN to connect to DB"); } - if ($encode) { - $data = json_encode($data); + // Create the DB connection with PDO (no need to pass username or password for mock DB) + $this->dbConnection = new PDO($dsn, null, null); + + // Tell PDO explicitly to throw exceptions on errors, so as to have more info when debugging DB operations + $this->dbConnection->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + } + + public function getAll(): ?array + { + if (isset($this->dbConnection)) { + $selectStatement = $this->dbConnection->query("SELECT * from users"); + if ($selectStatement) { + $mockUsers = []; + $mockUsersRaw = $selectStatement->fetchAll(PDO::FETCH_ASSOC); + foreach ($mockUsersRaw as $user) { + $mockUser = new MockUser(); + $mockUser->mapFromArray($user); + $mockUsers[] = $mockUser; + } + return $mockUsers; + } + + $this->logger->error("Couldn't read all users from mock DB. SELECT query to DB failed"); + return null; + } + } + + public function getOneById($id): ?MockUser + { + if (isset($id) && !empty($id)) { + if (isset($this->dbConnection)) { + try { + $selectOnePreparedStatement = $this->dbConnection->prepare( + "SELECT * FROM users WHERE id = ?" + ); + + $selectRes = $selectOnePreparedStatement->execute([$id]); + + if ($selectRes) { + $mockUsersRaw = $selectOnePreparedStatement->fetchAll(PDO::FETCH_ASSOC); + if ($mockUsersRaw) { + $mockUser = new MockUser(); + $mockUser->mapFromArray($mockUsersRaw[0]); + return $mockUser; + } else { + return null; + } + } else { + return null; + } + } catch (PDOException $e) { + $this->logger->error($e->getMessage()); + } + } } - return $data; + $this->logger->error( + "Argument provided to getOneById in class " . MockUserDataAccess::class . " is not set or empty" + ); + return null; + } + + public function create(MockUser $userToCreate): ?MockUser + { + $dateNow = date('Y-m-d H:i:s'); + + if (isset($this->dbConnection)) { + try { + $insertStatement = $this->dbConnection->prepare( + "INSERT INTO users + (id, userName, active, externalId, profileUrl, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)" + ); + + $userToCreate->setId(Util::genUuid()); + + $insertRes = $insertStatement->execute([ + $userToCreate->getId(), + $userToCreate->getUserName(), + $userToCreate->getActive(), + $userToCreate->getExternalId(), + $userToCreate->getProfileUrl(), + $dateNow, + $dateNow + ]); + + if ($insertRes) { + $this->logger->info("Created user " . $userToCreate->getUserName()); + return $userToCreate; + } else { + return null; + } + } catch (PDOException $e) { + $this->logger->error($e->getMessage()); + } + } else { + $this->logger->error("DB connection not available"); + } + $this->logger->error("Error creating user"); + return null; + } + + public function update(string $id, MockUser $userToUpdate): ?MockUser + { + $dateNow = date('Y-m-d H:i:s'); + + if (isset($this->dbConnection)) { + try { + $query = ""; + $values = array(); + + if ($userToUpdate->getUserName() !== null) { + $query = $query . "userName = ?, "; + $values[] = $userToUpdate->getUserName(); + } + + if ($userToUpdate->getActive() !== null) { + $query = $query . "active = ?, "; + $values[] = $userToUpdate->getActive(); + } + + if ($userToUpdate->getProfileUrl() !== null) { + $query = $query . "profileUrl = ?, "; + $values[] = $userToUpdate->getProfileUrl(); + } + + if ($userToUpdate->getExternalId() !== null) { + $query = $query . "externalId = ?, "; + $values[] = $userToUpdate->getExternalId(); + } + + if (empty($query)) { + $this->logger->error("No user properties to update"); + return null; + } + + $query = $query . "updated_at = ? "; + $values[] = $dateNow; + $values[] = $id; + + $updateStatement = $this->dbConnection->prepare( + "UPDATE users SET " . $query . " WHERE id = ?" + ); + + $updateRes = $updateStatement->execute($values); + + if ($updateRes) { + $this->logger->info("Updated user " . $id); + return $this->getOneById($id); + } else { + $this->logger->error("Error updating user " . $id); + return null; + } + } catch (PDOException $e) { + $this->logger->error($e->getMessage()); + } + } else { + $this->logger->error("Error updating user " . $id . " - DB connection unavailable"); + } + $this->logger->error("Error updating user " . $id); + return null; + } + + public function delete($id): bool + { + if (isset($this->dbConnection)) { + try { + $deleteStatement = $this->dbConnection->prepare( + "DELETE FROM users WHERE id = ?" + ); + $deleteRes = $deleteStatement->execute([$id]); + + // In case the delete was successful, return true + if ($deleteRes) { + $this->logger->info("Deleted user " . $id); + return true; + } else { + return false; + } + } catch (PDOException $e) { + $this->logger->error($e->getMessage()); + } + } else { + $this->logger->error("Error deleting user " . $id . " - DB connection unavailable"); + } + $this->logger->error("Error deleting user " . $id); + return false; } } diff --git a/src/DataAccess/Users/PfaUserDataAccess.php b/src/DataAccess/Users/PfaUserDataAccess.php deleted file mode 100644 index 9992577..0000000 --- a/src/DataAccess/Users/PfaUserDataAccess.php +++ /dev/null @@ -1,319 +0,0 @@ -logger = new Logger(PfaUserDataAccess::class); - $this->logger->pushHandler(new StreamHandler(__DIR__ . '/../../../logs/app.log', Logger::DEBUG)); - - // Try to obtain a DSN via the Util class and complain with an Exception if there's no DSN - $dsn = Util::buildDbDsn(); - if (!isset($dsn)) { - throw new Exception("Can't obtain DSN to connect to DB"); - } - - $this->config = Util::getConfigFile(); - if (isset($this->config) && !empty($this->config)) { - if (isset($this->config['db']) && !empty($this->config['db'])) { - if ( - isset($this->config['db']['user']) - && !empty($this->config['db']['user']) - && isset($this->config['db']['password']) - && !empty($this->config['db']['password']) - ) { - $dbUsername = $this->config['db']['user']; - $dbPassword = $this->config['db']['password']; - } else { - throw new Exception("No DB username and/or password provided to connect to DB"); - } - } - } - - // Create the DB connection with PDO - $this->dbConnection = new PDO($dsn, $dbUsername, $dbPassword); - - // Tell PDO explicitly to throw exceptions on errors, so as to have more info when debugging DB operations - $this->dbConnection->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - } - - public function getAll(): ?array - { - if (isset($this->dbConnection)) { - // TODO: should we use a variable for the table name? - // PFA users are 'mailbox' entries (see also https://web.audriga.com/mantis/view.php?id=5806#c28866): - // - with a corresponding 'alias' entry - // - with the 'alias.address' value being part of the 'alias.goto' value - $selectStatement = $this->dbConnection->query("SELECT mailbox.* FROM mailbox INNER JOIN alias - WHERE mailbox.username = alias.address - AND alias.goto LIKE CONCAT('%', alias.address, '%')"); - if ($selectStatement) { - $pfaUsers = []; - $pfaUsersRaw = $selectStatement->fetchAll(PDO::FETCH_ASSOC); - foreach ($pfaUsersRaw as $user) { - $pfaUser = new PfaUser(); - $pfaUser->mapFromArray($user); - $pfaUsers[] = $pfaUser; - } - return $pfaUsers; - } - - $this->logger->error("Couldn't read all users from PFA. SELECT query to DB failed"); - } - - $this->logger->error("Couldn't connect to DB while attempting to read all users from PFA"); - return null; - } - - // TODO: In the case of PFA, it maybe makes sense to rename this to something like getOneByUsername, - // since username is the distinguishing property between users (mailboxes) in PFA and not ID - public function getOneById($id): ?PfaUser - { - if (isset($id) && !empty($id)) { - if (isset($this->dbConnection)) { - try { - // TODO: should we use a variable for the table name? - $selectOnePreparedStatement = $this->dbConnection->prepare( - "SELECT mailbox.* FROM mailbox INNER JOIN alias - WHERE mailbox.username = alias.address - AND alias.goto LIKE CONCAT('%', alias.address, '%') - AND mailbox.username = ?" - ); - - $selectRes = $selectOnePreparedStatement->execute([$id]); - - if ($selectRes) { - $pfaUsersRaw = $selectOnePreparedStatement->fetchAll(PDO::FETCH_ASSOC); - if ($pfaUsersRaw) { - $pfaUser = new PfaUser(); - $pfaUser->mapFromArray($pfaUsersRaw[0]); - return $pfaUser; - } else { - return null; - } - } else { - return null; - } - } catch (PDOException $e) { - $this->logger->error($e->getMessage()); - } - } - } - - $this->logger->error( - "Argument provided to getOneById in class " . PfaUserDataAccess::class . " is not set or empty" - ); - return null; - } - - public function create(PfaUser $userToCreate): ?PfaUser - { - $dateNow = date('Y-m-d H:i:s'); - $date2000 = '2000-01-01 00:00:00'; - - if (isset($this->dbConnection)) { - try { - // We want to commit both insert (in mailbox and in alias) in one single commit, - // and we want to abort both if something fails - $this->dbConnection->beginTransaction(); - - // TODO: should we use a variable for the table name? - $insertStatement = $this->dbConnection->prepare( - "INSERT INTO mailbox - (username, password, name, maildir, quota, local_part, domain, created, modified, active, phone, - email_other, token, token_validity, password_expiry) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" - ); - - // When performing an INSERT into the mailbox table, maildir, domain and local_part - // as columns don't have default values. - // That's why here in the prepared statement we currently just give them the value of the empty string, - // so as to avoid the issue of MySQL complaining - // that nothing is provided for them when they have no default value - $insertRes1 = $insertStatement->execute([ - $userToCreate->getUserName(), - $userToCreate->getPassword() !== null ? $userToCreate->getPassword() : '', - $userToCreate->getName() !== null ? $userToCreate->getName() : '', - $userToCreate->getMaildir(), - $userToCreate->getQuota() !== null ? $userToCreate->getQuota() : 0, - $userToCreate->getLocalPart(), - $userToCreate->getDomain(), - $dateNow, - $dateNow, - $userToCreate->getActive(), - $userToCreate->getPhone() !== null ? $userToCreate->getPhone() : '', - $userToCreate->getEmailOther() !== null ? $userToCreate->getEmailOther() : '', - $userToCreate->getToken() !== null ? $userToCreate->getToken() : '', - $userToCreate->getTokenValidity() !== null ? $userToCreate->getTokenValidity() : $date2000, - $userToCreate->getPasswordExpiry() !== null ? $userToCreate->getPasswordExpiry() : $date2000 - ]); - - $insertStatement = $this->dbConnection->prepare( - "INSERT INTO alias - (address, goto, domain, created, modified, active) - VALUES (?, ?, ?, ?, ?, ?)" - ); - - // When performing an INSERT into the mailbox table, maildir, domain and local_part - // as columns don't have default values. - // That's why here in the prepared statement we currently just give them the value of the empty string, - // so as to avoid the issue of MySQL complaining - // that nothing is provided for them when they have no default value - $insertRes2 = $insertStatement->execute([ - $userToCreate->getUserName(), - $userToCreate->getUserName(), - $userToCreate->getDomain(), - $dateNow, - $dateNow, - $userToCreate->getActive() - ]); - - // In case the write was successful, return the user that was just written - if ($insertRes1 && $insertRes2) { - $this->dbConnection->commit(); - $this->logger->info("Created user " . $userToCreate->getUserName()); - return $this->getOneById($userToCreate->getUserName()); - //return $userToCreate; - } else { - // Otherwise, rollback and just return null - $this->dbConnection->rollBack(); - return null; - } - } catch (PDOException $e) { - $this->dbConnection->rollBack(); - $this->logger->error($e->getMessage()); - } - } else { - $this->logger->error("DB connection not available"); - } - $this->logger->error("Error creating user"); - return null; - } - - public function update(string $username, PfaUser $userToUpdate): ?PfaUser - { - $dateNow = date('Y-m-d H:i:s'); - - if (isset($this->dbConnection)) { - try { - $query = ""; - $values = array(); - if ($userToUpdate->getPassword() !== null) { - $query = $query . "password = ?, "; - $values[] = $userToUpdate->getPassword(); - } - if ($userToUpdate->getName() !== null) { - $query = $query . "name = ?, "; - $values[] = $userToUpdate->getName(); - } - if ($userToUpdate->getQuota() !== null) { - $query = $query . "quota = ?, "; - $values[] = $userToUpdate->getQuota(); - } - if ($userToUpdate->getActive() !== null) { - $query = $query . "active = ?, "; - $values[] = $userToUpdate->getActive(); - } - if ($userToUpdate->getEmailOther() !== null) { - $query = $query . "email_other = ?, "; - $values[] = $userToUpdate->getEmailOther(); - } - - if (empty($query)) { - $this->logger->error("No user properties to update"); - return null; - } - - $query = $query . "modified = ? "; - $values[] = $dateNow; - $values[] = $username; - - // Since in PFA the username column in the mailbox table is the primary and is unique, - // we use username in this case as an id that serves the purpose of a unique identifier - // TODO: should we use a variable for the table name? - $updateStatement = $this->dbConnection->prepare( - "UPDATE mailbox SET " . $query . " WHERE username = ?" - ); - - $updateRes = $updateStatement->execute($values); - - // In case the update was successful, return the user that was just updated - if ($updateRes) { - $this->logger->info("Updated user " . $username); - return $this->getOneById($username); - } else { - $this->logger->error("Error updating user " . $username); - return null; - } - } catch (PDOException $e) { - $this->logger->error($e->getMessage()); - } - } else { - $this->logger->error("Error updating user " . $username . " - DB connection unavailable"); - } - $this->logger->error("Error updating user " . $username); - return null; - } - - public function delete($username): bool - { - if (isset($this->dbConnection)) { - try { - // We want to commit both delete (in mailbox and in alias) in one single commit, - // and we want to abort both if something fails - $this->dbConnection->beginTransaction(); - - // TODO: should we use a variable for the table name? - $deleteStatement = $this->dbConnection->prepare( - "DELETE FROM mailbox WHERE username = ?" - ); - $deleteRes1 = $deleteStatement->execute([$username]); - - // TODO: should we use a variable for the table name? - $deleteStatement = $this->dbConnection->prepare( - "DELETE FROM alias WHERE address = ?" - ); - $deleteRes2 = $deleteStatement->execute([$username]); - - // In case the delete was successful, return true - if ($deleteRes1 && $deleteRes2) { - $this->dbConnection->commit(); - $this->logger->info("Deleted user " . $username); - return true; - //return $userToCreate; - } else { - // Otherwise, rollback and just return false - $this->dbConnection->rollBack(); - return false; - } - } catch (PDOException $e) { - $this->dbConnection->rollBack(); - $this->logger->error($e->getMessage()); - } - } else { - $this->logger->error("Error deleting user " . $username . " - DB connection unavailable"); - } - $this->logger->error("Error deleting user " . $username); - return false; - } -} diff --git a/src/Dependencies/dependencies.php b/src/Dependencies/dependencies.php index 9d707ae..ddd6427 100644 --- a/src/Dependencies/dependencies.php +++ b/src/Dependencies/dependencies.php @@ -2,58 +2,55 @@ declare(strict_types=1); -use DI\ContainerBuilder; use Opf\Controllers\Controller; use Opf\Util\Util; use Psr\Container\ContainerInterface; use Psr\Http\Message\ResponseInterface; use Tuupola\Middleware\JwtAuthentication; -return function (ContainerBuilder $containerBuilder) { - $containerBuilder->addDefinitions([ - // Monolog - Monolog\Logger::class => function (ContainerInterface $c) { - $config = Util::getConfigFile(); - $settings = $config['logger']; - $logger = new Monolog\Logger($settings['name']); - $logger->pushHandler(new Monolog\Handler\StreamHandler($settings['path'], $settings['level'])); - return $logger; - }, +return [ + // Monolog + Monolog\Logger::class => function () { + $config = Util::getConfigFile(); + $settings = $config['logger']; + $logger = new Monolog\Logger($settings['name']); + $logger->pushHandler(new Monolog\Handler\StreamHandler($settings['path'], $settings['level'])); + return $logger; + }, - // JWT - 'JwtAuthentication' => function (ContainerInterface $c) { - $config = Util::getConfigFile(); - $settings = $config['jwt']; - $settings["logger"] = $c->get(Monolog\Logger::class); - $settings["attribute"] = "jwt"; + // JWT + 'JwtAuthentication' => function (ContainerInterface $c) { + $config = Util::getConfigFile(); + $settings = $config['jwt']; + $settings["logger"] = $c->get(Monolog\Logger::class); + $settings["attribute"] = "jwt"; - // Don't ask for JWT when trying to obtain one - $basePath = ""; - if (isset($config) && !empty($config)) { - if (isset($config["basePath"]) && !empty($config["basePath"])) { - $basePath = $config["basePath"]; - } + // Don't ask for JWT when trying to obtain one + $basePath = ""; + if (isset($config) && !empty($config)) { + if (isset($config["basePath"]) && !empty($config["basePath"])) { + $basePath = $config["basePath"]; } - $settings["ignore"] = [$basePath . "/jwt"]; - - if (!isset($settings['error'])) { - $settings["error"] = function ( - ResponseInterface $response, - $arguments - ) { - $data["status"] = "error"; - $data["message"] = $arguments["message"]; - return $response - ->withHeader("Content-Type", "application/json") - ->getBody()->write(json_encode($data, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)); - }; - } - return new JwtAuthentication($settings); - }, - - // Controllers - Controller::class => function (ContainerInterface $c) { - return new Controller($c); } - ]); -}; + $settings["ignore"] = [$basePath . "/jwt"]; + + if (!isset($settings['error'])) { + $settings["error"] = function ( + ResponseInterface $response, + $arguments + ) { + $data["status"] = "error"; + $data["message"] = $arguments["message"]; + return $response + ->withHeader("Content-Type", "application/json") + ->getBody()->write(json_encode($data, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)); + }; + } + return new JwtAuthentication($settings); + }, + + // Controllers + Controller::class => function (ContainerInterface $c) { + return new Controller($c); + } +]; diff --git a/src/Dependencies/mock-dependencies.php b/src/Dependencies/mock-dependencies.php index 395f8d3..807fc69 100644 --- a/src/Dependencies/mock-dependencies.php +++ b/src/Dependencies/mock-dependencies.php @@ -2,45 +2,51 @@ declare(strict_types=1); -use DI\ContainerBuilder; use Opf\Adapters\Groups\MockGroupAdapter; use Opf\Adapters\Users\MockUserAdapter; -use Opf\Controllers\Controller; use Opf\DataAccess\Groups\MockGroupDataAccess; use Opf\DataAccess\Users\MockUserDataAccess; +use Opf\Middleware\SimpleAuthMiddleware; use Opf\Repositories\Groups\MockGroupsRepository; use Opf\Repositories\Users\MockUsersRepository; +use Opf\Util\Authentication\SimpleBearerAuthenticator; use Psr\Container\ContainerInterface; -use Psr\Http\Message\ResponseInterface; -use Tuupola\Middleware\JwtAuthentication; -return function (ContainerBuilder $containerBuilder) { - $containerBuilder->addDefinitions([ - // Repositories - 'UsersRepository' => function (ContainerInterface $c) { - return new MockUsersRepository($c); - }, +return [ + // Repositories + 'UsersRepository' => function (ContainerInterface $c) { + return new MockUsersRepository($c); + }, - 'GroupsRepository' => function (ContainerInterface $c) { - return new MockGroupsRepository($c); - }, + 'GroupsRepository' => function (ContainerInterface $c) { + return new MockGroupsRepository($c); + }, - // Data access classes - 'UsersDataAccess' => function () { - return new MockUserDataAccess(); - }, + // Data access classes + 'UsersDataAccess' => function () { + return new MockUserDataAccess(); + }, - 'GroupsDataAccess' => function () { - return new MockGroupDataAccess(); - }, + 'GroupsDataAccess' => function () { + return new MockGroupDataAccess(); + }, - // Adapters - 'UsersAdapter' => function () { - return new MockUserAdapter(); - }, + // Adapters + 'UsersAdapter' => function () { + return new MockUserAdapter(); + }, - 'GroupsAdapter' => function () { - return new MockGroupAdapter(); - } - ]); -}; + 'GroupsAdapter' => function () { + return new MockGroupAdapter(); + }, + + // Auth middleware + 'AuthMiddleware' => function (ContainerInterface $c) { + return new SimpleAuthMiddleware($c); + }, + + // Authenticators (used by SimpleAuthMiddleware) + 'BearerAuthenticator' => function (ContainerInterface $c) { + return new SimpleBearerAuthenticator($c); + } +]; diff --git a/src/Dependencies/pfa-dependencies.php b/src/Dependencies/pfa-dependencies.php deleted file mode 100644 index c7279ca..0000000 --- a/src/Dependencies/pfa-dependencies.php +++ /dev/null @@ -1,28 +0,0 @@ -addDefinitions([ - // Repositories - 'UsersRepository' => function (ContainerInterface $c) { - return new PfaUsersRepository($c); - }, - - // Data access classes - 'UsersDataAccess' => function () { - return new PfaUserDataAccess(); - }, - - // Adapters - 'UsersAdapter' => function () { - return new PfaUserAdapter(); - } - ]); -}; diff --git a/src/Handlers/HttpErrorHandler.php b/src/Handlers/HttpErrorHandler.php index 39a3c4b..bd5d536 100644 --- a/src/Handlers/HttpErrorHandler.php +++ b/src/Handlers/HttpErrorHandler.php @@ -69,6 +69,7 @@ class HttpErrorHandler extends ErrorHandler $payload = json_encode($error, JSON_PRETTY_PRINT); $response = $this->responseFactory->createResponse($statusCode); + $response = $response->withHeader('Content-Type', 'application/scim+json'); $response->getBody()->write($payload); return $response; diff --git a/src/Middleware/SimpleAuthMiddleware.php b/src/Middleware/SimpleAuthMiddleware.php new file mode 100644 index 0000000..e0e1173 --- /dev/null +++ b/src/Middleware/SimpleAuthMiddleware.php @@ -0,0 +1,56 @@ +bearerAuthenticator = $container->get('BearerAuthenticator'); + } + + public function process(Request $request, RequestHandler $handler): Response + { + // If no 'Authorization' header supplied, we directly return a 401 + if (!$request->hasHeader('Authorization')) { + return new Response(401); + } + + // $request->getHeader() gives back a string array, hence the need for [0] + $authHeader = $request->getHeader('Authorization')[0]; + + // Obtain the auth type and the supplied credentials + $authHeaderSplit = explode(' ', $authHeader); + $authType = $authHeaderSplit[0]; + $authCredentials = $authHeaderSplit[1]; + + // This is a flag that tracks whether auth succeeded or not + $isAuthSuccessful = false; + + $authorizationInfo = []; + + // Call the right authenticator, based on the auth type + if (strcmp($authType, 'Bearer') === 0) { + $isAuthSuccessful = $this->bearerAuthenticator->authenticate($authCredentials, $authorizationInfo); + } + + // If everything went fine, let the request pass through + if ($isAuthSuccessful) { + return $handler->handle($request); + } + + // If something didn't go right so far, then return a 401 + return new Response(401); + } +} diff --git a/src/Models/Mock/MockCommonEntity.php b/src/Models/Mock/MockCommonEntity.php new file mode 100644 index 0000000..216e78b --- /dev/null +++ b/src/Models/Mock/MockCommonEntity.php @@ -0,0 +1,45 @@ +id; + } + + public function setId($id) + { + $this->id = $id; + } + + public function getCreatedAt() + { + return $this->createdAt; + } + + public function setCreatedAt($createdAt) + { + $this->createdAt = $createdAt; + } + + public function getUpdatedAt() + { + return $this->updatedAt; + } + + public function setUpdatedAt($updatedAt) + { + $this->updatedAt = $updatedAt; + } +} diff --git a/src/Models/Mock/MockGroup.php b/src/Models/Mock/MockGroup.php new file mode 100644 index 0000000..5e5c6ec --- /dev/null +++ b/src/Models/Mock/MockGroup.php @@ -0,0 +1,69 @@ +|null $members */ + private $members; + + public function mapFromArray($properties = null): bool + { + $result = true; + if ($properties !== null) { + foreach ($properties as $key => $value) { + if (strcasecmp($key, 'id') === 0) { + $this->id = $value; + continue; + } + + if (strcasecmp($key, 'created_at') === 0) { + $this->createdAt = $value; + continue; + } + + if (strcasecmp($key, 'updated_at') === 0) { + $this->updatedAt = $value; + continue; + } + + if (strcasecmp($key, 'displayName') === 0) { + $this->displayName = $value; + continue; + } + + if (strcasecmp($key, 'members') === 0) { + $this->members = $value; + continue; + } + $result = false; + } + } else { + $result = false; + } + return $result; + } + + public function getDisplayName() + { + return $this->displayName; + } + + public function setDisplayName($displayName) + { + $this->displayName = $displayName; + } + + public function getMembers() + { + return $this->members; + } + + public function setMembers($members) + { + $this->members = $members; + } +} diff --git a/src/Models/Mock/MockUser.php b/src/Models/Mock/MockUser.php new file mode 100644 index 0000000..8b82477 --- /dev/null +++ b/src/Models/Mock/MockUser.php @@ -0,0 +1,111 @@ + $value) { + if (strcasecmp($key, 'id') === 0) { + $this->id = $value; + continue; + } + + if (strcasecmp($key, 'created_at') === 0) { + $this->createdAt = $value; + continue; + } + + if (strcasecmp($key, 'updated_at') === 0) { + $this->updatedAt = $value; + continue; + } + + if (strcasecmp($key, 'userName') === 0) { + $this->userName = $value; + continue; + } + + if (strcasecmp($key, 'active') === 0) { + if ($value === "1") { + $this->active = true; + } elseif ($value === "0") { + $this->active = false; + } else { + $this->active = $value; + } + continue; + } + + if (strcasecmp($key, 'externalId') === 0) { + $this->externalId = $value; + continue; + } + + if (strcasecmp($key, 'profileUrl') === 0) { + $this->profileUrl = $value; + continue; + } + $result = false; + } + } else { + $result = false; + } + return $result; + } + + public function getUserName() + { + return $this->userName; + } + + public function setUserName($userName) + { + $this->userName = $userName; + } + + public function getActive() + { + return $this->active; + } + + public function setActive($active) + { + $this->active = $active; + } + + public function getExternalId() + { + return $this->externalId; + } + + public function setExternalId($externalId) + { + $this->externalId = $externalId; + } + + public function getProfileUrl() + { + return $this->profileUrl; + } + + public function setProfileUrl($profileUrl) + { + $this->profileUrl = $profileUrl; + } +} diff --git a/src/Models/PFA/PfaUser.php b/src/Models/PFA/PfaUser.php deleted file mode 100644 index 6348ff0..0000000 --- a/src/Models/PFA/PfaUser.php +++ /dev/null @@ -1,364 +0,0 @@ - $value) { - if (strcasecmp($key, 'userName') === 0) { - $this->userName = $value; - continue; - } - if (strcasecmp($key, 'password') === 0) { - $this->password = $value; - continue; - } - if (strcasecmp($key, 'name') === 0) { - $this->name = $value; - continue; - } - if (strcasecmp($key, 'maildir') === 0) { - $this->maildir = $value; - continue; - } - if (strcasecmp($key, 'quota') === 0) { - $this->quota = $value; - continue; - } - if (strcasecmp($key, 'localpart') === 0) { - $this->localpart = $value; - continue; - } - if (strcasecmp($key, 'domain') === 0) { - $this->domain = $value; - continue; - } - if (strcasecmp($key, 'created') === 0) { - $this->created = $value; - continue; - } - if (strcasecmp($key, 'modified') === 0) { - $this->modified = $value; - continue; - } - if (strcasecmp($key, 'active') === 0) { - $this->active = $value; - continue; - } - if (strcasecmp($key, 'phone') === 0) { - $this->phone = $value; - continue; - } - if (strcasecmp($key, 'emailOther') === 0) { - $this->emailOther = $value; - continue; - } - if (strcasecmp($key, 'token') === 0) { - $this->token = $value; - continue; - } - if (strcasecmp($key, 'tokenValidity') === 0) { - $this->tokenValidity = $value; - continue; - } - if (strcasecmp($key, 'passwordExpiry') === 0) { - $this->passwordExpiry = $value; - continue; - } - $result = false; - } - } else { - $result = false; - } - return $result; - } - - /** - * @return string|null - */ - public function getUserName(): ?string - { - return $this->userName; - } - - /** - * @param string|null $userName - */ - public function setUserName(?string $userName): void - { - $this->userName = $userName; - } - - /** - * @return string|null - */ - public function getPassword(): ?string - { - return $this->password; - } - - /** - * @param string|null $password - */ - public function setPassword(?string $password): void - { - $this->password = $password; - } - - /** - * @return string|null - */ - public function getName(): ?string - { - return $this->name; - } - - /** - * @param string|null $name - */ - public function setName(?string $name): void - { - $this->name = $name; - } - - /** - * @return string|null - */ - public function getMaildir(): ?string - { - return $this->maildir; - } - - /** - * @param string|null $maildir - */ - public function setMaildir(?string $maildir): void - { - $this->maildir = $maildir; - } - - /** - * @return int|null - */ - public function getQuota(): ?int - { - return $this->quota; - } - - /** - * @param int|null $quota - */ - public function setQuota(?int $quota): void - { - $this->quota = $quota; - } - - /** - * @return string|null - */ - public function getLocalPart(): ?string - { - return $this->localPart; - } - - /** - * @param string|null $localPart - */ - public function setLocalPart(?string $localPart): void - { - $this->localPart = $localPart; - } - - /** - * @return string|null - */ - public function getDomain(): ?string - { - return $this->domain; - } - - /** - * @param string|null $domain - */ - public function setDomain(?string $domain): void - { - $this->domain = $domain; - } - - /** - * @return string|null - */ - public function getCreated(): ?string - { - return $this->created; - } - - /** - * @param string|null $created - */ - public function setCreated(?string $created): void - { - $this->created = $created; - } - - /** - * @return string|null - */ - public function getModified(): ?string - { - return $this->modified; - } - - /** - * @param string|null $modified - */ - public function setModified(?string $modified): void - { - $this->modified = $modified; - } - - /** - * @return string|null - */ - public function getActive(): ?string - { - return $this->active; - } - - /** - * @param string|null $active - */ - public function setActive(?string $active): void - { - $this->active = $active; - } - - /** - * @return string|null - */ - public function getPhone(): ?string - { - return $this->phone; - } - - /** - * @param string|null $phone - */ - public function setPhone(?string $phone): void - { - $this->phone = $phone; - } - - /** - * @return string|null - */ - public function getEmailOther(): ?string - { - return $this->emailOther; - } - - /** - * @param string|null $emailOther - */ - public function setEmailOther(?string $emailOther): void - { - $this->emailOther = $emailOther; - } - - /** - * @return string|null - */ - public function getToken(): ?string - { - return $this->token; - } - - /** - * @param string|null $token - */ - public function setToken(?string $token): void - { - $this->token = $token; - } - - /** - * @return string|null - */ - public function getTokenValidity(): ?string - { - return $this->tokenValidity; - } - - /** - * @param string|null $tokenValidity - */ - public function setTokenValidity(?string $tokenValidity): void - { - $this->tokenValidity = $tokenValidity; - } - - /** - * @return string|null - */ - public function getPasswordExpiry(): ?string - { - return $this->passwordExpiry; - } - - /** - * @param string|null $passwordExpiry - */ - public function setPasswordExpiry(?string $passwordExpiry): void - { - $this->passwordExpiry = $passwordExpiry; - } -} diff --git a/src/Models/SCIM/Custom/Domains/Domain.php b/src/Models/SCIM/Custom/Domains/Domain.php index 7e92662..e3a4445 100644 --- a/src/Models/SCIM/Custom/Domains/Domain.php +++ b/src/Models/SCIM/Custom/Domains/Domain.php @@ -2,7 +2,221 @@ namespace Opf\Models\SCIM\Custom\Domains; -// TODO: This is currently a dummy class to demonstrate how to add custom SCIM resources to the codebase -class Domain +use Opf\Models\SCIM\Standard\CommonEntity; +use Opf\Models\SCIM\Standard\Meta; +use Opf\Util\Util; + +class Domain extends CommonEntity { + /** @var string|null $domainName */ + private ?string $domainName; + + /** @var string|null $description */ + private ?string $description = null; + + /** @var int $maxAliases */ + private int $maxAliases; + + /** @var int $maxMailboxes */ + private int $maxMailboxes; + + /** @var int $maxQuota */ + private int $maxQuota; + + /** @var int $usedQuota */ + private int $usedQuota; + + /** @var bool $active */ + private bool $active; + + /** + * @return string|null + */ + public function getDomainName(): ?string + { + return $this->domainName; + } + + /** + * @param string|null $domainName + */ + public function setDomainName(?string $domainName): void + { + $this->domainName = $domainName; + } + + /** + * @return string|null + */ + public function getDescription(): ?string + { + return $this->description; + } + + /** + * @param string|null $description + */ + public function setDescription(?string $description): void + { + $this->description = $description; + } + + /** + * @return int + */ + public function getMaxAliases(): int + { + return $this->maxAliases; + } + + /** + * @param int $maxAliases + */ + public function setMaxAliases(int $maxAliases): void + { + $this->maxAliases = $maxAliases; + } + + /** + * @return int + */ + public function getMaxMailboxes(): int + { + return $this->maxMailboxes; + } + + /** + * @param int $maxMailboxes + */ + public function setMaxMailboxes(int $maxMailboxes): void + { + $this->maxMailboxes = $maxMailboxes; + } + + /** + * @return int + */ + public function getMaxQuota(): int + { + return $this->maxQuota; + } + + /** + * @param int $maxQuota + */ + public function setMaxQuota(int $maxQuota): void + { + $this->maxQuota = $maxQuota; + } + + /** + * @return int + */ + public function getUsedQuota(): int + { + return $this->usedQuota; + } + + /** + * @param int $usedQuota + */ + public function setUsedQuota(int $usedQuota): void + { + $this->usedQuota = $usedQuota; + } + + /** + * @return bool + */ + public function getActive(): bool + { + return $this->active; + } + + /** + * @param bool $active + */ + public function setActive(bool $active): void + { + $this->active = $active; + } + + /** + * Create a Domain object from JSON SCIM data + * + * @param array $data The JSON SCIM data + */ + public function fromSCIM(array $data) + { + if (isset($data['id'])) { + $this->setId($data['id']); + } + + $this->setExternalId(isset($data['externalId']) ? $data['externalId'] : null); + + $this->setDomainName(isset($data['domainName']) ? $data['domainName'] : null); + $this->setDescription(isset($data['description']) ? $data['description'] : null); + + // For the int attributes that are set below, we set 0 as the default value + // in case that nothing is supplied and/or set in the JSON + // TODO: Is that an okayish solution with this default value? + $this->setMaxAliases(isset($data['maxAliases']) ? $data['maxAliases'] : 0); + $this->setMaxMailboxes(isset($data['maxMailboxes']) ? $data['maxMailboxes'] : 0); + $this->setMaxQuota(isset($data['maxQuota']) ? $data['maxQuota'] : 0); + $this->setUsedQuota(isset($data['usedQuota']) ? $data['usedQuota'] : 0); + + if (isset($data['meta']) && !empty($data['meta'])) { + $meta = new Meta(); + $meta->setResourceType("Domain"); + $meta->setCreated(isset($data['meta']['created']) ? $data['meta']['created'] : null); + $meta->setLastModified(isset($data['meta']['modified']) ? $data['meta']['modified'] : null); + $meta->setVersion(isset($data['meta']['version']) ? $data['meta']['version'] : null); + + $this->setMeta($meta); + } + + // In case that "active" is not set in the JSON, we set it to true by default + // TODO: Is that an okayish solution with this default value? + $this->setActive(isset($data['active']) ? boolval($data['active']) : true); + + $this->setSchemas(isset($data['schemas']) ? $data['schemas'] : []); + } + + /** + * Convert a Domain object to its JSON or array representation + * + * @param bool $encode A flag indicating if the object should be encoded as JSON + * @param string $baseLocation A path indicating the base location of the SCIM server + * + * @return array|string|false If $encode is true, return either a JSON string or false on failure, else an array + */ + public function toSCIM(bool $encode = true, string $baseLocation = 'http://localhost:8888/v1') + { + $data = [ + 'id' => $this->getId(), + 'externalId' => $this->getExternalId(), + 'schemas' => [Util::DOMAIN_SCHEMA], + 'meta' => null !== $this->getMeta() ? [ + 'resourceType' => null !== $this->getMeta()->getResourceType() + ? $this->getMeta()->getResourceType() : null, + 'created' => null !== $this->getMeta()->getCreated() ? $this->getMeta()->getCreated() : null, + 'updated' => null !== $this->getMeta()->getLastModified() ? $this->getMeta()->getLastModified() : null, + 'location' => $baseLocation . '/Domains/' . $this->getId(), + 'version' => null !== $this->getMeta()->getVersion() ? $this->getMeta()->getVersion() : null + ] : null, + 'domainName' => null !== $this->getDomainName() ? $this->getDomainName() : null, + 'description' => null !== $this->getDescription() ? $this->getDescription() : null, + 'maxAliases' => $this->getMaxAliases(), + 'maxMailboxes' => $this->getMaxMailboxes(), + 'maxQuota' => $this->getMaxQuota(), + 'usedQuota' => $this->getUsedQuota(), + 'active' => $this->getActive() + ]; + + if ($encode) { + $data = json_encode($data); + } + + return $data; + } } diff --git a/src/Models/SCIM/Standard/Filters/AttributeExpression.php b/src/Models/SCIM/Standard/Filters/AttributeExpression.php new file mode 100644 index 0000000..aec5ddd --- /dev/null +++ b/src/Models/SCIM/Standard/Filters/AttributeExpression.php @@ -0,0 +1,89 @@ +attributePath = $attributePath; + } else { + throw new FilterException( + "Attribute path passed to Attribute Expression was either empty, null or not a string" + ); + } + + switch ($compareOperator) { + case "eq": + $this->compareOperator = AttributeOperator::OP_EQ; + break; + + case "ne": + $this->compareOperator = AttributeOperator::OP_NE; + break; + + case "co": + $this->compareOperator = AttributeOperator::OP_CO; + break; + + case "sw": + $this->compareOperator = AttributeOperator::OP_SW; + break; + + case "ew": + $this->compareOperator = AttributeOperator::OP_EW; + break; + + case "gt": + $this->compareOperator = AttributeOperator::OP_GT; + break; + + case "lt": + $this->compareOperator = AttributeOperator::OP_LT; + break; + + case "ge": + $this->compareOperator = AttributeOperator::OP_GE; + break; + + case "le": + $this->compareOperator = AttributeOperator::OP_LE; + break; + + case "pr": + $this->compareOperator = AttributeOperator::OP_PR; + break; + + default: + throw new FilterException("Invalid AttributeOperation passed to AttributeExpression"); + break; + } + + $this->comparisonValue = $comparisonValue; + } + + public function getAttributePath() + { + return $this->attributePath; + } + + public function getCompareOperator() + { + return $this->compareOperator; + } + + public function getComparisonValue() + { + return $this->comparisonValue; + } +} diff --git a/src/Models/SCIM/Standard/Filters/AttributeOperator.php b/src/Models/SCIM/Standard/Filters/AttributeOperator.php new file mode 100644 index 0000000..dd5d2be --- /dev/null +++ b/src/Models/SCIM/Standard/Filters/AttributeOperator.php @@ -0,0 +1,17 @@ +setDisplayName(isset($data['displayName']) ? $data['displayName'] : null); $meta = new Meta(); - if (isset($data['meta']) && isset($data['meta']['created'])) { + // This is currently commented out, since the code complains about wrongly + // formatted timestamps sometimes when fromSCIM is called + // TODO: Need to possibly refactor string2datetime and/or dateTime2string in order to fix this + /*if (isset($data['meta']) && isset($data['meta']['created'])) { $meta->setCreated(Util::string2dateTime($data['meta']['created'])); } else { $meta->setCreated(Util::dateTime2string(new \DateTime('NOW'))); - } + }*/ $this->setMeta($meta); - $this->setMembers(isset($data['members']) ? $data['members'] : true); + if (isset($data['members'])) { + $members = []; + foreach ($data['members'] as $member) { + $scimMember = new MultiValuedAttribute(); + $scimMember->setValue($member); + $members[] = $scimMember; + } + $this->setMembers($members); + } else { + $this->setMembers(null); + } $this->setExternalId(isset($data['externalId']) ? $data['externalId'] : null); } @@ -65,17 +79,18 @@ class CoreGroup extends CommonEntity 'schemas' => [Util::GROUP_SCHEMA], 'id' => $this->getId(), 'externalId' => $this->getExternalId(), - 'meta' => [ - 'resourceType' => $this->getMeta()->getResourceType(), - 'created' => $this->getMeta()->getCreated(), + 'meta' => null !== $this->getMeta() ? [ + 'resourceType' => null !== $this->getMeta()->getResourceType() + ? $this->getMeta()->getResourceType() : null, + 'created' => null !== $this->getMeta()->getCreated() ? $this->getMeta()->getCreated() : null, 'location' => $baseLocation . '/Groups/' . $this->getId(), - 'version' => $this->getMeta()->getVersion() - ], + 'version' => null !== $this->getMeta()->getVersion() ? $this->getMeta()->getVersion() : null + ] : null, 'displayName' => $this->getDisplayName(), 'members' => $this->getMembers() ]; - if (null !== $this->getMeta()->getLastModified()) { + if (null !== $this->getMeta() && null !== $this->getMeta()->getLastModified()) { $data['meta']['updated'] = $this->getMeta()->getLastModified(); } diff --git a/src/Models/SCIM/Standard/Users/CoreUser.php b/src/Models/SCIM/Standard/Users/CoreUser.php index a5de57d..6807124 100644 --- a/src/Models/SCIM/Standard/Users/CoreUser.php +++ b/src/Models/SCIM/Standard/Users/CoreUser.php @@ -299,19 +299,38 @@ class CoreUser extends CommonEntity $this->setUserName(isset($data['userName']) ? $data['userName'] : null); $name = new Name(); - $name->setFamilyName($data['name']['familyName']); - $name->setFormatted($data['name']['formatted']); - $name->setGivenName($data['name']['givenName']); - $name->setHonorificPrefix($data['name']['honorificPrefix']); - $name->setHonorificSuffix($data['name']['honorificSuffix']); + if (isset($data['name']) && !empty($data['name'])) { + if (isset($data['name']['familyName']) && !empty($data['name']['familyName'])) { + $name->setFamilyName($data['name']['familyName']); + } + + if (isset($data['name']['formatted']) && !empty($data['name']['formatted'])) { + $name->setFormatted($data['name']['formatted']); + } + + if (isset($data['name']['givenName']) && !empty($data['name']['givenName'])) { + $name->setGivenName($data['name']['givenName']); + } + + if (isset($data['name']['honorificPrefix']) && !empty($data['name']['honorificPrefix'])) { + $name->setHonorificPrefix($data['name']['honorificPrefix']); + } + + if (isset($data['name']['honorificSuffix']) && !empty($data['name']['honorificSuffix'])) { + $name->setHonorificSuffix($data['name']['honorificSuffix']); + } + } $this->setName($name); $meta = new Meta(); - if (isset($data['meta']) && isset($data['meta']['created'])) { + // This is currently commented out, since the code complains about wrongly + // formatted timestamps sometimes when fromSCIM is called + // TODO: Need to possibly refactor string2datetime and/or dateTime2string in order to fix this + /*if (isset($data['meta']) && isset($data['meta']['created'])) { $meta->setCreated(Util::string2dateTime($data['meta']['created'])); } else { $meta->setCreated(Util::dateTime2string(new \DateTime('NOW'))); - } + }*/ $this->setMeta($meta); $this->setActive(isset($data['active']) ? $data['active'] : true); @@ -377,7 +396,7 @@ class CoreUser extends CommonEntity 'preferredLanguage' => $this->getPreferredLanguage(), 'locale' => $this->getLocale(), 'timezone' => $this->getTimezone(), - 'active' => $this->getActive(), + 'active' => boolval($this->getActive()), 'password' => $this->getPassword(), 'emails' => $this->getEmails(), 'phoneNumbers' => $this->getPhoneNumbers(), diff --git a/src/Repositories/Groups/MockGroupsRepository.php b/src/Repositories/Groups/MockGroupsRepository.php index 60ecd7c..2551c25 100644 --- a/src/Repositories/Groups/MockGroupsRepository.php +++ b/src/Repositories/Groups/MockGroupsRepository.php @@ -2,136 +2,106 @@ namespace Opf\Repositories\Groups; +use Monolog\Logger; use Opf\Models\SCIM\Standard\Groups\CoreGroup; use Opf\Models\SCIM\Standard\Meta; use Opf\Repositories\Repository; +use Opf\Util\Filters\FilterUtil; use Psr\Container\ContainerInterface; class MockGroupsRepository extends Repository { + private $logger; + public function __construct(ContainerInterface $container) { parent::__construct($container); $this->dataAccess = $this->container->get('GroupsDataAccess'); $this->adapter = $this->container->get('GroupsAdapter'); + $this->logger = $this->container->get(Logger::class); } - public function getAll(): array - { + public function getAll( + $filter = '', + $startIndex = 0, + $count = 0, + $attributes = [], + $excludedAttributes = [] + ): array { // Read all mock groups from the database - $mockGroups = $this->dataAccess::all(); + $mockGroups = $this->dataAccess->getAll(); $scimGroups = []; - // Transform each mock group to a SCIM group via the injected adapter foreach ($mockGroups as $mockGroup) { - $this->adapter->setGroup($mockGroup); - - $scimGroup = new CoreGroup(); - $scimGroup->setId($this->adapter->getId()); - $scimGroup->setDisplayName($this->adapter->getDisplayName()); - $scimGroup->setMembers($this->adapter->getMembers()); - - $scimGroupMeta = new Meta(); - $scimGroupMeta->setCreated($this->adapter->getCreatedAt()); - - $scimGroup->setMeta($scimGroupMeta); - + $scimGroup = $this->adapter->getCoreGroup($mockGroup); $scimGroups[] = $scimGroup; } + if (isset($filter) && !empty($filter)) { + $scimGroupsToFilter = []; + foreach ($scimGroups as $scimGroup) { + $scimGroupsToFilter[] = $scimGroup->toSCIM(false); + } + + $filteredScimData = FilterUtil::performFiltering($filter, $scimGroupsToFilter); + + $scimGroups = []; + foreach ($filteredScimData as $filteredScimGroup) { + $scimGroup = new CoreGroup(); + $scimGroup->fromSCIM($filteredScimGroup); + $scimGroups[] = $scimGroup; + } + + return $scimGroups; + } + return $scimGroups; } - public function getOneById(string $id): ?CoreGroup - { - if (isset($id) && !empty($id)) { - $mockGroup = $this->dataAccess::find($id); - - if (isset($mockGroup) && !empty($mockGroup)) { - $this->adapter->setGroup($mockGroup); - - $scimGroup = new CoreGroup(); - $scimGroup->setId($this->adapter->getId()); - $scimGroup->setDisplayName($this->adapter->getDisplayName()); - $scimGroup->setMembers($this->adapter->getMembers()); - - $scimGroupMeta = new Meta(); - $scimGroupMeta->setCreated($this->adapter->getCreatedAt()); - - $scimGroup->setMeta($scimGroupMeta); - - return $scimGroup; - } else { - return null; - } - } else { - return null; - } + public function getOneById( + string $id, + $filter = '', + $startIndex = 0, + $count = 0, + $attributes = [], + $excludedAttributes = [] + ): ?CoreGroup { + $mockGroup = $this->dataAccess->getOneById($id); + return $this->adapter->getCoreGroup($mockGroup); } public function create($object): ?CoreGroup { - if (isset($object) && !empty($object)) { - $scimGroup = new CoreGroup(); - $scimGroup->fromSCIM($object); + $scimGroupToCreate = new CoreGroup(); + $scimGroupToCreate->fromSCIM($object); - $this->adapter->setGroup($this->dataAccess); + $mockGroupToCreate = $this->adapter->getMockGroup($scimGroupToCreate); - $this->adapter->setId($scimGroup->getId()); - $this->adapter->setDisplayName($scimGroup->getDisplayName()); - $this->adapter->setMembers($scimGroup->getMembers()); - $this->adapter->setCreatedAt($scimGroup->getMeta()->getCreated()); + $mockGroupCreated = $this->dataAccess->create($mockGroupToCreate); - $this->dataAccess = $this->adapter->getGroup(); - if ($this->dataAccess->save()) { - return $scimGroup; - } else { - return null; - } + if (isset($mockGroupCreated)) { + return $this->adapter->getCoreGroup($mockGroupCreated); } + return null; } public function update(string $id, $object): ?CoreGroup { - if (isset($id) && !empty($id)) { - $mockGroup = $this->dataAccess::find($id); - if (isset($mockGroup) && !empty($mockGroup)) { - $scimGroup = new CoreGroup(); - $scimGroup->fromSCIM($object); + $scimGroupToUpdate = new CoreGroup(); + $scimGroupToUpdate->fromSCIM($object); - $this->adapter->setGroup($mockGroup); + $mockGroupToUpdate = $this->adapter->getMockGroup($scimGroupToUpdate); - $scimGroup->setId($this->adapter->getId()); + $mockGroupUpdated = $this->dataAccess->update($id, $mockGroupToUpdate); - $this->adapter->setDisplayName($scimGroup->getDisplayName()); - $this->adapter->setMembers($scimGroup->getMembers()); - $this->adapter->setCreatedAt($scimGroup->getMeta()->getCreated()); - - $mockGroup = $this->adapter->getGroup(); - if ($mockGroup->save()) { - return $scimGroup; - } else { - return null; - } - } else { - return null; - } - } else { - return null; + if (isset($mockGroupUpdated)) { + return $this->adapter->getCoreGroup($mockGroupUpdated); } + return null; } public function delete(string $id): bool { - if (isset($id) && !empty($id)) { - $mockGroup = $this->dataAccess::find($id); - if (!isset($mockGroup) || empty($mockGroup)) { - return false; - } - $mockGroup->delete(); - return true; - } else { - return false; - } + return $this->dataAccess->delete($id); } } diff --git a/src/Repositories/Repository.php b/src/Repositories/Repository.php index 7a11484..18ac25b 100644 --- a/src/Repositories/Repository.php +++ b/src/Repositories/Repository.php @@ -15,9 +15,68 @@ abstract class Repository $this->container = $container; } - abstract public function getAll(): array; - abstract public function getOneById(string $id): ?object; + /** + * @param string $filter Optional parameter which contains a filter expression + * as per https://www.rfc-editor.org/rfc/rfc7644.html#section-3.4.2.2 + * + * @param int $startIndex Optional parameter for specifying the start index, used for pagination + * as per https://www.rfc-editor.org/rfc/rfc7644.html#section-3.4.2.4 + * + * @param int $count Optional parameter for specifying the number of results, used for pagination + * as per https://www.rfc-editor.org/rfc/rfc7644.html#section-3.4.2.4 + * + * @param array $attributes Optional parameter for including only specific attributes in the response + * as per https://www.rfc-editor.org/rfc/rfc7644.html#section-3.4.2.5 + * (if $exludedAttributes is not empty, this should be empty) + * + * @param array $excludedAttributes Optional parameter for excluding specific attributes from the response + * as per https://www.rfc-editor.org/rfc/rfc7644.html#section-3.4.2.5 + * (if $attributes is not empty, this should be empty) + * + * @return array An array of SCIM resources + */ + abstract public function getAll( + $filter = '', + $startIndex = 0, + $count = 0, + $attributes = [], + $excludedAttributes = [] + ): array; + + /** + * @param string $id Required parameter which contains of a given entity that should be retrieved + * + * @param string $filter Optional parameter which contains a filter expression + * as per https://www.rfc-editor.org/rfc/rfc7644.html#section-3.4.2.2 + * + * @param int $startIndex Optional parameter for specifying the start index, used for pagination + * as per https://www.rfc-editor.org/rfc/rfc7644.html#section-3.4.2.4 + * + * @param int $count Optional parameter for specifying the number of results, used for pagination + * as per https://www.rfc-editor.org/rfc/rfc7644.html#section-3.4.2.4 + * + * @param array $attributes Optional parameter for including only specific attributes in the response + * as per https://www.rfc-editor.org/rfc/rfc7644.html#section-3.4.2.5 + * (if $exludedAttributes is not empty, this should be empty) + * + * @param array $excludedAttributes Optional parameter for excluding specific attributes from the response + * as per https://www.rfc-editor.org/rfc/rfc7644.html#section-3.4.2.5 + * (if $attributes is not empty, this should be empty) + * + * @return object|null A SCIM resource or null if no resource found + */ + abstract public function getOneById( + string $id, + $filter = '', + $startIndex = 0, + $count = 0, + $attributes = [], + $excludedAttributes = [] + ): ?object; + abstract public function create($object): ?object; + abstract public function update(string $id, $object): ?object; + abstract public function delete(string $id): bool; } diff --git a/src/Repositories/Users/MockUsersRepository.php b/src/Repositories/Users/MockUsersRepository.php index 2e4bd5e..3f2d380 100644 --- a/src/Repositories/Users/MockUsersRepository.php +++ b/src/Repositories/Users/MockUsersRepository.php @@ -2,190 +2,120 @@ namespace Opf\Repositories\Users; +use Monolog\Logger; use Opf\Models\SCIM\Standard\Users\CoreUser; use Opf\Models\SCIM\Standard\Meta; use Opf\Repositories\Repository; +use Opf\Util\Filters\FilterUtil; use Psr\Container\ContainerInterface; class MockUsersRepository extends Repository { + private $logger; + public function __construct(ContainerInterface $container) { parent::__construct($container); $this->dataAccess = $this->container->get('UsersDataAccess'); $this->adapter = $this->container->get('UsersAdapter'); + $this->logger = $this->container->get(Logger::class); } - public function getAll(): array - { + + public function getAll( + $filter = '', + $startIndex = 0, + $count = 0, + $attributes = [], + $excludedAttributes = [] + ): array { // Read all mock users from the database - $mockUsers = $this->dataAccess::all(); + $mockUsers = $this->dataAccess->getAll(); $scimUsers = []; - // Transform each mock user to a SCIM user via the injected adapter foreach ($mockUsers as $mockUser) { - // TODO: Possibly refactor the transformation logic between SCIM users and other users - // in a separate method or class, since it seems to be rather repetitive - $this->adapter->setUser($mockUser); - - $scimUser = new CoreUser(); - $scimUser->setId($this->adapter->getId()); - $scimUser->setUserName($this->adapter->getUserName()); - - $scimUserMeta = new Meta(); - $scimUserMeta->setCreated($this->adapter->getCreatedAt()); - - $scimUser->setMeta($scimUserMeta); - $scimUser->setActive($this->adapter->getActive()); - $scimUser->setExternalId($this->adapter->getExternalId()); - $scimUser->setProfileUrl($this->adapter->getProfileUrl()); - + $scimUser = $this->adapter->getCoreUser($mockUser); $scimUsers[] = $scimUser; } + if (isset($filter) && !empty($filter)) { + $scimUsersToFilter = []; + foreach ($scimUsers as $scimUser) { + $scimUsersToFilter[] = $scimUser->toSCIM(false); + } + + $filteredScimData = FilterUtil::performFiltering($filter, $scimUsersToFilter); + + $scimUsers = []; + foreach ($filteredScimData as $filteredScimUser) { + $scimUser = new CoreUser(); + $scimUser->fromSCIM($filteredScimUser); + $scimUsers[] = $scimUser; + } + + return $scimUsers; + } + return $scimUsers; } - public function getOneByUserName(string $userName): ?CoreUser - { - if (isset($userName) && !empty($userName)) { - // Try to find the first user from the database with the supplied username - $mockUser = $this->dataAccess::where('userName', $userName)->first(); + public function getOneById( + string $id, + $filter = '', + $startIndex = 0, + $count = 0, + $attributes = [], + $excludedAttributes = [] + ): ?CoreUser { + $mockUser = $this->dataAccess->getOneById($id); + $scimUser = $this->adapter->getCoreUser($mockUser); - // If such a user exists, map it to a SCIM user and return the SCIM user - if (isset($mockUser) && !empty($mockUser)) { - $this->adapter->setUser($mockUser); + if (isset($filter) && !empty($filter)) { + // Pass the single user as an array of an array, representing the user + $scimUsersToFilter = array($scimUser->toSCIM(false)); + $filteredScimData = FilterUtil::performFiltering($filter, $scimUsersToFilter); + if (!empty($filteredScimData)) { $scimUser = new CoreUser(); - $scimUser->setId($this->adapter->getId()); - $scimUser->setUserName($this->adapter->getUserName()); - - $scimUserMeta = new Meta(); - $scimUserMeta->setCreated($this->adapter->getCreatedAt()); - - $scimUser->setMeta($scimUserMeta); - $scimUser->setActive($this->adapter->getActive()); - $scimUser->setExternalId($this->adapter->getExternalId()); - $scimUser->setProfileUrl($this->adapter->getProfileUrl()); - + $scimUser->fromSCIM($filteredScimData[0]); return $scimUser; - } else { - return null; } } - } - public function getOneById(string $id): ?CoreUser - { - if (isset($id) && !empty($id)) { - // Try to find a user from the database with the supplied ID - $mockUser = $this->dataAccess::find($id); - - // If there's such a user, transform it to a SCIM user and return the SCIM user - if (isset($mockUser) && !empty($mockUser)) { - $this->adapter->setUser($mockUser); - - $scimUser = new CoreUser(); - $scimUser->setId($this->adapter->getId()); - $scimUser->setUserName($this->adapter->getUserName()); - - $scimUserMeta = new Meta(); - $scimUserMeta->setCreated($this->adapter->getCreatedAt()); - - $scimUser->setMeta($scimUserMeta); - $scimUser->setActive($this->adapter->getActive()); - $scimUser->setExternalId($this->adapter->getExternalId()); - $scimUser->setProfileUrl($this->adapter->getProfileUrl()); - - return $scimUser; - } else { - return null; - } - } else { - return null; - } + return $scimUser; } public function create($object): ?CoreUser { - if (isset($object) && !empty($object)) { - // Transform the incoming JSON user object to a SCIM object - // Then transform the SCIM object to a mock user that can be stored in the database - $scimUser = new CoreUser(); - $scimUser->fromSCIM($object); + $scimUserToCreate = new CoreUser(); + $scimUserToCreate->fromSCIM($object); - // $this->dataAccess represents an instance of the MockUser ORM model - // that we use for user storage to and retrieval from SQLite - $this->adapter->setUser($this->dataAccess); + $mockUserToCreate = $this->adapter->getMockUser($scimUserToCreate); - $this->adapter->setId($scimUser->getId()); - $this->adapter->setUserName($scimUser->getUserName()); - $this->adapter->setCreatedAt($scimUser->getMeta()->getCreated()); - $this->adapter->setActive($scimUser->getActive()); - $this->adapter->setExternalId($scimUser->getExternalId()); - $this->adapter->setProfileUrl($scimUser->getProfileUrl()); + $mockUserCreated = $this->dataAccess->create($mockUserToCreate); - // Obtain the transformed mock user from the adapter and try to save it to the database - $this->dataAccess = $this->adapter->getUser(); - if ($this->dataAccess->save()) { - return $scimUser; - } else { - return null; - } + if (isset($mockUserCreated)) { + return $this->adapter->getCoreUser($mockUserCreated); } + return null; } public function update(string $id, $object): ?CoreUser { - if (isset($id) && !empty($id)) { - // Try to find the user with the supplied ID - $mockUser = $this->dataAccess::find($id); - if (isset($mockUser) && !empty($mockUser)) { - // Transform the received JSON user object to a SCIM object - $scimUser = new CoreUser(); - $scimUser->fromSCIM($object); + $scimUserToUpdate = new CoreUser(); + $scimUserToUpdate->fromSCIM($object); - // Set the adapter's internal user object to the found user from the database - $this->adapter->setUser($mockUser); + $mockUserToUpdate = $this->adapter->getMockUser($scimUserToUpdate); - // Set the SCIM user's ID to be the same as the ID of the found user from the database - // Otherwise, we might lose the ID if a new one is supplied in the request - $scimUser->setId($this->adapter->getId()); + $mockUserUpdated = $this->dataAccess->update($id, $mockUserToUpdate); - // Transform the SCIM object to a mock user via the adapter and replace - // any properties of the mock user with the new properties incoming via the SCIM object - $this->adapter->setUserName($scimUser->getUserName()); - $this->adapter->setCreatedAt($scimUser->getMeta()->getCreated()); - $this->adapter->setActive($scimUser->getActive()); - $this->adapter->setExternalId($scimUser->getExternalId()); - $this->adapter->setProfileUrl($scimUser->getProfileUrl()); - - // Obtain the updated mock user via the adapter and try to save it to the database - $mockUser = $this->adapter->getUser(); - if ($mockUser->save()) { - return $scimUser; - } else { - return null; - } - } else { - return null; - } - } else { - return null; + if (isset($mockUserUpdated)) { + return $this->adapter->getCoreUser($mockUserUpdated); } + return null; } public function delete(string $id): bool { - if (isset($id) && !empty($id)) { - // Try to find the user to be deleted - $mockUser = $this->dataAccess::find($id); - if (!isset($mockUser) || empty($mockUser)) { - return false; - } - $mockUser->delete(); - return true; - } else { - return false; - } + return $this->dataAccess->delete($id); } } diff --git a/src/Repositories/Users/PfaUsersRepository.php b/src/Repositories/Users/PfaUsersRepository.php deleted file mode 100644 index 4c8cff7..0000000 --- a/src/Repositories/Users/PfaUsersRepository.php +++ /dev/null @@ -1,78 +0,0 @@ -dataAccess = $this->container->get('UsersDataAccess'); - $this->adapter = $this->container->get('UsersAdapter'); - $this->logger = $this->container->get(Logger::class); - } - - public function getAll(): array - { - $pfaUsers = $this->dataAccess->getAll(); - $scimUsers = []; - - foreach ($pfaUsers as $pfaUser) { - $scimUser = $this->adapter->getProvisioningUser($pfaUser); - $scimUsers[] = $scimUser; - } - - return $scimUsers; - } - - public function getOneById(string $id): ?ProvisioningUser - { - $pfaUser = $this->dataAccess->getOneById($id); - return $this->adapter->getProvisioningUser($pfaUser); - } - - public function create($object): ?ProvisioningUser - { - $scimUserToCreate = new ProvisioningUser(); - $scimUserToCreate->fromSCIM($object); - - $pfaUserToCreate = $this->adapter->getPfaUser($scimUserToCreate); - - $pfaUserCreated = $this->dataAccess->create($pfaUserToCreate); - - if (isset($pfaUserCreated)) { - return $this->adapter->getProvisioningUser($pfaUserCreated); - } - return null; - } - - public function update(string $id, $object): ?ProvisioningUser - { - $scimUserToUpdate = new ProvisioningUser(); - $scimUserToUpdate->fromSCIM($object); - - $pfaUserToUpdate = $this->adapter->getPfaUser($scimUserToUpdate); - - $pfaUserUpdated = $this->dataAccess->update($id, $pfaUserToUpdate); - - if (isset($pfaUserUpdated)) { - return $this->adapter->getProvisioningUser($pfaUserUpdated); - } - return null; - } - - public function delete(string $id): bool - { - return $this->dataAccess->delete($id); - } -} diff --git a/src/ScimServer.php b/src/ScimServer.php new file mode 100644 index 0000000..dafdc12 --- /dev/null +++ b/src/ScimServer.php @@ -0,0 +1,156 @@ +scimServerPhpRoot = $scimServerPhpRoot; + + /** + * Once we have the root directory of the project that's using + * OPF, we include its autoload file, so that we don't run into + * autoloading issues. + */ + require $this->scimServerPhpRoot . '/vendor/autoload.php'; + } + + public function setConfig(string $configFilePath) + { + if (!isset($configFilePath) || empty($configFilePath)) { + throw new Exception("Config file path must be supplied"); + } + + Util::setConfigFile($configFilePath); + } + + public function setDependencies(array $dependencies = array()) + { + $baseDependencies = require __DIR__ . '/Dependencies/dependencies.php'; + $this->dependencies = array_merge($baseDependencies, $dependencies); + } + + public function setMiddleware(array $middleware = array()) + { + $this->middleware = $middleware; + } + + public function run() + { + session_start(); + + // Instantiate the PHP-DI ContainerBuilder + $containerBuilder = new ContainerBuilder(); + + $config = Util::getConfigFile(); + if ($config['isInProduction']) { + $containerBuilder->enableCompilation(__DIR__ . '/../var/cache'); + } + + // Set up a few Slim-related settings + $settings = [ + 'settings' => [ + 'determineRouteBeforeAppMiddleware' => false, + 'displayErrorDetails' => true, // set to false in production + 'addContentLengthHeader' => false, // Allow the web server to send the content-length header + ] + ]; + $containerBuilder->addDefinitions($settings); + + // Set all necessary dependencies which are provided in this class' + // $dependencies attribute + $containerBuilder->addDefinitions($this->dependencies); + + // Build PHP-DI Container instance + $container = $containerBuilder->build(); + + // Instantiate the app + AppFactory::setContainer($container); + $this->app = AppFactory::create(); + + // Set our app's base path if it's configured + if (isset($config['basePath']) && !empty($config['basePath'])) { + $this->app->setBasePath($config['basePath']); + } + + // Register routes + $routes = require __DIR__ . '/routes.php'; + $routes($this->app); + + + // Iterate through the custom middleware (if any) and set it + if (isset($this->middleware) && !empty($this->middleware)) { + foreach ($this->middleware as $middleware) { + $this->app->addMiddleware($this->app->getContainer()->get($middleware)); + } + } + + // Add Routing Middleware + $this->app->addRoutingMiddleware(); + $this->app->addBodyParsingMiddleware(); + + $callableResolver = $this->app->getCallableResolver(); + $responseFactory = $this->app->getResponseFactory(); + + // Instantiate our custom Http error handler that we need further down below + $errorHandler = new HttpErrorHandler($callableResolver, $responseFactory); + + // Add error middleware + $errorMiddleware = $this->app->addErrorMiddleware( + $config['isInProduction'] ? false : true, + true, + true + ); + $errorMiddleware->setDefaultErrorHandler($errorHandler); + + // Run app + $this->app->run(); + } +} diff --git a/src/Util/Authentication/AuthenticatorInterface.php b/src/Util/Authentication/AuthenticatorInterface.php new file mode 100644 index 0000000..f897b1a --- /dev/null +++ b/src/Util/Authentication/AuthenticatorInterface.php @@ -0,0 +1,16 @@ +logger = $container->get(\Monolog\Logger::class); + } + + public function authenticate(string $credentials, array $authorizationInfo): bool + { + $jwtPayload = []; + $jwtSecret = Util::getConfigFile()['jwt']['secret']; + try { + $jwtPayload = (array) JWT::decode($credentials, new Key($jwtSecret, 'HS256')); + } catch (Exception $e) { + // If we land here, something was wrong with the JWT and auth has thus failed + $this->logger->error($e->getMessage()); + return false; + } + return true; + } +} diff --git a/src/Util/Filters/FilterParser.php b/src/Util/Filters/FilterParser.php new file mode 100644 index 0000000..fb9aa92 --- /dev/null +++ b/src/Util/Filters/FilterParser.php @@ -0,0 +1,40 @@ + 3) { + throw new FilterException("Incorrectly formatted AttributeExpression"); + } + + $attributeExpression = new AttributeExpression( + $splitFilterExpression[0], + $splitFilterExpression[1], + $splitFilterExpression[2] + ); + + return $attributeExpression; + } +} diff --git a/src/Util/Filters/FilterUtil.php b/src/Util/Filters/FilterUtil.php new file mode 100644 index 0000000..673bfb1 --- /dev/null +++ b/src/Util/Filters/FilterUtil.php @@ -0,0 +1,398 @@ +getAttributePath(); + $compareOperator = $attributeExpression->getCompareOperator(); + $comparisonValue = $attributeExpression->getComparisonValue(); + + // If we have a nested attribute (i.e., an attribute with subattribute(s)), + // then we split the attribute path by "." and store all the single parts + // in an array + // On the other hand (i.e., no nested attribute), we store the attribute path + // in an array which only contains the attribute path as a single element + if (strpos($attributePath, ".") !== false) { + $attributePath = explode(".", $attributePath); + } else { + $attributePath = array($attributePath); + } + + // Decide which filter function to call, based on the comparison operator + $filterFunctionToUse = null; + + switch ($compareOperator) { + case AttributeOperator::OP_EQ: + $filterFunctionToUse = "stringEqualsFilter"; + break; + + case AttributeOperator::OP_NE: + $filterFunctionToUse = "stringNotEqualsFilter"; + break; + + case AttributeOperator::OP_CO: + $filterFunctionToUse = "stringContainsFilter"; + break; + + case AttributeOperator::OP_SW: + $filterFunctionToUse = "stringStartsWithFilter"; + break; + + case AttributeOperator::OP_EW: + $filterFunctionToUse = "stringEndsWithFilter"; + break; + + case AttributeOperator::OP_PR: + if (isset($comparisonValue) && !empty($comparisonValue)) { + throw new FilterException("\"pr\" filter operator must be used without a comparison value"); + break; + } else { + $filterFunctionToUse = "hasValueFilter"; + break; + } + + case AttributeOperator::OP_GT: + $filterFunctionToUse = "greaterThanFilter"; + break; + + case AttributeOperator::OP_GE: + $filterFunctionToUse = "greaterThanOrEqualToFilter"; + break; + + case AttributeOperator::OP_LT: + $filterFunctionToUse = "lessThanFilter"; + break; + + case AttributeOperator::OP_LE: + $filterFunctionToUse = "lessThanOrEqualToFilter"; + break; + + default: + throw new FilterException("Unknown comparison operator found"); + break; + } + + // Put the function to call in a variable that we can use for calls below + $filterFunctionToUse = array(FilterUtil::class, $filterFunctionToUse); + + foreach ($scimData as $scimObject) { + // Obtain the attribute of the SCIM objects that we want to filter for + $attribute = $scimObject; + foreach ($attributePath as $attributePathComponent) { + if (array_key_exists($attributePathComponent, $attribute)) { + $attribute = $attribute[$attributePathComponent]; + } else { + throw new FilterException( + "Attribute " . $attributePathComponent . + " to filter by is undefined for the given SCIM resources" + ); + } + } + + $filterResult = false; + // If the filter function to call is "hasValueFilter", we need to pass it only the attribute + if (strcmp($filterFunctionToUse[1], "hasValueFilter") === 0) { + $filterResult = $filterFunctionToUse($attribute); + } else { + $filterResult = $filterFunctionToUse($attribute, $comparisonValue); + } + + if ($filterResult) { + $filteredScimData[] = $scimObject; + } + } + + return $filteredScimData; + } + + private static function stringEqualsFilter($attribute, $comparisonValue) + { + if (strcmp($attribute, $comparisonValue) === 0) { + return true; + } + + return false; + } + + private static function stringNotEqualsFilter($attribute, $comparisonValue) + { + if (strcmp($attribute, $comparisonValue) !== 0) { + return true; + } + + return false; + } + + private static function stringContainsFilter($attribute, $comparisonValue) + { + if (strpos($attribute, $comparisonValue) !== false) { + return true; + } + + return false; + } + + private static function stringStartsWithFilter($attribute, $comparisonValue) + { + if (substr($attribute, 0, strlen($comparisonValue)) === $comparisonValue) { + return true; + } + + return false; + } + + private static function stringEndsWithFilter($attribute, $comparisonValue) + { + if (substr($attribute, -strlen($comparisonValue)) === $comparisonValue) { + return true; + } + + return false; + } + + private static function hasValueFilter($attribute) + { + if (isset($attribute) && !empty($attribute)) { + return true; + } + + return false; + } + + private static function greaterThanFilter($attribute, $comparisonValue) + { + $comparisonValueType = gettype($comparisonValue); + + $attributeType = gettype($attribute); + + // First, make sure that the attribute and the comparison value have the same type + if (strcmp($attributeType, $comparisonValueType) !== 0) { + throw new FilterException( + "\"gt\" filter operator requires the attribute and the comparison value to be of the same type" + ); + } + + switch ($attributeType) { + case "string": + // Try to parse string to date to see if we need to compare timestamps + if ( + strtotime($attribute) !== false + && strtotime($comparisonValue) !== false + ) { + if (strtotime($attribute) > strtotime($comparisonValue)) { + return true; + } + } else { // If not date, but just regular string, then perform lexicographic comparison + if (strcasecmp($attribute, $comparisonValue) > 0) { + return true; + } + } + break; + + case "integer": + if ($attribute > $comparisonValue) { + return true; + } + break; + + // For any other data type, throw an exception + // TODO: Return 400 with "scimType" of "invalidFilter" as per + // https://www.rfc-editor.org/rfc/rfc7644.html#section-3.4.2.2 + default: + throw new FilterException("Unsupported type for \"gt\" operation"); + break; + } + + return false; + } + + private static function greaterThanOrEqualToFilter($attribute, $comparisonValue) + { + $comparisonValueType = gettype($comparisonValue); + + $attributeType = gettype($attribute); + + // First, make sure that the attribute and the comparison value have the same type + if (strcmp($attributeType, $comparisonValueType) !== 0) { + throw new FilterException( + "\"ge\" filter operator requires the attribute and the comparison value to be of the same type" + ); + } + + switch ($attributeType) { + case "string": + // Try to parse string to date to see if we need to compare timestamps + if ( + strtotime($attribute) !== false + && strtotime($comparisonValue) !== false + ) { + if (strtotime($attribute) >= strtotime($comparisonValue)) { + return true; + } + } else { // If not date, but just regular string, then perform lexicographic comparison + if (strcasecmp($attribute, $comparisonValue) >= 0) { + return true; + } + } + break; + + case "integer": + if ($attribute >= $comparisonValue) { + return true; + } + break; + + // For any other data type, throw an exception + // TODO: Return 400 with "scimType" of "invalidFilter" as per + // https://www.rfc-editor.org/rfc/rfc7644.html#section-3.4.2.2 + default: + throw new FilterException("Unsupported type for \"ge\" operation"); + break; + } + + return false; + } + + private static function lessThanFilter($attribute, $comparisonValue) + { + $comparisonValueType = gettype($comparisonValue); + + $attributeType = gettype($attribute); + + // First, make sure that the attribute and the comparison value have the same type + if (strcmp($attributeType, $comparisonValueType) !== 0) { + throw new FilterException( + "\"lt\" filter operator requires the attribute and the comparison value to be of the same type" + ); + } + + switch ($attributeType) { + case "string": + // Try to parse string to date to see if we need to compare timestamps + if ( + strtotime($attribute) !== false + && strtotime($comparisonValue) !== false + ) { + if (strtotime($attribute) < strtotime($comparisonValue)) { + return true; + } + } else { // If not date, but just regular string, then perform lexicographic comparison + if (strcasecmp($attribute, $comparisonValue) < 0) { + return true; + } + } + break; + + case "integer": + if ($attribute < $comparisonValue) { + return true; + } + break; + + // For any other data type, throw an exception + // TODO: Return 400 with "scimType" of "invalidFilter" as per + // https://www.rfc-editor.org/rfc/rfc7644.html#section-3.4.2.2 + default: + throw new FilterException("Unsupported type for \"lt\" operation"); + break; + } + + return false; + } + + private static function lessThanOrEqualToFilter($attribute, $comparisonValue) + { + $comparisonValueType = gettype($comparisonValue); + + $attributeType = gettype($attribute); + + // First, make sure that the attribute and the comparison value have the same type + if (strcmp($attributeType, $comparisonValueType) !== 0) { + throw new FilterException( + "\"le\" filter operator requires the attribute and the comparison value to be of the same type" + ); + } + + switch ($attributeType) { + case "string": + // Try to parse string to date to see if we need to compare timestamps + if ( + strtotime($attribute) !== false + && strtotime($comparisonValue) !== false + ) { + if (strtotime($attribute) <= strtotime($comparisonValue)) { + return true; + } + } else { // If not date, but just regular string, then perform lexicographic comparison + if (strcasecmp($attribute, $comparisonValue) <= 0) { + return true; + } + } + break; + + case "integer": + if ($attribute <= $comparisonValue) { + return true; + } + break; + + // For any other data type, throw an exception + // TODO: Return 400 with "scimType" of "invalidFilter" as per + // https://www.rfc-editor.org/rfc/rfc7644.html#section-3.4.2.2 + default: + throw new FilterException("Unsupported type for \"le\" operation"); + break; + } + + return false; + } +} diff --git a/src/Util/Util.php b/src/Util/Util.php index b5b0d0e..7df6663 100644 --- a/src/Util/Util.php +++ b/src/Util/Util.php @@ -2,11 +2,20 @@ namespace Opf\Util; +use Opf\Models\SCIM\Standard\Service\CoreResourceType; +use Opf\Models\SCIM\Standard\Service\CoreSchemaExtension; +use Exception; +use PDO; + abstract class Util { + private static string $defaultConfigFilePath = __DIR__ . '/../../config/config.default.php'; + private static string $customConfigFilePath; + public const USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User"; public const ENTERPRISE_USER_SCHEMA = "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"; - public const PROVISIONING_USER_SCHEMA = "urn:audriga:params:scim:schemas:extension:provisioning:2.0:User"; + public const PROVISIONING_USER_SCHEMA = "urn:ietf:params:scim:schemas:extension:audriga:provisioning:2.0:User"; + public const DOMAIN_SCHEMA = "urn:ietf:params:scim:schemas:audriga:2.0:Domain"; public const GROUP_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Group"; public const RESOURCE_TYPE_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:ResourceType"; public const SERVICE_PROVIDER_CONFIGURATION_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"; @@ -104,12 +113,20 @@ abstract class Util isset($config['db']['driver']) && !empty($config['db']['driver']) && isset($config['db']['host']) && !empty($config['db']['host']) && isset($config['db']['port']) && !empty($config['db']['port']) - && isset($config['db']['database']) && !empty($config['db']['database']) + && isset($config['db']['database']) && !empty($config['db']['database'] + && strcmp($config['db']['driver'], 'mysql') === 0) ) { return $config['db']['driver'] . ':host=' . $config['db']['host'] . ';port=' . $config['db']['port'] . ';dbname=' . $config['db']['database']; + } elseif ( + isset($config['db']['driver']) && !empty($config['db']['driver']) + && isset($config['db']['databaseFile']) && !empty($config['db']['databaseFile'] + && strcmp($config['db']['driver'], 'sqlite') === 0) + ) { + return $config['db']['driver'] . ':host=' + . '../../' . $config['db']['databaseFile']; } } } @@ -119,6 +136,55 @@ abstract class Util return null; } + /** + * Utility method for providing a DB connection via PDO + * + * @throws Exception if there was an issue with obtaining the DB connection + * @return PDO A PDO object representing the DB connection + */ + public static function getDbConnection() + { + // Try to obtain a DSN and complain with an Exception if there's no DSN + $dsn = self::buildDbDsn(); + if (!isset($dsn)) { + throw new Exception("Can't obtain DSN to connect to DB"); + } + + $config = self::getConfigFile(); + if (isset($config) && !empty($config)) { + if (isset($config['db']) && !empty($config['db'])) { + if ( + isset($config['db']['user']) + && !empty($config['db']['user']) + && isset($config['db']['password']) + && !empty($config['db']['password']) + ) { + $dbUsername = $config['db']['user']; + $dbPassword = $config['db']['password']; + } else { + // If no DB username and/or password provided, throw an Exception + throw new Exception("No DB username and/or password provided to connect to DB"); + } + } + } + + // Create the DB connection with PDO + try { + $dbConnection = new PDO($dsn, $dbUsername, $dbPassword); + } catch (Exception $e) { + throw $e; + } + + // Tell PDO explicitly to throw exceptions on errors, so as to have more info when debugging DB operations + if (isset($config['isInProduction'])) { + if ($config['isInProduction'] === false) { + $dbConnection->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + } + } + + return $dbConnection; + } + public static function getDomainFromEmail($email) { $parts = explode("@", $email); @@ -146,18 +212,147 @@ abstract class Util */ public static function getConfigFile() { - $defaultConfigFilePath = dirname(__DIR__) . '/../config/config.default.php'; - $customConfigFilePath = dirname(__DIR__) . '/../config/config.php'; - $config = []; // In case we don't have a custom config, we just rely on the default one - if (!file_exists($customConfigFilePath)) { - $config = require($defaultConfigFilePath); + if (!file_exists(self::$customConfigFilePath)) { + $config = require(self::$defaultConfigFilePath); } else { - $config = require($customConfigFilePath); + $config = require(self::$customConfigFilePath); } return $config; } + + public static function setConfigFile(string $configFilePath) + { + self::$customConfigFilePath = $configFilePath; + } + + /** + * A utility method for obtaining the supported SCIM resource types + * + * @param string $baseUrl A base URL required for each resource type that is returned + * + * @return array The array containing the resource types + */ + public static function getResourceTypes($baseUrl) + { + // Check which resource types are supported via the config file and in this method further down below + // make sure to only return those that are indeed supported + $config = Util::getConfigFile(); + $supportedResourceTypes = $config['supportedResourceTypes']; + + $scimResourceTypes = []; + + if (in_array('User', $supportedResourceTypes)) { + $userResourceType = new CoreResourceType(); + $userResourceType->setId("User"); + $userResourceType->setName("User"); + $userResourceType->setEndpoint("/Users"); + $userResourceType->setDescription("User Account"); + $userResourceType->setSchema(Util::USER_SCHEMA); + + if (in_array('EnterpriseUser', $supportedResourceTypes)) { + $enterpriseUserSchemaExtension = new CoreSchemaExtension(); + $enterpriseUserSchemaExtension->setSchema(Util::ENTERPRISE_USER_SCHEMA); + $enterpriseUserSchemaExtension->setRequired(true); + + $userResourceType->setSchemaExtensions(array($enterpriseUserSchemaExtension)); + } + if (in_array('ProvisioningUser', $supportedResourceTypes)) { + $provisioningUserSchemaExtension = new CoreSchemaExtension(); + $provisioningUserSchemaExtension->setSchema(Util::PROVISIONING_USER_SCHEMA); + $provisioningUserSchemaExtension->setRequired(true); + + $userResourceType->setSchemaExtensions(array($provisioningUserSchemaExtension)); + } + + $scimResourceTypes[] = $userResourceType->toSCIM(false, $baseUrl); + } + + if (in_array('Group', $supportedResourceTypes)) { + $groupResourceType = new CoreResourceType(); + $groupResourceType->setId("Group"); + $groupResourceType->setName("Group"); + $groupResourceType->setEndpoint("/Groups"); + $groupResourceType->setDescription("Group"); + $groupResourceType->setSchema("urn:ietf:params:scim:schemas:core:2.0:Group"); + $groupResourceType->setSchemaExtensions([]); + + $scimResourceTypes[] = $groupResourceType->toSCIM(false, $baseUrl); + } + + if (in_array('Domain', $supportedResourceTypes)) { + $domainResourceType = new CoreResourceType(); + $domainResourceType->setId("Domain"); + $domainResourceType->setName("Domain"); + $domainResourceType->setEndpoint("/Domains"); + $domainResourceType->setDescription("Domain"); + $domainResourceType->setSchema(self::DOMAIN_SCHEMA); + $domainResourceType->setSchemaExtensions([]); + + $scimResourceTypes[] = $domainResourceType->toSCIM(false, $baseUrl); + } + + return $scimResourceTypes; + } + + /** + * A utility method for obtaining the configured SCIM schemas + * + * @return array|null Return an array of schemas or null if no schemas were found + */ + public static function getSchemas() + { + $config = Util::getConfigFile(); + $supportedSchemas = $config['supportedResourceTypes']; + $mandatorySchemas = ['Schema', 'ResourceType']; + + $scimSchemas = []; + + // We store the schemas that the SCIM server supports in separate JSON files + // That's why we try to read them here and add them to $scimSchemas, which is returned as a result + $pathToSchemasDir = dirname(__DIR__, 2) . '/config/Schema'; + $schemaFiles = scandir($pathToSchemasDir, SCANDIR_SORT_NONE); + + // If scandir() failed (i.e., it returned false), then return null + if ($schemaFiles === false) { + return null; + } + + foreach ($schemaFiles as $schemaFile) { + if (!in_array($schemaFile, array('.', '..'))) { + $scimSchemaJsonDecoded = json_decode(file_get_contents($pathToSchemasDir . '/' . $schemaFile), true); + + // Only return schemas that are either mandatory (like the 'Schema' and 'ResourceType' ones) + // or supported by the server + if (in_array($scimSchemaJsonDecoded['name'], array_merge($supportedSchemas, $mandatorySchemas))) { + $scimSchemas[] = $scimSchemaJsonDecoded; + } + } + } + + return $scimSchemas; + } + + /** + * A utility method for obtaining the SCIM service provider configuration + * + * @return string|null Return the service provider configuration or null if no config was found + */ + public static function getServiceProviderConfig() + { + $pathToServiceProviderConfigurationFile = + dirname(__DIR__, 2) . '/config/ServiceProviderConfig/serviceProviderConfig.json'; + + $scimServiceProviderConfigurationFile = file_get_contents($pathToServiceProviderConfigurationFile); + + // If there was no service provider config JSON file found, then return null + if ($scimServiceProviderConfigurationFile === false) { + return null; + } + + return $scimServiceProviderConfigurationFile; + } } diff --git a/src/eloquent.php b/src/eloquent.php deleted file mode 100644 index 41088d4..0000000 --- a/src/eloquent.php +++ /dev/null @@ -1,15 +0,0 @@ -addConnection($dbSettings); - $capsule->setAsGlobal(); - $capsule->bootEloquent(); -}; diff --git a/src/routes.php b/src/routes.php index cb62e1c..b98f6f8 100644 --- a/src/routes.php +++ b/src/routes.php @@ -1,11 +1,15 @@ delete('/Groups/{id}', DeleteGroupAction::class)->setName('groups.delete'); } + if (in_array('Domain', $supportedResourceTypes)) { + $app->get('/Domains', ListDomainsAction::class)->setName('domains.list'); + $app->get('/Domains/{id}', GetDomainAction::class)->setName('domains.get'); + $app->post('/Domains', CreateDomainAction::class)->setName('domains.create'); + $app->put('/Domains/{id}', UpdateDomainAction::class)->setName('domains.update'); + $app->delete('/Domains/{id}', DeleteDomainAction::class)->setName('domains.delete'); + } + // ServiceProvider routes $app->get('/ResourceTypes', ListResourceTypesAction::class)->setName('resourceTypes.list'); $app->get('/Schemas', ListSchemasAction::class)->setName('schemas.list'); @@ -48,7 +60,4 @@ return function (App $app) { '/ServiceProviderConfig', ListServiceProviderConfigurationsAction::class )->setName('serviceProviderConfigs.list'); - - // JWT - $app->get('/jwt', GenerateJWTAction::class)->setName('jwt.generate'); }; diff --git a/test/postman/scim-env.postman_environment.json b/test/postman/scim-env.postman_environment.json index 17d0511..849922d 100644 --- a/test/postman/scim-env.postman_environment.json +++ b/test/postman/scim-env.postman_environment.json @@ -1,5 +1,5 @@ { - "id": "7880a4e5-d9ce-42f8-8ed0-57f886616527", + "id": "938b836a-076e-4938-a04a-3f088c363ff0", "name": "scim-env", "values": [ { @@ -7,9 +7,21 @@ "value": "http://localhost:8888", "type": "default", "enabled": true + }, + { + "key": "superadmin_jwt", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoiYWRtaW5AbG9jYWxob3N0Lm9yZyJ9.z5j4P09bk7StVda48g9_0Jt0LhopiNhjmmeguQCrVx8", + "type": "any", + "enabled": true + }, + { + "key": "non_superadmin_jwt", + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoidGVzdEB0ZXN0Lm9yZyJ9.Lu1JcCSUiTRPGeuLgs6k6TG5DgCpuAIyA8IKg_nli5M", + "type": "default", + "enabled": true } ], "_postman_variable_scope": "environment", - "_postman_exported_at": "2022-04-14T14:54:36.929Z", - "_postman_exported_using": "Postman/9.15.2" -} \ No newline at end of file + "_postman_exported_at": "2022-10-07T10:05:35.659Z", + "_postman_exported_using": "Postman/9.31.0" +} diff --git a/test/postman/scim-opf-pfa.postman_collection.json b/test/postman/scim-opf-pfa.postman_collection.json deleted file mode 100644 index 9f3a833..0000000 --- a/test/postman/scim-opf-pfa.postman_collection.json +++ /dev/null @@ -1,597 +0,0 @@ -{ - "info": { - "_postman_id": "2b79327d-70c4-425f-942f-6037f38d67e5", - "name": "PFA SCIM PHP Collection", - "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" - }, - "item": [ - { - "name": "JWT", - "item": [ - { - "name": "Get JWT", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "const response = pm.response.json();", - "pm.environment.set(\"jwt_token\", response.Bearer)" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{url}}/jwt", - "host": [ - "{{url}}" - ], - "path": [ - "jwt" - ] - } - }, - "response": [] - } - ] - }, - { - "name": "ResourceTypes", - "item": [ - { - "name": "Read all ResourceTypes", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Response status code is 200\", () => {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test(\"Content-Type header is application/scim+json\", () => {", - " pm.expect(pm.response.headers.get('Content-Type')).to.eql('application/scim+json');", - "});", - "", - "pm.test(\"Response body is not empty\", () => {", - " pm.expect(pm.response.json().Resources).to.not.be.empty;", - "});", - "", - "pm.test(\"Response body contains exactly one entry\", () => {", - " pm.expect(pm.response.json().Resources.length).to.eql(1);", - "});", - "", - "pm.test(\"Response body contains ResourceType with id \\\"User\\\"\", () => {", - " pm.expect(pm.response.json().Resources[0].id).to.eql(\"User\");", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer: {{jwt_token}}", - "type": "default" - } - ], - "url": { - "raw": "{{url}}/ResourceTypes", - "host": [ - "{{url}}" - ], - "path": [ - "ResourceTypes" - ] - } - }, - "response": [] - } - ] - }, - { - "name": "Schemas", - "item": [ - { - "name": "Read all Schemas", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Response status code is 200\", () => {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test(\"Content-Type header is application/scim+json\", () => {", - " pm.expect(pm.response.headers.get('Content-Type')).to.eql('application/scim+json');", - "});", - "", - "pm.test(\"Response body is not empty\", () => {", - " pm.expect(pm.response.json().Resources).to.not.be.empty;", - "});", - "", - "pm.test(\"Response body contains exactly four entries\", () => {", - " pm.expect(pm.response.json().Resources.length).to.eql(4);", - "});", - "", - "pm.test(\"Response body contains Schema with id \\\"urn:audriga:params:scim:schemas:extension:provisioning:2.0:User\\\"\", () => {", - " pm.expect(pm.response.json().Resources[0].id).to.eql(\"urn:audriga:params:scim:schemas:extension:provisioning:2.0:User\");", - "});", - "", - "pm.test(\"Response body contains Schema with id \\\"urn:ietf:params:scim:schemas:core:2.0:ResourceType\\\"\", () => {", - " pm.expect(pm.response.json().Resources[1].id).to.eql(\"urn:ietf:params:scim:schemas:core:2.0:ResourceType\");", - "});", - "", - "pm.test(\"Response body contains Schema with id \\\"urn:ietf:params:scim:schemas:core:2.0:User\\\"\", () => {", - " pm.expect(pm.response.json().Resources[2].id).to.eql(\"urn:ietf:params:scim:schemas:core:2.0:User\");", - "});", - "", - "pm.test(\"Response body contains Schema with id \\\"urn:ietf:params:scim:schemas:core:2.0:Schema\\\"\", () => {", - " pm.expect(pm.response.json().Resources[3].id).to.eql(\"urn:ietf:params:scim:schemas:core:2.0:Schema\");", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer: {{jwt_token}}", - "type": "default" - } - ], - "url": { - "raw": "{{url}}/Schemas", - "host": [ - "{{url}}" - ], - "path": [ - "Schemas" - ] - } - }, - "response": [] - } - ] - }, - { - "name": "ServiceProviderConfigs", - "item": [ - { - "name": "Read all ServiceProviderConfigs", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Response status code is 200\", () => {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test(\"Content-Type header is application/scim+json\", () => {", - " pm.expect(pm.response.headers.get('Content-Type')).to.eql('application/scim+json');", - "});", - "", - "pm.test(\"Response body is not empty\", () => {", - " pm.expect(pm.response.json()).to.not.be.empty;", - "});", - "", - "pm.test(\"Response body contains a ServiceProviderConfig with a correct schema\", () => {", - " pm.expect(pm.response.json().schemas).to.include(\"urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig\");", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer: {{jwt_token}}", - "type": "default" - } - ], - "url": { - "raw": "{{url}}/ServiceProviderConfig", - "host": [ - "{{url}}" - ], - "path": [ - "ServiceProviderConfig" - ] - } - }, - "response": [] - } - ] - }, - { - "name": "Users", - "item": [ - { - "name": "Create a single user", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Response status code is 201\", () => {", - " pm.response.to.have.status(201);", - "});", - "", - "pm.test(\"Content-Type header is application/scim+json\", () => {", - " pm.expect(pm.response.headers.get('Content-Type')).to.eql('application/scim+json');", - "});", - "", - "pm.test(\"Response body is not empty\", () => {", - " pm.expect(pm.response.json()).to.not.be.empty;", - "});", - "", - "pm.test(\"Response body contains user with userName \\\"createdtestuser@test.org\\\"\", () => {", - " pm.expect(pm.response.json().userName).to.eql(\"createdtestuser@test.org\");", - "});", - "", - "pm.test(\"Response body contains a valid non-null user ID (the ID of the user which was created)\", () => {", - " pm.expect(pm.response.json().id).to.not.be.null;", - "});", - "", - "pm.test(\"Response body contains user with sizeQuota equal to 12345\", () => {", - " pm.expect(pm.response.json()[\"urn:audriga:params:scim:schemas:extension:provisioning:2.0:User\"].sizeQuota).to.eql(12345);", - "});", - "", - "pm.collectionVariables.set(\"testUserId\", pm.response.json().id);" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/scim+json", - "type": "default" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"userName\": \"createdtestuser@test.org\",\n \"id\": \"createdtestuser@test.org\",\n \"active\": true,\n \"password\": \"somepass123\",\n \"displayName\": \"createdtestuser\",\n \"emails\": [{\"value\":\"createdtestuser@test.org\"}],\n \"urn:audriga:params:scim:schemas:extension:provisioning:2.0:User\": {\n \"sizeQuota\": 12345\n }\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{url}}/Users", - "host": [ - "{{url}}" - ], - "path": [ - "Users" - ] - } - }, - "response": [] - }, - { - "name": "Read a single user", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Response status code is 200\", () => {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test(\"Content-Type header is application/scim+json\", () => {", - " pm.expect(pm.response.headers.get('Content-Type')).to.eql('application/scim+json');", - "});", - "", - "pm.test(\"Response body is not empty\", () => {", - " pm.expect(pm.response.json()).to.not.be.empty;", - "});", - "", - "pm.test(\"Response body contains the user ID of the user we want to read\", () => {", - " pm.expect(pm.response.json().id).to.eql(\"createdtestuser@test.org\");", - "});", - "", - "pm.test(\"Response body contains user with userName \\\"createdtestuser@test.org\\\"\", () => {", - " pm.expect(pm.response.json().userName).to.eql(\"createdtestuser@test.org\");", - "});", - "", - "pm.test(\"Response body contains user with sizeQuota equal to 12345\", () => {", - " pm.expect(pm.response.json()[\"urn:audriga:params:scim:schemas:extension:provisioning:2.0:User\"].sizeQuota).to.eql(12345);", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer: {{jwt_token}}", - "type": "default" - } - ], - "url": { - "raw": "{{url}}/Users/{{testUserId}}", - "host": [ - "{{url}}" - ], - "path": [ - "Users", - "{{testUserId}}" - ] - } - }, - "response": [] - }, - { - "name": "Read a non-existent user", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Response status code is 404\", () => {", - " pm.response.to.have.status(404);", - "});", - "", - "pm.test(\"Response body is empty\", () => {", - " pm.expect(pm.response.body).to.be.undefined;", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer: {{jwt_token}}", - "type": "default" - } - ], - "url": { - "raw": "{{url}}/Users/some-non-existent-user@test.org", - "host": [ - "{{url}}" - ], - "path": [ - "Users", - "some-non-existent-user@test.org" - ] - } - }, - "response": [] - }, - { - "name": "Read all users", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Response status code is 200\", () => {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test(\"Content-Type header is application/scim+json\", () => {", - " pm.expect(pm.response.headers.get('Content-Type')).to.eql('application/scim+json');", - "});", - "", - "pm.test(\"Response body is not empty\", () => {", - " pm.expect(pm.response.json().Resources).to.not.be.empty;", - "});", - "", - "pm.test(\"Response body contains user with userName \\\"createdtestuser@test.org\\\"\", () => {", - " pm.expect(pm.response.json().Resources[0].userName).to.eql(\"createdtestuser@test.org\");", - "});", - "", - "pm.test(\"Response body contains user with sizeQuota equal to 12345\", () => {", - " pm.expect(pm.response.json().Resources[0][\"urn:audriga:params:scim:schemas:extension:provisioning:2.0:User\"].sizeQuota).to.eql(12345);", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer: {{jwt_token}}", - "type": "default" - } - ], - "url": { - "raw": "{{url}}/Users", - "host": [ - "{{url}}" - ], - "path": [ - "Users" - ] - } - }, - "response": [] - }, - { - "name": "Update a single user", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Response status code is 200\", () => {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test(\"Content-Type header is application/scim+json\", () => {", - " pm.expect(pm.response.headers.get('Content-Type')).to.eql('application/scim+json');", - "});", - "", - "pm.test(\"Response body is not empty\", () => {", - " pm.expect(pm.response.json()).to.not.be.empty;", - "});", - "", - "pm.test(\"Response body contains the user ID of the user we want to read\", () => {", - " pm.expect(pm.response.json().id).to.eql(pm.collectionVariables.get('testUserId'));", - "});", - "", - "pm.test(\"Response body contains user with displayName \\\"updatedtestuser\\\"\", () => {", - " pm.expect(pm.response.json().displayName).to.eql(\"updatedtestuser\");", - "});", - "", - "pm.test(\"Response body contains user with sizeQuota equal to 123456789\", () => {", - " pm.expect(pm.response.json()[\"urn:audriga:params:scim:schemas:extension:provisioning:2.0:User\"].sizeQuota).to.eql(123456789);", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "PUT", - "header": [ - { - "key": "Content-Type", - "value": "application/scim+json", - "type": "default" - }, - { - "key": "Authorization", - "value": "Bearer: {{jwt_token}}", - "type": "default" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"id\": \"createdtestuser@test.org\",\n \"displayName\": \"updatedtestuser\",\n \"urn:audriga:params:scim:schemas:extension:provisioning:2.0:User\": {\n \"sizeQuota\": 123456789\n }\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{url}}/Users/{{testUserId}}", - "host": [ - "{{url}}" - ], - "path": [ - "Users", - "{{testUserId}}" - ] - } - }, - "response": [] - }, - { - "name": "Delete a single user", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Response status code is 204\", () => {", - " pm.response.to.have.status(204);", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "DELETE", - "header": [ - { - "key": "Content-Type", - "value": "application/scim+json", - "type": "default" - }, - { - "key": "Authorization", - "value": "Bearer: {{jwt_token}}", - "type": "default" - } - ], - "url": { - "raw": "{{url}}/Users/{{testUserId}}", - "host": [ - "{{url}}" - ], - "path": [ - "Users", - "{{testUserId}}" - ] - } - }, - "response": [] - } - ] - } - ], - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "{{jwt_token}}", - "type": "string" - } - ] - }, - "event": [ - { - "listen": "prerequest", - "script": { - "type": "text/javascript", - "exec": [ - "" - ] - } - }, - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "" - ] - } - } - ], - "variable": [ - { - "key": "testUserId", - "value": "" - }, - { - "key": "testGroupId", - "value": "" - } - ] -} \ No newline at end of file diff --git a/test/postman/scim-opf.postman_collection.json b/test/postman/scim-opf.postman_collection.json index 0d2bcd9..94861b1 100644 --- a/test/postman/scim-opf.postman_collection.json +++ b/test/postman/scim-opf.postman_collection.json @@ -1,10 +1,44 @@ { "info": { - "_postman_id": "73043646-f766-4adc-96ee-05316cc59bdd", + "_postman_id": "c90f5107-b2fb-46dc-9a32-b07f5ff68440", "name": "SCIM PHP Collection", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, "item": [ + { + "name": "JWT", + "item": [ + { + "name": "Get JWT", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "const response = pm.response.json();", + "pm.environment.set(\"jwt_token\", response.Bearer)" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{url}}/jwt", + "host": [ + "{{url}}" + ], + "path": [ + "jwt" + ] + } + }, + "response": [] + } + ] + }, { "name": "Users", "item": [ @@ -138,7 +172,8 @@ "});", "", "pm.test(\"Response body contains user with userName \\\"createdtestuser\\\"\", () => {", - " pm.expect(pm.response.json().Resources[0].userName).to.eql(\"createdtestuser\");", + " var resources = pm.response.json().Resources.map(x => x.userName);", + " pm.expect(resources).to.contain(\"createdtestuser\");", "});" ], "type": "text/javascript" @@ -160,6 +195,154 @@ }, "response": [] }, + { + "name": "Filter users by userName", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Response status code is 200\", () => {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Content-Type header is application/scim+json\", () => {", + " pm.expect(pm.response.headers.get('Content-Type')).to.eql('application/scim+json');", + "});", + "", + "pm.test(\"Response body is not empty\", () => {", + " pm.expect(pm.response.json()).to.not.be.empty;", + "});", + "", + "pm.test(\"Response body contains user with userName \\\"createdtestuser\\\"\", () => {", + " pm.expect(pm.response.json().Resources[0].userName).to.eql(\"createdtestuser\");", + "});", + "", + "pm.test(\"Response body contains a valid non-null user ID\", () => {", + " pm.expect(pm.response.json().id).to.not.be.null;", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{url}}/Users?filter=userName%20eq%20createdtestuser", + "host": [ + "{{url}}" + ], + "path": [ + "Users" + ], + "query": [ + { + "key": "filter", + "value": "userName%20eq%20createdtestuser" + } + ] + } + }, + "response": [] + }, + { + "name": "Filter users with invalid filter", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Response status code is 500\", () => {", + " pm.response.to.have.status(500);", + "});", + "", + "pm.test(\"Content-Type header is application/scim+json\", () => {", + " pm.expect(pm.response.headers.get('Content-Type')).to.eql('application/scim+json');", + "});", + "", + "pm.test(\"Response body is not empty\", () => {", + " pm.expect(pm.response.json()).to.not.be.empty;", + "});", + "", + "pm.test(\"Response body contains error '\\\"pr\\\" filter operator must be used without a comparison value'\", () => {", + " pm.expect(pm.response.json().error.description).to.eql(\"\\\"pr\\\" filter operator must be used without a comparison value\");", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{url}}/Users?filter=userName%20pr%20createdtestuser", + "host": [ + "{{url}}" + ], + "path": [ + "Users" + ], + "query": [ + { + "key": "filter", + "value": "userName%20pr%20createdtestuser" + } + ] + } + }, + "response": [] + }, + { + "name": "Filter users with unmatching filter", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Response status code is 200\", () => {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Content-Type header is application/scim+json\", () => {", + " pm.expect(pm.response.headers.get('Content-Type')).to.eql('application/scim+json');", + "});", + "", + "pm.test(\"Response body is not empty\", () => {", + " pm.expect(pm.response.json()).to.not.be.empty;", + "});", + "", + "pm.test(\"Response body contains no users\", () => {", + " pm.expect(pm.response.json().Resources).to.be.empty;", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{url}}/Users?filter=userName%20sw%20somenonexistentstring", + "host": [ + "{{url}}" + ], + "path": [ + "Users" + ], + "query": [ + { + "key": "filter", + "value": "userName%20sw%20somenonexistentstring" + } + ] + } + }, + "response": [] + }, { "name": "Update a single user", "event": [ @@ -403,7 +586,8 @@ "});", "", "pm.test(\"Response body contains group with displayName \\\"createdtestgroup\\\"\", () => {", - " pm.expect(pm.response.json().Resources[0].displayName).to.eql(\"createdtestgroup\");", + " var resources = pm.response.json().Resources.map(x => x.displayName);", + " pm.expect(resources).to.contain(\"createdtestgroup\");", "});" ], "type": "text/javascript" @@ -425,6 +609,154 @@ }, "response": [] }, + { + "name": "Filter groups by displayName", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Response status code is 200\", () => {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Content-Type header is application/scim+json\", () => {", + " pm.expect(pm.response.headers.get('Content-Type')).to.eql('application/scim+json');", + "});", + "", + "pm.test(\"Response body is not empty\", () => {", + " pm.expect(pm.response.json()).to.not.be.empty;", + "});", + "", + "pm.test(\"Response body contains group with displayName \\\"createdtestgroup\\\"\", () => {", + " pm.expect(pm.response.json().Resources[0].displayName).to.eql(\"createdtestgroup\");", + "});", + "", + "pm.test(\"Response body contains a valid non-null group ID\", () => {", + " pm.expect(pm.response.json().id).to.not.be.null;", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{url}}/Groups?filter=displayName%20eq%20createdtestgroup", + "host": [ + "{{url}}" + ], + "path": [ + "Groups" + ], + "query": [ + { + "key": "filter", + "value": "displayName%20eq%20createdtestgroup" + } + ] + } + }, + "response": [] + }, + { + "name": "Filter groups with invalid filter", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Response status code is 500\", () => {", + " pm.response.to.have.status(500);", + "});", + "", + "pm.test(\"Content-Type header is application/scim+json\", () => {", + " pm.expect(pm.response.headers.get('Content-Type')).to.eql('application/scim+json');", + "});", + "", + "pm.test(\"Response body is not empty\", () => {", + " pm.expect(pm.response.json()).to.not.be.empty;", + "});", + "", + "pm.test(\"Response body contains error '\\\"pr\\\" filter operator must be used without a comparison value'\", () => {", + " pm.expect(pm.response.json().error.description).to.eql(\"\\\"pr\\\" filter operator must be used without a comparison value\");", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{url}}/Groups?filter=displayName%20pr%20createdtestgroup", + "host": [ + "{{url}}" + ], + "path": [ + "Groups" + ], + "query": [ + { + "key": "filter", + "value": "displayName%20pr%20createdtestgroup" + } + ] + } + }, + "response": [] + }, + { + "name": "Filter groups with unmatching filter", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Response status code is 200\", () => {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Content-Type header is application/scim+json\", () => {", + " pm.expect(pm.response.headers.get('Content-Type')).to.eql('application/scim+json');", + "});", + "", + "pm.test(\"Response body is not empty\", () => {", + " pm.expect(pm.response.json()).to.not.be.empty;", + "});", + "", + "pm.test(\"Response body contains no groups\", () => {", + " pm.expect(pm.response.json().Resources).to.be.empty;", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{url}}/Groups?filter=displayName%20sw%20somenonexistentstring", + "host": [ + "{{url}}" + ], + "path": [ + "Groups" + ], + "query": [ + { + "key": "filter", + "value": "displayName%20sw%20somenonexistentstring" + } + ] + } + }, + "response": [] + }, { "name": "Update a single group", "event": [ @@ -579,6 +911,8 @@ "listen": "test", "script": { "exec": [ + "jsonData = pm.response.json();", + "", "pm.test(\"Response status code is 200\", () => {", " pm.response.to.have.status(200);", "});", @@ -588,31 +922,30 @@ "});", "", "pm.test(\"Response body is not empty\", () => {", - " pm.expect(pm.response.json().Resources).to.not.be.empty;", + " pm.expect(jsonData.Resources).to.not.be.empty;", "});", "", - "pm.test(\"Response body contains exactly five entries\", () => {", - " pm.expect(pm.response.json().Resources.length).to.eql(5);", + "pm.test(\"Response body contains exactly four entries\", () => {", + " pm.expect(jsonData.Resources.length).to.eql(4);", "});", "", "pm.test(\"Response body contains Schema with id \\\"urn:ietf:params:scim:schemas:core:2.0:Group\\\"\", () => {", - " pm.expect(pm.response.json().Resources[0].id).to.eql(\"urn:ietf:params:scim:schemas:core:2.0:Group\");", + " isContained = jsonData.Resources.some((resource) => resource.id === \"urn:ietf:params:scim:schemas:core:2.0:Group\");", + " pm.expect(isContained).to.be.true;", "});", "", "pm.test(\"Response body contains Schema with id \\\"urn:ietf:params:scim:schemas:core:2.0:ResourceType\\\"\", () => {", - " pm.expect(pm.response.json().Resources[1].id).to.eql(\"urn:ietf:params:scim:schemas:core:2.0:ResourceType\");", + " isContained = jsonData.Resources.some((resource) => resource.id === \"urn:ietf:params:scim:schemas:core:2.0:ResourceType\");", + " pm.expect(isContained).to.be.true;", "});", "", "pm.test(\"Response body contains Schema with id \\\"urn:ietf:params:scim:schemas:core:2.0:Schema\\\"\", () => {", - " pm.expect(pm.response.json().Resources[2].id).to.eql(\"urn:ietf:params:scim:schemas:core:2.0:Schema\");", + " isContained = jsonData.Resources.some((resource) => resource.id === \"urn:ietf:params:scim:schemas:core:2.0:Schema\");", + " pm.expect(isContained).to.be.true;", "});", "", "pm.test(\"Response body contains Schema with id \\\"urn:ietf:params:scim:schemas:core:2.0:User\\\"\", () => {", - " pm.expect(pm.response.json().Resources[3].id).to.eql(\"urn:ietf:params:scim:schemas:core:2.0:User\");", - "});", - "", - "pm.test(\"Response body contains Schema with id \\\"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User\\\"\", () => {", - " pm.expect(pm.response.json().Resources[4].id).to.eql(\"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User\");", + " isContained = jsonData.Resources.some((resource) => resource.id === \"urn:ietf:params:scim:schemas:core:2.0:User\");", "});" ], "type": "text/javascript" @@ -684,6 +1017,16 @@ ] } ], + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{jwt_token}}", + "type": "string" + } + ] + }, "event": [ { "listen": "prerequest", diff --git a/test/resources/filterTestGroups.json b/test/resources/filterTestGroups.json new file mode 100644 index 0000000..4988d3e --- /dev/null +++ b/test/resources/filterTestGroups.json @@ -0,0 +1,17 @@ +[ + { + "displayName": "testGroup", + "members": [] + }, + { + "displayName": "testGroup2", + "members": [] + }, + { + "displayName": "testGroup3", + "members": [ + "12345678-9012-3456-7890-12345678", + "87654321-2109-6543-0987-87654321" + ] + } +] \ No newline at end of file diff --git a/test/resources/filterTestUsers.json b/test/resources/filterTestUsers.json new file mode 100644 index 0000000..659df83 --- /dev/null +++ b/test/resources/filterTestUsers.json @@ -0,0 +1,19 @@ +[ + { + "userName": "testuser", + "profileUrl": "http://example.com/testuser" + }, + { + "userName": "testuser2", + "externalId": "testuser2external", + "name": { + "givenName": "given", + "familyName": "family" + } + }, + { + "userName": "testuser3", + "externalId": "testuser3external", + "profileUrl": "http://example.com/testuser3" + } +] \ No newline at end of file diff --git a/test/resources/mock-test-config.php b/test/resources/mock-test-config.php new file mode 100644 index 0000000..fdf4dcd --- /dev/null +++ b/test/resources/mock-test-config.php @@ -0,0 +1,25 @@ + false, // Set to true when deploying in production + 'basePath' => null, // If you want to specify a base path for the Slim app, add it here (e.g., '/test/scim') + 'supportedResourceTypes' => ['User', 'Group'], // Specify all the supported SCIM ResourceTypes by their names here + + // SQLite DB settings + 'db' => [ + 'driver' => 'sqlite', // Type of DB + 'databaseFile' => 'db/scim-mock.sqlite' // DB name + ], + + // Monolog settings + 'logger' => [ + 'name' => 'scim-opf', + 'path' => isset($_ENV['docker']) ? 'php://stdout' : __DIR__ . '/../../logs/app.log', + 'level' => \Monolog\Logger::DEBUG, + ], + + // Bearer token settings + 'jwt' => [ + 'secret' => 'secret' + ] +]; diff --git a/test/unit/FilterParserTest.php b/test/unit/FilterParserTest.php new file mode 100644 index 0000000..27b2d00 --- /dev/null +++ b/test/unit/FilterParserTest.php @@ -0,0 +1,57 @@ +assertInstanceOf(FilterExpression::class, $attributeFilterExpression); + $this->assertInstanceOf(AttributeExpression::class, $attributeFilterExpression); + + $this->assertEquals("userName", $attributeFilterExpression->getAttributePath()); + $this->assertEquals(AttributeOperator::OP_EQ, $attributeFilterExpression->getCompareOperator()); + $this->assertEquals("sometestusername", $attributeFilterExpression->getComparisonValue()); + + + // Test a "pr" filter expression + $filterString = "meta.created pr"; + $attributeFilterExpression = FilterParser::parseFilterExpression($filterString); + + $this->assertInstanceOf(FilterExpression::class, $attributeFilterExpression); + $this->assertInstanceOf(AttributeExpression::class, $attributeFilterExpression); + + $this->assertEquals("meta.created", $attributeFilterExpression->getAttributePath()); + $this->assertEquals(AttributeOperator::OP_PR, $attributeFilterExpression->getCompareOperator()); + $this->assertNull($attributeFilterExpression->getComparisonValue()); + } + + public function testParseTooShortFilterExpression() + { + $this->expectException(FilterException::class); + $this->expectExceptionMessage("Incorrectly formatted AttributeExpression"); + + $filterString = "somestring"; + $parsedFilterExpression = FilterParser::parseFilterExpression($filterString); + } + + public function testParseTooLongFilterExpression() + { + $this->expectException(FilterException::class); + $this->expectExceptionMessage("Incorrectly formatted AttributeExpression"); + + $filterString = "userName eq some value"; + $parsedFilterExpression = FilterParser::parseFilterExpression($filterString); + } +} diff --git a/test/unit/FilterUtilTest.php b/test/unit/FilterUtilTest.php new file mode 100644 index 0000000..7b084b1 --- /dev/null +++ b/test/unit/FilterUtilTest.php @@ -0,0 +1,56 @@ +scimGroups = json_decode(file_get_contents(__DIR__ . '/../resources/filterTestGroups.json'), true); + $this->scimUsers = json_decode(file_get_contents(__DIR__ . '/../resources/filterTestUsers.json'), true); + } + + public function tearDown(): void + { + $this->scimGroups = []; + $this->scimUsers = []; + } + + public function testGroupFiltering() + { + // "ne" filter test + $filterString = "displayName ne testGroup"; + $filteredScimGroups = FilterUtil::performFiltering($filterString, $this->scimGroups); + + $this->assertEquals(array_splice($this->scimGroups, 1, 2), $filteredScimGroups); + } + + public function testUserFiltering() + { + // "sw" filter test + $filterString = "userName sw testuser"; + $filteredScimUsers = FilterUtil::performFiltering($filterString, $this->scimUsers); + + $this->assertEquals($this->scimUsers, $filteredScimUsers); + } + + public function testInvalidFiltering() + { + $this->expectException(FilterException::class); + $this->expectExceptionMessage("Incorrectly formatted AttributeExpression"); + + $filterString = "externalId eq some value"; + $filteredScimUsers = FilterUtil::performFiltering($filterString, $this->scimUsers); + } +} diff --git a/test/unit/MockGroupsDataAccessTest.php b/test/unit/MockGroupsDataAccessTest.php index 4097cbf..9828ba5 100644 --- a/test/unit/MockGroupsDataAccessTest.php +++ b/test/unit/MockGroupsDataAccessTest.php @@ -3,85 +3,50 @@ namespace Opf\Test\Unit; use Illuminate\Database\Capsule\Manager; +use Opf\Adapters\Groups\MockGroupAdapter; use Opf\DataAccess\Groups\MockGroupDataAccess; +use Opf\Models\SCIM\Standard\Groups\CoreGroup; +use Opf\Util\Util; use PHPUnit\Framework\TestCase; use SQLite3; final class MockGroupsDataAccessTest extends TestCase { - /** @var SQLite3 */ - protected $database = null; + /** @var Opf\Models\SCIM\Standard\Groups\CoreGroup */ + protected $coreGroup = null; - /** @var array */ - protected $dbSettings = null; - - /** @var Illuminate\Database\Capsule\Manager */ - protected $capsule = null; - - /** @var Opf\Models\CoreGroup */ + /** @var Opf\DataAccess\Groups\MockGroupDataAccess */ protected $mockGroupDataAccess = null; + /** @var Opf\Opf\Adapters\Groups\MockGroupAdapter */ + protected $mockGroupAdapter = null; + public function setUp(): void { - $this->database = new SQLite3(__DIR__ . '/../resources/test-scim-opf.sqlite'); - - $groupDbSql = "CREATE TABLE IF NOT EXISTS groups ( - id varchar(160) NOT NULL UNIQUE, - displayName varchar(160) NOT NULL DEFAULT '', - members TEXT NOT NULL DEFAULT '', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NULL - )"; - - $this->database->exec($groupDbSql); - - $createGroupSql = "INSERT INTO groups ( - id, - displayName, - members - ) VALUES ( - '12345678-9012-3456-7890-12345679', - 'testGroup', - '12345678-9012-3456-7890-12345678' - )"; - - $this->database->exec($createGroupSql); - - $this->dbSettings = [ - 'driver' => 'sqlite', - 'database' => __DIR__ . '/../resources/test-scim-opf.sqlite', - 'prefix' => '' - ]; - - $this->capsule = new Manager(); - $this->capsule->addConnection($this->dbSettings); - $this->capsule->setAsGlobal(); - $this->capsule->bootEloquent(); - + Util::setConfigFile(__DIR__ . '/../resources/mock-test-config.php'); + $this->coreGroup = new CoreGroup(); + $this->mockGroupAdapter = new MockGroupAdapter(); $this->mockGroupDataAccess = new MockGroupDataAccess(); } public function tearDown(): void { + $this->coreGroup = null; + $this->mockGroupAdapter = null; $this->mockGroupDataAccess = null; - $this->capsule = null; - $this->dbSettings = null; - $this->database->exec("DROP TABLE groups"); - $this->database = null; - - unlink(__DIR__ . '/../resources/test-scim-opf.sqlite'); - } - - public function testReadAllGroups() - { - $this->assertNotEmpty($this->mockGroupDataAccess->all()); } public function testCreateGroup() { $testGroupJson = json_decode(file_get_contents(__DIR__ . '/../resources/testGroup.json'), true); - $this->mockGroupDataAccess->fromSCIM($testGroupJson); - $groupCreateRes = $this->mockGroupDataAccess->save(); - $this->assertTrue($groupCreateRes); + $this->coreGroup->fromSCIM($testGroupJson); + $mockGroup = $this->mockGroupAdapter->getMockGroup($this->coreGroup); + $groupCreateRes = $this->mockGroupDataAccess->create($mockGroup); + $this->assertNotNull($groupCreateRes); + } + + public function testReadAllGroups() + { + $this->assertNotEmpty($this->mockGroupDataAccess->getAll()); } } diff --git a/test/unit/MockUsersDataAccessTest.php b/test/unit/MockUsersDataAccessTest.php index 3f1c347..71a1e71 100644 --- a/test/unit/MockUsersDataAccessTest.php +++ b/test/unit/MockUsersDataAccessTest.php @@ -4,88 +4,49 @@ namespace Opf\Test\Unit; use PHPUnit\Framework\TestCase; use Illuminate\Database\Capsule\Manager; +use Opf\Adapters\Users\MockUserAdapter; use Opf\DataAccess\Users\MockUserDataAccess; +use Opf\Models\SCIM\Standard\Users\CoreUser; +use Opf\Util\Util; use SQLite3; final class MockUsersDataAccessTest extends TestCase { - /** @var SQLite3 */ - protected $database = null; + /** @var Opf\Models\SCIM\Standard\Users\CoreUser */ + protected $coreUser = null; - /** @var array */ - protected $dbSettings = null; - - /** @var Illuminate\Database\Capsule\Manager */ - protected $capsule = null; - - /** @var Opf\Models\MockUser */ + /** @var Opf\DataAccess\Users\MockUserDataAccess */ protected $mockUserDataAccess = null; + /** @var Opf\Adapters\Users\MockUserAdapter */ + protected $mockUserAdapter = null; + public function setUp(): void { - $this->database = new SQLite3(__DIR__ . '/../resources/test-scim-opf.sqlite'); - - $userDbSql = "CREATE TABLE IF NOT EXISTS users ( - id varchar(160) NOT NULL UNIQUE, - userName varchar(160) NOT NULL, - active BOOLEAN NOT NULL DEFAULT 1, - externalId varchar(160) NULL, - profileUrl varchar(160) NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NULL - )"; - - $this->database->exec($userDbSql); - - $createUserSql = "INSERT INTO users ( - id, - userName, - externalId, - profileUrl - ) VALUES ( - '12345678-9012-3456-7890-12345678', - 'testuser', - 'testuserexternal', - 'https://example.com/testuser' - )"; - - $this->database->exec($createUserSql); - - $this->dbSettings = [ - 'driver' => 'sqlite', - 'database' => __DIR__ . '/../resources/test-scim-opf.sqlite', - 'prefix' => '' - ]; - - $this->capsule = new Manager(); - $this->capsule->addConnection($this->dbSettings); - $this->capsule->setAsGlobal(); - $this->capsule->bootEloquent(); - + Util::setConfigFile(__DIR__ . '/../resources/mock-test-config.php'); + $this->coreUser = new CoreUser(); $this->mockUserDataAccess = new MockUserDataAccess(); + $this->mockUserAdapter = new MockUserAdapter(); } public function tearDown(): void { + $this->coreUser = null; $this->mockUserDataAccess = null; - $this->capsule = null; - $this->dbSettings = null; - $this->database->exec("DROP TABLE users"); - $this->database = null; - - unlink(__DIR__ . '/../resources/test-scim-opf.sqlite'); - } - - public function testReadAllUsers() - { - $this->assertNotEmpty($this->mockUserDataAccess->all()); + $this->mockUserAdapter = null; } public function testCreateUser() { $testUserJson = json_decode(file_get_contents(__DIR__ . '/../resources/testUser.json'), true); - $this->mockUserDataAccess->fromSCIM($testUserJson); - $userCreateRes = $this->mockUserDataAccess->save(); - $this->assertTrue($userCreateRes); + $this->coreUser->fromSCIM($testUserJson); + $mockUser = $this->mockUserAdapter->getMockUser($this->coreUser); + $userCreateRes = $this->mockUserDataAccess->create($mockUser); + $this->assertNotNull($userCreateRes); + } + + public function testReadAllUsers() + { + $this->assertNotEmpty($this->mockUserDataAccess->getAll()); } }