diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/NOTICE b/NOTICE new file mode 100644 index 000000000..38057cd82 --- /dev/null +++ b/NOTICE @@ -0,0 +1,95 @@ +COmanage Registry + +Copyright (C) 2010-2020 +University Corporation for Advanced Internet Development, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this software except in compliance with the License. +You may obtain a copy of the License at: + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +--------------------------------------------------------------------------- + +This material is based upon work supported by the National Science +Foundation under Grant No. OCI-0721896, OCI-0330626, OCI-1032468, +and ACI-1547268. Any opinions, findings and conclusions or recommendations +expressed in this material are those of the author(s) and do not +necessarily reflect the views of the National Science Foundation (NSF). + +--------------------------------------------------------------------------- + +Contributions with original copyright held by, and copyright license granted +to the University Corporation for Advanced Internet Development, Inc. as per +the Contributor License Agreement by, + + GRNET SA + https://grnet.gr + + Modern Language Association + https://www.mla.org + + Spherical Cow Group + https://sphericalcowgroup.com + + SURFnet BV + https://www.surf.nl + +This project uses the following third party utilities, see the appropriate +files and utilities for further information: + + CakePHP (lib/Cake) + MIT License + http://cakephp.org + + ADOdb (app/Vendor/adodb5) + BSD 3-Clause License + http://adodb.org + + Bootstrap (app/webroot/js/bootstrap) + MIT License + https://getbootstrap.com/ + + Duet Date Picker (app/webroot/js/duet-datepicker) + MIT License + https://github.com/duetds/date-picker + + Guzzle (app/AvailablePlugin/GithubProvisioner/Vendor/guzzle) + MIT License + https://github.com/guzzle/guzzle + + jQuery (app/webroot/js/jquery) + MIT License + http://jquery.com + + jsTimezoneDetect (app/webroot/js/jstimezonedetect) + MIT License + https://bitbucket.org/pellepim/jstimezonedetect + + Material Icons (app/webroot/css/fonts/material-icons) + Apache 2.0 + https://fonts.google.com/icons?selected=Material+Icons + + Open Sans (app/webroot/css/fonts/opensans) + Apache 2.0 + https://fonts.google.com/specimen/Open+Sans + + PHP GitHub API 2.0 (app/AvailablePlugin/GithubProvisioner/Vendor/guzzle/guzzle) + MIT License + https://github.com/KnpLabs/php-github-api + + Shibboleth Embedded Discovery Service (app/webroot/js/eds) + Apache 2.0 + https://shibboleth.net/products/embedded-discovery-service.html + + vuejs + MIT License + https://vuejs.org/ + +--------------------------------------------------------------------------- diff --git a/app/.editorconfig b/app/.editorconfig new file mode 100644 index 000000000..2317d521b --- /dev/null +++ b/app/.editorconfig @@ -0,0 +1,23 @@ +; This file is for unifying the coding style for different editors and IDEs. +; More information at https://editorconfig.org + +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +insert_final_newline = false +trim_trailing_whitespace = false + +[*.bat] +end_of_line = crlf + +[*.yml] +indent_size = 2 + +[*.twig] +insert_final_newline = false + +[Makefile] +indent_style = tab diff --git a/app/.gitattributes b/app/.gitattributes new file mode 100644 index 000000000..9963dddc5 --- /dev/null +++ b/app/.gitattributes @@ -0,0 +1,34 @@ +# Define the line ending behavior of the different file extensions +# Set default behavior, in case users don't have core.autocrlf set. +* text text=auto eol=lf + +# Declare files that will always have CRLF line endings on checkout. +*.bat eol=crlf + +# Declare files that will always have LF line endings on checkout. +*.pem eol=lf + +# Denote all files that are truly binary and should not be modified. +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.webp binary +*.ico binary +*.mo binary +*.pdf binary +*.xls binary +*.xlsx binary +*.phar binary +*.woff binary +*.woff2 binary +*.ttf binary +*.otf binary +*.eot binary +*.gz binary +*.bz2 binary +*.7z binary +*.zip binary +*.webm binary +*.mp4 binary +*.ogv binary diff --git a/app/.github/ISSUE_TEMPLATE.md b/app/.github/ISSUE_TEMPLATE.md new file mode 100644 index 000000000..660b5399d --- /dev/null +++ b/app/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,23 @@ +This is a (multiple allowed): + +* [x] bug +* [ ] enhancement +* [ ] feature-discussion (RFC) + +* CakePHP Application Skeleton Version: EXACT RELEASE VERSION OR COMMIT HASH, HERE. +* Platform and Target: YOUR WEB-SERVER, DATABASE AND OTHER RELEVANT INFO AND HOW THE REQUEST IS BEING MADE, HERE. + +### What you did +EXPLAIN WHAT YOU DID, PREFERABLY WITH CODE EXAMPLES, HERE. + +### What happened +EXPLAIN WHAT IS ACTUALLY HAPPENING, HERE. + +### What you expected to happen +EXPLAIN WHAT IS TO BE EXPECTED, HERE. + +P.S. Remember, an issue is not the place to ask questions. You can use [Stack Overflow](https://stackoverflow.com/questions/tagged/cakephp) +for that or join the #cakephp channel on irc.freenode.net, where we will be more +than happy to help answer your questions. + +Before you open an issue, please check if a similar issue already exists or has been closed before. \ No newline at end of file diff --git a/app/.github/PULL_REQUEST_TEMPLATE.md b/app/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..aae4cbd16 --- /dev/null +++ b/app/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,14 @@ + diff --git a/app/.github/workflows/ci.yml b/app/.github/workflows/ci.yml new file mode 100644 index 000000000..5f54d2705 --- /dev/null +++ b/app/.github/workflows/ci.yml @@ -0,0 +1,82 @@ +name: CI + +on: + push: + branches: + - master + - '4.next' + pull_request: + branches: + - '*' + +jobs: + testsuite: + runs-on: ubuntu-18.04 + strategy: + fail-fast: false + matrix: + php-version: ['7.2', '7.4', '8.0'] + name: PHP ${{ matrix.php-version }} + + steps: + - uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: mbstring, intl, pdo_sqlite, pdo_mysql + coverage: none + + - name: Composer install + run: | + if [[ ${{ matrix.php-version }} == '8.0' ]]; then + composer install --ignore-platform-reqs + else + composer install + fi + composer run-script post-install-cmd --no-interaction + + - name: Run PHPUnit + run: | + vendor/bin/phpunit + + coding-standard: + name: Coding Standard + runs-on: ubuntu-18.04 + + steps: + - uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '7.2' + extensions: mbstring, intl + coverage: none + + - name: Composer install + run: composer install + + - name: Run PHP CodeSniffer + run: composer cs-check + + static-analysis: + name: Static Analysis + runs-on: ubuntu-18.04 + + steps: + - uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '7.2' + extensions: mbstring, intl + coverage: none + + - name: Composer install + run: composer require --dev phpstan/phpstan:^0.12 + + - name: Run phpstan + run: vendor/bin/phpstan.phar analyse diff --git a/app/.github/workflows/stale.yml b/app/.github/workflows/stale.yml new file mode 100644 index 000000000..9192ec78f --- /dev/null +++ b/app/.github/workflows/stale.yml @@ -0,0 +1,23 @@ +name: Mark stale issues and pull requests + +on: + schedule: + - cron: "0 0 * * *" + +jobs: + stale: + + runs-on: ubuntu-latest + + steps: + - uses: actions/stale@v1 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + stale-issue-message: 'This issue is stale because it has been open for 120 days with no activity. Remove the `stale` label or comment or this will be closed in 15 days' + stale-pr-message: 'This pull request is stale because it has been open 30 days with no activity. Remove the `stale` label or comment on this issue, or it will be closed in 15 days' + stale-issue-label: 'stale' + stale-pr-label: 'stale' + days-before-stale: 120 + days-before-close: 15 + exempt-issue-label: 'pinned' + exempt-pr-label: 'pinned' diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 000000000..6dfef167d --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,42 @@ +# CakePHP specific files # +########################## +/config/app_local.php +/config/.env +/logs/* +/tmp/* + +# OS generated files # +###################### +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +Icon? +ehthumbs.db +Thumbs.db +.directory + +# Tool specific files # +####################### +# PHPUnit +.phpunit.result.cache +# vim +*~ +*.swp +*.swo +# sublime text & textmate +*.sublime-* +*.stTheme.cache +*.tmlanguage.cache +*.tmPreferences.cache +# Eclipse +.settings/* +# JetBrains, aka PHPStorm, IntelliJ IDEA +.idea/* +# NetBeans +nbproject/* +# Visual Studio Code +.vscode +# Sass preprocessor +.sass-cache/ diff --git a/app/.htaccess b/app/.htaccess new file mode 100644 index 000000000..54b08e82e --- /dev/null +++ b/app/.htaccess @@ -0,0 +1,12 @@ +# Uncomment the following to prevent the httpoxy vulnerability +# See: https://httpoxy.org/ +# +# RequestHeader unset Proxy +# + + + RewriteEngine on + RewriteRule ^(\.well-known/.*)$ $1 [L] + RewriteRule ^$ webroot/ [L] + RewriteRule (.*) webroot/$1 [L] + diff --git a/app/availableplugins/ApiConnector/README.md b/app/availableplugins/ApiConnector/README.md new file mode 100644 index 000000000..5c68d2808 --- /dev/null +++ b/app/availableplugins/ApiConnector/README.md @@ -0,0 +1,11 @@ +# ApiSource plugin for CakePHP + +## Installation + +You can install this plugin into your CakePHP application using [composer](https://getcomposer.org). + +The recommended way to install composer packages is: + +``` +composer require your-name-here/api-source +``` diff --git a/app/availableplugins/ApiConnector/composer.json b/app/availableplugins/ApiConnector/composer.json new file mode 100644 index 000000000..98e70b8c4 --- /dev/null +++ b/app/availableplugins/ApiConnector/composer.json @@ -0,0 +1,24 @@ +{ + "name": "your-name-here/api-connector", + "description": "ApiConnector plugin for CakePHP", + "type": "cakephp-plugin", + "license": "MIT", + "require": { + "php": ">=7.2", + "cakephp/cakephp": "4.4.*" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.3" + }, + "autoload": { + "psr-4": { + "ApiConnector\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "ApiConnector\\Test\\": "tests/", + "Cake\\Test\\": "vendor/cakephp/cakephp/tests/" + } + } +} diff --git a/app/availableplugins/ApiConnector/config/routes.php b/app/availableplugins/ApiConnector/config/routes.php new file mode 100644 index 000000000..2f470cb73 --- /dev/null +++ b/app/availableplugins/ApiConnector/config/routes.php @@ -0,0 +1,77 @@ +scope('/api/apisource', function (RouteBuilder $builder) { + // Register scoped middleware for in scopes. +// Do not enable CSRF for the REST API, it will break standard (non-AJAX) clients +// $builder->registerMiddleware('csrf', new CsrfProtectionMiddleware(['httponly' => true])); + // BodyParserMiddleware will automatically parse JSON bodies, but we only + // want that for API transactions, so we only apply it to the /api scope. + $builder->registerMiddleware('bodyparser', new BodyParserMiddleware()); + /* + * Apply a middleware to the current route scope. + * Requires middleware to be registered through `Application::routes()` with `registerMiddleware()` + */ +// Do not enable CSRF for the REST API, it will break standard (non-AJAX) clients +// $builder->applyMiddleware('csrf'); + $builder->setExtensions(['json']); + $builder->applyMiddleware('bodyparser'); + + $builder->delete( + '/{id}/v2/sorPeople/{sorlabel}/{sorid}', + ['plugin' => 'ApiConnector', 'controller' => 'ApiV2', 'action' => 'delete'] + ) + ->setPass(['id', 'sorlabel', 'sorid']) + ->setPatterns(['id' => '[0-9]+']); + + $builder->get( + '/{id}/v2/sorPeople/{sorlabel}/{sorid}', + ['plugin' => 'ApiConnector', 'controller' => 'ApiV2', 'action' => 'get'] + ) + ->setPass(['id', 'sorlabel', 'sorid']) + ->setPatterns(['id' => '[0-9]+']); + + $builder->put( + '/{id}/v2/sorPeople/{sorlabel}/{sorid}', + ['plugin' => 'ApiConnector', 'controller' => 'ApiV2', 'action' => 'upsert'] + ) + ->setPass(['id', 'sorlabel', 'sorid']) + ->setPatterns(['id' => '[0-9]+']); +}); \ No newline at end of file diff --git a/app/availableplugins/ApiConnector/phpunit.xml.dist b/app/availableplugins/ApiConnector/phpunit.xml.dist new file mode 100644 index 000000000..487e87af0 --- /dev/null +++ b/app/availableplugins/ApiConnector/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + + + + + tests/TestCase/ + + + + + + + + + + + src/ + + + diff --git a/app/availableplugins/ApiConnector/resources/locales/en_US/api_connector.po b/app/availableplugins/ApiConnector/resources/locales/en_US/api_connector.po new file mode 100644 index 000000000..eea0637bd --- /dev/null +++ b/app/availableplugins/ApiConnector/resources/locales/en_US/api_connector.po @@ -0,0 +1,32 @@ +# COmanage Registry Localizations (api_connector domain) +# +# Portions licensed to the University Corporation for Advanced Internet +# Development, Inc. ("UCAID") under one or more contributor license agreements. +# See the NOTICE file distributed with this work for additional information +# regarding copyright ownership. +# +# UCAID licenses this file to you under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with the +# License. You may obtain a copy of the License at: +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# @link https://www.internet2.edu/comanage COmanage Project +# @package registry +# @since COmanage Registry v5.0.0 +# @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + +msgid "controller.ApiSources" +#msgstr "{0,plural,=1{API Source} other{API Sources}}" + +msgid "field.ApiSources.push_mode" +msgstr "Push Mode" + +msgid "information.endpoint.push" +msgstr "The API endpoint for using this plugin in Push Mode is {0}" \ No newline at end of file diff --git a/app/availableplugins/ApiConnector/resources/schemas/apisource-message.json b/app/availableplugins/ApiConnector/resources/schemas/apisource-message.json new file mode 100644 index 000000000..0089ef082 --- /dev/null +++ b/app/availableplugins/ApiConnector/resources/schemas/apisource-message.json @@ -0,0 +1,312 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.internet2.edu/COmanage/registry/tree/5.0.0/app/resources/schema/apisource-message.json", + "title": "COmanage Registry API Source Message Format", + "description": "COmanage Registry API Source Message Format", + + "$defs": { + "addresses": { + "type": "array", + "items": { + "type": "object", + "properties": { + "country": { + "description": "The country for this address", + "type": "string" + }, + "language": { + "description": "The language encoding for this address", + "type": "string" + }, + "locality": { + "description": "The city or locality for this address", + "type": "string" + }, + "postalCode": { + "description": "The postal code for this address", + "type": "string" + }, + "region": { + "description": "The state, province, or region for this address", + "type": "string" + }, + "room": { + "description": "The room number for this address", + "type": "string" + }, + "streetAddress": { + "description": "The street for this address", + "type": "string" + }, + "type": { + "description": "The type of address", + "type": "string" + } + } + } + }, + "adhoc": { + "type": "array", + "items": { + "type": "object", + "properties": { + "tag": { + "description": "Ad Hoc attribute tag/label", + "type": "string" + }, + "value": { + "description": "Ad Hoc attribute value", + "type": "string" + } + } + }, + "required": [ "tag" ] + }, + "dateOfBirth": { + "description": "Date of Birth for the person associated with this identity", + "type": "string", + "format": "date" + }, + "emailAddresses": { + "type": "array", + "items": { + "type": "object", + "properties": { + "mail": { + "description": "An email address for this identity", + "type": "string", + "format": "email" + }, + "type": { + "description": "The type of email address", + "type": "string" + }, + "verified": { + "description": "Whether this email address has been verified", + "type": "boolean" + } + } + }, + "required": [ "mail" ] + }, + "identifiers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "identifier": { + "description": "A identifier for the person", + "type": "string" + }, + "type": { + "description": "The type of identifier", + "type": "string" + } + }, + "required": [ "identifier" ] + } + }, + "names": { + "type": "array", + "items": { + "type": "object", + "properties": { + "family": { + "description": "The person's family or surname", + "type": "string" + }, + "given": { + "description": "The person's given or first name", + "type": "string" + }, + "language": { + "description": "The language encoding for this name", + "type": "string" + }, + "middle": { + "description": "The person's middle name", + "type": "string" + }, + "prefix": { + "description": "The honorific or prefix for the person's name", + "type": "string" + }, + "suffix": { + "description": "The suffix for this person's name", + "type": "string" + }, + "type": { + "description": "The type of name", + "type": "string" + } + }, + "required": [ "given" ] + } + }, + "telephoneNumbers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "description": "A telephone number for the person", + "type": "string", + "format": "itu-e164" + }, + "type": { + "description": "The type of telephone number", + "type": "string" + } + }, + "required": [ "number" ] + } + }, + "urls": { + "type": "array", + "items": { + "type": "object", + "properties": { + "url": { + "description": "A URL for the person", + "type": "string", + "format": "uri" + }, + "type": { + "description": "The type of URL", + "type": "string" + } + }, + "required": [ "url" ] + } + }, + "meta": { + "type": "object", + "properties": { + "id": { + "description": "COmanage identifier for this object", + "type": "string" + } + }, + "required": [ "id" ] + } + }, + + "type": "object", + "properties": { + "meta": { + "type": "object", + "properties": { + "version": { + "const": "1.0.0" + }, + "objectType": { + "const": "externalIdentity" + } + }, + "required": [ "version", "objectType" ] + }, + "returnUrl": { + "description": "Petition Specific Redirect Target", + "type": "string", + "format": "uri" + }, + "sorAttributes": { + "type": "object", + "properties": { + "meta": { + "$ref": "#/$defs/meta" + }, + "addresses": { + "$ref": "#/$defs/addresses" + }, + "adhoc": { + "$ref": "#/$defs/adhoc" + }, + "dateOfBirth": { + "$ref": "#/$defs/dateOfBirth" + }, + "emailAddresses": { + "$ref": "#/$defs/emailAddresses" + }, + "identifiers": { + "$ref": "#/$defs/identifiers" + }, + "names": { + "$ref": "#/$defs/names" + }, + "telephoneNumbers": { + "$ref": "#/$defs/telephoneNumbers" + }, + "urls": { + "$ref": "#/$defs/urls" + }, + "roles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "addresses": { + "$ref": "#/$defs/addresses" + }, + "adhoc": { + "$ref": "#/$defs/adhoc" + }, + "affiliation": { + "description": "The affiliation for this role", + "type": "string" + }, + "department": { + "description": "The department for this role", + "type": "string" + }, + "managerIdentifier": { + "description": "An identifier for the Manager for this role", + "type": "string" + }, + "organization": { + "description": "The organization for this role", + "type": "string" + }, + "rank": { + "description": "The rank or order for this role, among all roles (lower number indicate higher priorities)", + "type": "integer" + }, + "roleIdentifier": { + "description": "A unique identifier for this role", + "type": "string" + }, + "sponsorIdentifier": { + "description": "An identifier for the Sponsor for this role", + "type": "string" + }, + "status": { + "description": "The status for this role", + "type": "string", + "enum": [ "A", "D", "D2", "GP", "S" ] + }, + "telephoneNumbers": { + "$ref": "#/$defs/telephoneNumbers" + }, + "title": { + "description": "The title for this role", + "type": "string" + }, + "validFrom": { + "description": "The time from which this role is valid", + "type": "string", + "format": "date-time" + }, + "validThrough": { + "description": "The time through which this role is valid", + "type": "string", + "format": "date-time" + } + } + }, + "required": [ "affiliation", "roleIdentifier", "status" ] + } + } + } + }, + "required": [ "sorAttributes" ] +} diff --git a/app/availableplugins/ApiConnector/src/ApiConnectorPlugin.php b/app/availableplugins/ApiConnector/src/ApiConnectorPlugin.php new file mode 100644 index 000000000..3f3ec29b8 --- /dev/null +++ b/app/availableplugins/ApiConnector/src/ApiConnectorPlugin.php @@ -0,0 +1,94 @@ +plugin( + 'ApiConnector', + ['path' => '/api-connector'], + function (RouteBuilder $builder) { + // Add custom routes here + + $builder->fallbacks(); + } + ); + + parent::routes($routes); + } + + /** + * Add middleware for the plugin. + * + * @param \Cake\Http\MiddlewareQueue $middlewareQueue The middleware queue to update. + * @return \Cake\Http\MiddlewareQueue + */ + public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue + { + // Add your middlewares here + + return $middlewareQueue; + } + + /** + * Add commands for the plugin. + * + * @param \Cake\Console\CommandCollection $commands The command collection to update. + * @return \Cake\Console\CommandCollection + */ + public function console(CommandCollection $commands): CommandCollection + { + // Add your commands here + + $commands = parent::console($commands); + + return $commands; + } + + /** + * Register application container services. + * + * @param \Cake\Core\ContainerInterface $container The Container to update. + * @return void + * @link https://book.cakephp.org/4/en/development/dependency-injection.html#dependency-injection + */ + public function services(ContainerInterface $container): void + { + // Add your services here + } +} diff --git a/app/availableplugins/ApiConnector/src/Controller/ApiSourcesController.php b/app/availableplugins/ApiConnector/src/Controller/ApiSourcesController.php new file mode 100644 index 000000000..f003372ed --- /dev/null +++ b/app/availableplugins/ApiConnector/src/Controller/ApiSourcesController.php @@ -0,0 +1,66 @@ + [ + 'ApiSources.id' => 'asc' + ] + ]; + + /** + * Callback run prior to the request render. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Cake Event + * @return \Cake\Http\Response HTTP Response + */ + + public function beforeRender(\Cake\Event\EventInterface $event) { + $apiSource = $this->ApiSources->get( + $this->request->getParam('pass.0'), + ['contain' => 'ExternalIdentitySources'] + ); + + $this->set( + 'vv_push_endpoint', + Router::url( + url: '/api/apisource/' . $apiSource->id . '/v2/sorPeople/' . $apiSource->external_identity_source->sor_label, + full: true + ) + ); + + return parent::beforeRender($event); + } +} diff --git a/app/availableplugins/ApiConnector/src/Controller/ApiV2Controller.php b/app/availableplugins/ApiConnector/src/Controller/ApiV2Controller.php new file mode 100644 index 000000000..d865f96c5 --- /dev/null +++ b/app/availableplugins/ApiConnector/src/Controller/ApiV2Controller.php @@ -0,0 +1,209 @@ +request->getParam('id'); + + $ApiSource = TableRegistry::getTableLocator()->get('ApiConnector.ApiSources'); + + $cfg = $ApiSource->get($apiSourceId, ['contain' => 'ExternalIdentitySources']); + + return $cfg->external_identity_source->co_id ?? null; + } + + /** + * Calculate authorization for the current request. + * + * @since COmanage Registry v5.0.0 + * @return bool True if the current request is permitted, false otherwise + */ + + public function calculatePermission(): bool { + $request = $this->getRequest(); + $action = $request->getParam('action'); + $authUser = $this->RegistryAuth->getAuthenticatedUser(); + + $authorized = false; + + // Our authorization is pretty straightforward, the configured API User + // is permitted to perform all actions. + + // This should be set or the route won't match + $apiSourceId = $this->request->getParam('id'); + + $ApiSource = TableRegistry::getTableLocator()->get('ApiConnector.ApiSources'); + + $cfg = $ApiSource->get($apiSourceId, ['contain' => 'ApiUsers']); + + if(!empty($cfg->api_user->username) + && !empty($authUser) + && $authUser == $cfg->api_user->username) { + $authorized = true; + } + + return $authorized; + } + + /** + * Handle an SOR Person Role Deleted request. + * + * @since COmanage Registry v5.0.0 + * @param string $id ApiSource ID + * @param string $sorlabel System of Record Label from request URL + * @param string $sorid System of Record ID from request URL + */ + + public function delete(string $id, string $sorlabel, string $sorid) { + $ApiSource = TableRegistry::getTableLocator()->get('ApiConnector.ApiSources'); + + $resultCode = 500; + $results = []; + + try { + $ApiSource->remove((int)$id, $sorlabel, $sorid); + + $resultCode = 200; + } + catch(\Cake\Datasource\Exception\RecordNotFoundException $e) { + $resultCode = 404; + $results['error'] = $e->getMessage(); + } + catch(\Exception $e) { + $this->llog('debug', $e->getMessage()); + $results['error'] = $e->getMessage(); + + $resultCode = 500; + } + + $this->response = $this->response->withStatus($resultCode); + $this->set('vv_results', $results); + } + + /** + * Handle a Get SOR Person Role request. + * + * @since COmanage Registry v5.0.0 + * @param string $id ApiSource ID + * @param string $sorlabel System of Record Label from request URL + * @param string $sorid System of Record ID from request URL + */ + + public function get(string $id, string $sorlabel, string $sorid) { + // We basically just pull the currently cached source record and return it. + + $ApiSourceRecord = TableRegistry::getTableLocator()->get('ApiConnector.ApiSourceRecords'); + + $results = []; + $resultCode = 500; + + try { + $record = $ApiSourceRecord->find() + ->where(['api_source_id' => $id, 'source_key' => $sorid]) + ->firstOrFail(); + + $resultCode = 200; + $results = json_decode($record->source_record); + } + catch(\Cake\Datasource\Exception\RecordNotFoundException $e) { + $resultCode = 404; + $results['error'] = $e->getMessage(); + } + catch(\Exception $e) { + $results['error'] = $e->getMessage(); + } + + $this->response = $this->response->withStatus($resultCode); + $this->set('vv_results', $results); + } + + /** + * Handle an SOR Person Role Added or Updated request. + * + * @since COmanage Registry v5.0.0 + * @param string $id ApiSource ID + * @param string $sorlabel System of Record Label from request URL + * @param string $sorid System of Record ID from request URL + */ + + public function upsert(string $id, string $sorlabel, string $sorid) { + // Pass the requested data to the Backend and return a response. +// XXX todo: add support for returnUrl back in + + $ApiSource = TableRegistry::getTableLocator()->get('ApiConnector.ApiSources'); + + $resultCode = 400; + $results = []; + + try { + $result = $ApiSource->upsert((int)$id, $sorlabel, $sorid, $this->request->getData()); + + if(isset($result['new']) && $result['new']) { + $resultCode = 201; + } else { + $resultCode = 200; + } + } + catch(\Exception $e) { + $this->llog('debug', $e->getMessage()); + $results['error'] = $e->getMessage(); + + $resultCode = 400; + } + + $this->response = $this->response->withStatus($resultCode); + $this->set('vv_results', $results); + } + + /** + * Indicate whether this Controller will handle some or all authnz. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Cake event, ie: from beforeFilter + * @return string "no", "open", "authz", or "yes" + */ + + public function willHandleAuth(\Cake\Event\EventInterface $event): string { + // We always take over authz + return 'authz'; + } +} diff --git a/app/availableplugins/ApiConnector/src/Controller/AppController.php b/app/availableplugins/ApiConnector/src/Controller/AppController.php new file mode 100644 index 000000000..c11c4469c --- /dev/null +++ b/app/availableplugins/ApiConnector/src/Controller/AppController.php @@ -0,0 +1,10 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/availableplugins/ApiConnector/src/Model/Entity/ApiSourceRecord.php b/app/availableplugins/ApiConnector/src/Model/Entity/ApiSourceRecord.php new file mode 100644 index 000000000..763b797cc --- /dev/null +++ b/app/availableplugins/ApiConnector/src/Model/Entity/ApiSourceRecord.php @@ -0,0 +1,49 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/availableplugins/ApiConnector/src/Model/Table/ApiSourceRecordsTable.php b/app/availableplugins/ApiConnector/src/Model/Table/ApiSourceRecordsTable.php new file mode 100644 index 000000000..9013bb2ce --- /dev/null +++ b/app/availableplugins/ApiConnector/src/Model/Table/ApiSourceRecordsTable.php @@ -0,0 +1,111 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + // Timestamp behavior handles created/modified updates + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Artifact); + + // Define associations + $this->belongsTo('ApiConnector.ApiSources'); + //$this->belongsTo('ApiUsers'); + + $this->setDisplayField('source_key'); + + $this->setPrimaryLink(['ApiConnector.api_source_id']); + $this->setRequiresCO(true); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, + 'edit' => false, + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + * @throws InvalidArgumentException + * @throws RecordNotFoundException + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('api_source_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('api_source_id'); + + $this->registerStringValidation($validator, $schema, 'source_key', true); + + // We don't require any particular validation of source_record we because + // we don't want to accidentally throw errors + $validator->allowEmptyString('source_record'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/availableplugins/ApiConnector/src/Model/Table/ApiSourcesTable.php b/app/availableplugins/ApiConnector/src/Model/Table/ApiSourcesTable.php new file mode 100644 index 000000000..fd4b255e4 --- /dev/null +++ b/app/availableplugins/ApiConnector/src/Model/Table/ApiSourcesTable.php @@ -0,0 +1,473 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('ExternalIdentitySources'); + $this->belongsTo('ApiUsers'); + + $this->hasMany('ApiConnector.ApiSourceRecords') + ->setDependent(true) + ->setCascadeCallbacks(true); + + $this->setDisplayField('id'); + + $this->setPrimaryLink(['external_identity_source_id']); + $this->setRequiresCO(true); + + $this->setAutoViewVars([ + 'apiUsers' => [ + 'type' => 'select', + 'model' => 'ApiUsers', + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, // Delete the pluggable object instead + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Map API field names to Registry data model names. + * + * @since COmanage Registry v5.0.0 + * @param string $model Model name (in API format) + * @param array $attributes Model attributes + * @return array Mapped model attributes + */ + + protected function mapApiToRegistry(string $model, array $attributes): array { + // All API attributes must be defined in the fieldMap, even if both names are the same + $fieldMap = [ + // sorAttributes = top level External Identity + 'sorAttributes' => [ + // Dates should correctly marshal to a DateTime object without us doing anything + 'dateOfBirth' => 'date_of_birth' + ], + // roles = External Identity Role + 'roles' => [ + 'roleIdentifier' => 'role_key', + 'affiliation' => 'affiliation', + 'department' => 'department', + 'managerIdentifier' => 'manager_identifier', + 'rank' => 'ordr', + 'organization' => 'organization', + 'sponsorIdentifier' => 'sponsor_identifier', + 'status' => 'status', + 'title' => 'title', + 'validFrom' => 'valid_from', + 'validThrough' => 'valid_through' + ], + // MVEAs + 'addresses' => [ + 'country' => 'country', + 'language' => 'language', + 'locality' => 'locality', + 'postalCode' => 'postal_code', + 'region' => 'state', + 'room' => 'room', + 'streetAddress' => 'street', + 'type' => 'type' + ], + 'adhoc' => [ + 'tag' => 'tag', + 'value' => 'value' + ], + 'emailAddresses' => [ + 'address' => 'mail', + 'type' => 'type', + 'verified' => 'verified' + ], + 'identifiers' => [ + 'identifier' => 'identifier', + 'type' => 'type' + ], + 'names' => [ + 'family' => 'family', + 'given' => 'given', + 'language' => 'language', + 'middle' => 'middle', + 'prefix' => 'honorific', + 'suffix' => 'suffix', + 'type' => 'type' + ], + 'telephoneNumbers' => [ + 'number' => 'number', + 'type' => 'type' + ], + 'urls' => [ + 'type' => 'type', + 'url' => 'url' + ] + ]; + + $ret = []; + + foreach($attributes as $attr => $value) { + if(isset($fieldMap[$model][$attr])) { + $ret[ $fieldMap[$model][$attr] ] = $value; + } + } + + return $ret; + } + + /** + * Remove a record from the External Identity Source. + * + * @since COmanage Registry v5.0.0 + * @param ExternalIdentitySource $source EIS Entity with instantiated plugin configuration + * @param string $sorId API System of Record ID + * @return bool True on success + * @throws RecordNotFoundException + */ + + public function remove( + \App\Model\Entity\ExternalIdentitySource $source, + string $sorId + ): array { + // We call this remove() so as not to interfere with the default table::delete(). + + // Remove the ApiSourceRecord for this $source_key from the cache + + $apiSourceRecord = $this->ApiSourceRecords->find() + ->where([ + 'api_source_id' => $source->api_source->id, + 'source_key' => $sorId + ]) + ->firstOrFail(); + + $this->ApiSourceRecords->delete($apiSourceRecord); + + // Run sync +// XXX do we need some sort of return value to pass back in the API response? + $this->ExternalIdentitySources->sync($source->id, $sorId); + + return true; + } + + + /** + * Convert a record from the ApiSource message to a record suitable for + * construction of an Entity. + * + * @since COmanage Registry v5.0.0 + * @param array $result ApiSource message + * @return array Entity record (in array format) + */ + + protected function resultToEntityData(array $result): array { + // Convert the inbound message format to the Entity record array format. + // They're actually very similar, but we need to do some work, in particular + // around mapping field names. + + $eidata = []; + + // Start with single-value EI attributes + + $eidata = $this->mapApiToRegistry('sorAttributes', $result['sorAttributes']); + + // EI MVEAs, which can generally just be copied in place + + foreach([ + 'addresses' => 'addresses', + 'adhoc' => 'ad_hoc_attributes', + 'emailAddresses' => 'email_addresses', + 'identifiers' => 'identifiers', + 'names' => 'names', + 'telephoneNumbers' => 'telephone_numbers', + 'urls' => 'urls' + ] as $apiModel => $registryModel) { + if(!empty($result['sorAttributes'][$apiModel])) { + foreach($result['sorAttributes'][$apiModel] as $m) { + $eidata[$registryModel][] = $this->mapApiToRegistry($apiModel, $m); + } + } + } + + // EI Roles + + if(!empty($result['sorAttributes']['roles'])) { + foreach($result['sorAttributes']['roles'] as $roleData) { + if(!empty($roleData['roleIdentifier'])) { + // The top level role data + $eirdata = $this->mapApiToRegistry('roles', $roleData); + + // EIR MVEAs + + foreach([ + 'addresses' => 'addresses', + 'adhoc' => 'ad_hoc_attributes', + 'telephoneNumbers' => 'telephone_numbers', + 'urls' => 'urls' + ] as $apiModel => $registryModel) { + if(!empty($roleData[$apiModel])) { + foreach($roleData[$apiModel] as $m) { + $eirdata[$registryModel][] = $this->mapApiToRegistry($apiModel, $m); + } + } + } + + $eidata['external_identity_roles'][] = $eirdata; + } + } + } + + return $eidata; + } + + /** + * Retrieve a record from the External Identity Source. + * + * @since COmanage Registry v5.0.0 + * @param ExternalIdentitySource $source EIS Entity with instantiated plugin configuration + * @param string $source_key Backend source key for requested record + * @return array Array of source_key, source_record, and entity_data + * @throws RecordNotFoundException + */ + + public function retrieve( + \App\Model\Entity\ExternalIdentitySource $source, + string $source_key + ): array { + $ret = [ + 'source_key' => $source_key + ]; + + // Pull the ApiSourceRecord for this $source_key from the cache + + $apiSourceRecord = $this->ApiSourceRecords->find() + ->where([ + 'api_source_id' => $source->api_source->id, + 'source_key' => $source_key + ]) + ->firstOrFail(); + + $ret['source_record'] = $apiSourceRecord->source_record; + $ret['entity_data'] = $this->resultToEntityData( + json_decode(json: $apiSourceRecord->source_record, associative: true) + ); + + return $ret; + } + + /** + * Search the External Identity Source. + * + * @since COmanage Registry v5.0.0 + * @param ExternalIdentitySource $source EIS Entity with instantiated plugin configuration + * @param array $searchAttrs Array of search attributes and values, as configured by searchAttributes() + * @return array Array of matching records + * @throws InvalidArgumentException + */ + + public function search( + \App\Model\Entity\ExternalIdentitySource $source, + array $searchAttrs + ): array { + $ret = []; + + // We search the cache of existing records (push), but not (currently) a + // remote URL (pull). For now we only search on SORID, which matches v4 behavior. + + $records = $this->ApiSourceRecords->find() + ->where([ + 'api_source_id' => $source->api_source->id, + 'source_key' => $searchAttrs['q'] + ]) + ->all(); + + if(!empty($records)) { + foreach($records as $rec) { + $ret[ $rec->source_key ] = $this->resultToEntityData(json_decode($rec->source_record, true)); + } + } + + return $ret; + } + + /** + * Obtain the set of searchable attributes for this backend. + * + * @since COmanage Registry v5.0.0 + * @return array Array of searchable attributes and localized descriptions + */ + + public function searchableAttributes(): array { + // In v4 we aonly accepted SORID. For now, that's all we implement in search(), + // but we could enhance this to search the text of source_record as well. + + return [ + 'q' => __d('field', 'search.placeholder') + ]; + } + + /** + * Insert or update an ApiSource record and associated External Identity. + * + * @since COmanage Registry v5.0.0 + * @param int $id ApiSource ID + * @param string $sorLabel System of Record Label + * @param string $sorId System of Record ID + * @param array $attributes Attributes from message body + * @return array bool 'new': true if a new External Identity was created + * @throws InvalidArgumentException + */ + + public function upsert( + int $id, + string $sorLabel, + string $sorId, + array $attributes + ): array { + $ret = []; + + // Pull our configuration + $apiSource = $this->get($id, ['contain' => ['ExternalIdentitySources']]); + + // Strictly speaking we don't need $sorid since we know which configuration + // to use from the ApiSource ID, and $sorlabel might not be unique across COs + // in a multi-tenant environment. Eventually we could support multiple + // Systems of Record within the same ApiSource configuration, but for now + // we just make sure $sorLabel matches the configuration and throw an error + // if it doesn't. + + if(empty($apiSource->external_identity_source->sor_label) + || $apiSource->external_identity_source->sor_label != $sorLabel) { + throw new \InvalidArgumentException("Requested SOR Label $sorLabel does not match configuration"); + } + + // Create or Update the API Source Record + + // For consistency, we'll always make the source_record pretty (which + // should also make it slightly easier for an admin to look at it. + $sourceRecord = json_encode($attributes, JSON_PRETTY_PRINT); + + // Note we transition from "SOR ID" (TAP API terminology) to "Source Key" + // (Registry terminology) here + $apiSourceRecord = $this->ApiSourceRecords->find() + ->where([ + 'api_source_id' => $id, + 'source_key' => $sorId + ]) + ->first(); + + if(!empty($apiSourceRecord)) { + // Update + + $apiSourceRecord->source_record = $sourceRecord; + } else { + // Insert + + $apiSourceRecord = $this->ApiSourceRecords->newEntity([ + 'api_source_id' => $id, + 'source_key' => $sorId, + 'source_record' => $sourceRecord + ]); + + $ret['new'] = true; + } + + $this->ApiSourceRecords->saveOrFail($apiSourceRecord); + + // Note update of ApiSourceRecord doesn't necessarily imply update of + // an associated External Identity - it could be an insert. Regardless, + // ExternalIdentitySources::sync (really Pipelines::execute) will deal with it. + + $this->ExternalIdentitySources->sync($apiSource->external_identity_source_id, $sorId); + + return $ret; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + * @throws InvalidArgumentException + * @throws RecordNotFoundException + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('external_source_identity_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('external_source_identity_id'); + + $validator->add('api_user_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('api_user_id'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/availableplugins/ApiConnector/src/config/plugin.json b/app/availableplugins/ApiConnector/src/config/plugin.json new file mode 100644 index 000000000..be96e7f42 --- /dev/null +++ b/app/availableplugins/ApiConnector/src/config/plugin.json @@ -0,0 +1,34 @@ +{ + "types": { + "source": [ + "ApiSources" + ] + }, + "schema": { + "tables": { + "api_sources": { + "columns": { + "id": {}, + "external_identity_source_id": {}, + "api_user_id": {} + }, + "indexes": { + "api_sources_i1": { "columns": [ "external_identity_source_id" ] } + } + }, + "api_source_records": { + "columns": { + "id": {}, + "api_source_id": { "type": "integer", "foreignkey": { "table": "api_sources", "column": "id" } }, + "source_key": { "type": "string", "size": 1024 }, + "source_record": { "type": "text" } + }, + "indexes": { + "api_source_records_i1": { "columns": [ "api_source_id" ] }, + "api_source_records_i2": { "columns": [ "api_source_id", "source_key" ] } + }, + "changelog": false + } + } + } +} \ No newline at end of file diff --git a/app/availableplugins/ApiConnector/templates/ApiSources/fields-nav.inc b/app/availableplugins/ApiConnector/templates/ApiSources/fields-nav.inc new file mode 100644 index 000000000..b4d5a2809 --- /dev/null +++ b/app/availableplugins/ApiConnector/templates/ApiSources/fields-nav.inc @@ -0,0 +1,31 @@ + 'plugin', + 'active' => 'plugin' + ]; \ No newline at end of file diff --git a/app/availableplugins/ApiConnector/templates/ApiSources/fields.inc b/app/availableplugins/ApiConnector/templates/ApiSources/fields.inc new file mode 100644 index 000000000..6eb871263 --- /dev/null +++ b/app/availableplugins/ApiConnector/templates/ApiSources/fields.inc @@ -0,0 +1,37 @@ +

' . __d('api_connector', 'field.ApiSources.push_mode') . '

'; + + print $this->Field->banner(__d('api_connector', 'information.endpoint.push', [$vv_push_endpoint])); + + print $this->Field->control('api_user_id'); + +} diff --git a/app/availableplugins/ApiConnector/templates/ApiV2/json/delete.php b/app/availableplugins/ApiConnector/templates/ApiV2/json/delete.php new file mode 100644 index 000000000..ec7400ae0 --- /dev/null +++ b/app/availableplugins/ApiConnector/templates/ApiV2/json/delete.php @@ -0,0 +1,30 @@ + $vv_results]); +} \ No newline at end of file diff --git a/app/availableplugins/ApiConnector/templates/ApiV2/json/get.php b/app/availableplugins/ApiConnector/templates/ApiV2/json/get.php new file mode 100644 index 000000000..e584fac54 --- /dev/null +++ b/app/availableplugins/ApiConnector/templates/ApiV2/json/get.php @@ -0,0 +1,32 @@ +error)) { + print json_encode(["results" => $vv_results]); +} else { + print json_encode($vv_results); +} \ No newline at end of file diff --git a/app/availableplugins/ApiConnector/templates/ApiV2/json/upsert.php b/app/availableplugins/ApiConnector/templates/ApiV2/json/upsert.php new file mode 100644 index 000000000..143ca1280 --- /dev/null +++ b/app/availableplugins/ApiConnector/templates/ApiV2/json/upsert.php @@ -0,0 +1,30 @@ + $vv_results]); +} \ No newline at end of file diff --git a/app/availableplugins/ApiConnector/tests/bootstrap.php b/app/availableplugins/ApiConnector/tests/bootstrap.php new file mode 100644 index 000000000..92fce1162 --- /dev/null +++ b/app/availableplugins/ApiConnector/tests/bootstrap.php @@ -0,0 +1,55 @@ +loadSqlFiles('tests/schema.sql', 'test'); diff --git a/app/availableplugins/ApiConnector/tests/schema.sql b/app/availableplugins/ApiConnector/tests/schema.sql new file mode 100644 index 000000000..d01d6f95e --- /dev/null +++ b/app/availableplugins/ApiConnector/tests/schema.sql @@ -0,0 +1 @@ +-- Test database schema for ApiSource diff --git a/app/availableplugins/ApiConnector/webroot/.gitkeep b/app/availableplugins/ApiConnector/webroot/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/app/availableplugins/FileConnector/README.md b/app/availableplugins/FileConnector/README.md new file mode 100644 index 000000000..8bb5edeb3 --- /dev/null +++ b/app/availableplugins/FileConnector/README.md @@ -0,0 +1,11 @@ +# FileProvisioner plugin for CakePHP + +## Installation + +You can install this plugin into your CakePHP application using [composer](https://getcomposer.org). + +The recommended way to install composer packages is: + +``` +composer require your-name-here/file-provisioner +``` diff --git a/app/availableplugins/FileConnector/composer.json b/app/availableplugins/FileConnector/composer.json new file mode 100644 index 000000000..740d46354 --- /dev/null +++ b/app/availableplugins/FileConnector/composer.json @@ -0,0 +1,24 @@ +{ + "name": "comanage-registry/file-provisioner", + "description": "FileConnector plugin for COmanage Registry", + "type": "cakephp-plugin", + "license": "MIT", + "require": { + "php": ">=7.2", + "cakephp/cakephp": "4.4.*" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.3" + }, + "autoload": { + "psr-4": { + "FileConnector\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "FileConnector\\Test\\": "tests/", + "Cake\\Test\\": "vendor/cakephp/cakephp/tests/" + } + } +} diff --git a/app/availableplugins/FileConnector/phpunit.xml.dist b/app/availableplugins/FileConnector/phpunit.xml.dist new file mode 100644 index 000000000..9fb2429d3 --- /dev/null +++ b/app/availableplugins/FileConnector/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + + + + + tests/TestCase/ + + + + + + + + + + + src/ + + + diff --git a/app/availableplugins/FileConnector/resources/locales/en_US/file_connector.po b/app/availableplugins/FileConnector/resources/locales/en_US/file_connector.po new file mode 100644 index 000000000..400a9aa7b --- /dev/null +++ b/app/availableplugins/FileConnector/resources/locales/en_US/file_connector.po @@ -0,0 +1,71 @@ +# COmanage Registry Localizations (file_provisioner domain) +# +# Portions licensed to the University Corporation for Advanced Internet +# Development, Inc. ("UCAID") under one or more contributor license agreements. +# See the NOTICE file distributed with this work for additional information +# regarding copyright ownership. +# +# UCAID licenses this file to you under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with the +# License. You may obtain a copy of the License at: +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# @link https://www.internet2.edu/comanage COmanage Project +# @package registry +# @since COmanage Registry v5.0.0 +# @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + +msgid "controller.FileProvisioners" +msgstr "{0,plural,=1{File Provisioner} other{File Provisioners}}" + +msgid "enumeration.FileSourceFormatEnum.C3" +msgstr "CSV v3" + +msgid "error.filename.readable" +msgstr "The file \"{0}\" is not readable" + +msgid "error.filename.writeable" +msgstr "The file \"{0}\" is not writable" + +msgid "error.header" +msgstr "Did not find CSV file header" + +msgid "field.FileProvisioners.filename" +msgstr "File Name" + +msgid "field.FileProvisioners.filename.desc" +msgstr "Full path to file to write to, which must exist and be writeable" + +msgid "field.FileSources.archivedir" +msgstr "Archive Directory" + +msgid "field.FileSources.archivedir.desc" +msgstr "If specified, a limited number of prior copies of the source file will be stored here" + +msgid "field.FileSources.filename" +msgstr "File Name" + +msgid "field.FileSources.filename.desc" +msgstr "Full path to file to read from, which must exist and be readable" + +msgid "field.FileSources.format" +msgstr "File Format" + +msgid "field.FileSources.threshold_warn" +msgstr "Warning Threshold" + +msgid "field.FileSources.threshold_warn.desc" +msgstr "If the number of changed records exceeds the specified percentage, a warning will be generated and processing will stop (requires Archive Directory)" + +msgid "field.FileSources.threshold_override" +msgstr "Warning Threshold Override" + +msgid "field.FileSources.threshold_override.desc" +msgstr "If set, the next Full sync will ignore the Warning Threshold" \ No newline at end of file diff --git a/app/availableplugins/FileConnector/src/Controller/AppController.php b/app/availableplugins/FileConnector/src/Controller/AppController.php new file mode 100644 index 000000000..d1cf9843f --- /dev/null +++ b/app/availableplugins/FileConnector/src/Controller/AppController.php @@ -0,0 +1,10 @@ + [ + 'FileProvisioners.id' => 'asc' + ] + ]; +} diff --git a/app/availableplugins/FileConnector/src/Controller/FileSourcesController.php b/app/availableplugins/FileConnector/src/Controller/FileSourcesController.php new file mode 100644 index 000000000..bd8407232 --- /dev/null +++ b/app/availableplugins/FileConnector/src/Controller/FileSourcesController.php @@ -0,0 +1,40 @@ + [ + 'FileSources.id' => 'asc' + ] + ]; +} diff --git a/app/availableplugins/FileConnector/src/FileConnectorPlugin.php b/app/availableplugins/FileConnector/src/FileConnectorPlugin.php new file mode 100644 index 000000000..c3e445196 --- /dev/null +++ b/app/availableplugins/FileConnector/src/FileConnectorPlugin.php @@ -0,0 +1,93 @@ +plugin( + 'FileConnector', + ['path' => '/file-connector'], + function (RouteBuilder $builder) { + // Add custom routes here + + $builder->fallbacks(); + } + ); + parent::routes($routes); + } + + /** + * Add middleware for the plugin. + * + * @param \Cake\Http\MiddlewareQueue $middlewareQueue The middleware queue to update. + * @return \Cake\Http\MiddlewareQueue + */ + public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue + { + // Add your middlewares here + + return $middlewareQueue; + } + + /** + * Add commands for the plugin. + * + * @param \Cake\Console\CommandCollection $commands The command collection to update. + * @return \Cake\Console\CommandCollection + */ + public function console(CommandCollection $commands): CommandCollection + { + // Add your commands here + + $commands = parent::console($commands); + + return $commands; + } + + /** + * Register application container services. + * + * @param \Cake\Core\ContainerInterface $container The Container to update. + * @return void + * @link https://book.cakephp.org/4/en/development/dependency-injection.html#dependency-injection + */ + public function services(ContainerInterface $container): void + { + // Add your services here + } +} diff --git a/app/availableplugins/FileConnector/src/Lib/Enum/FileSourceFormatEnum.php b/app/availableplugins/FileConnector/src/Lib/Enum/FileSourceFormatEnum.php new file mode 100644 index 000000000..0bf9a88ce --- /dev/null +++ b/app/availableplugins/FileConnector/src/Lib/Enum/FileSourceFormatEnum.php @@ -0,0 +1,41 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/availableplugins/FileConnector/src/Model/Entity/FileSource.php b/app/availableplugins/FileConnector/src/Model/Entity/FileSource.php new file mode 100644 index 000000000..ca49ad543 --- /dev/null +++ b/app/availableplugins/FileConnector/src/Model/Entity/FileSource.php @@ -0,0 +1,49 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/availableplugins/FileConnector/src/Model/Table/FileProvisionersTable.php b/app/availableplugins/FileConnector/src/Model/Table/FileProvisionersTable.php new file mode 100644 index 000000000..3a0aff846 --- /dev/null +++ b/app/availableplugins/FileConnector/src/Model/Table/FileProvisionersTable.php @@ -0,0 +1,190 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('ProvisioningTargets'); + + $this->setDisplayField('filename'); + + $this->setPrimaryLink(['provisioning_target_id']); + $this->setRequiresCO(true); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, // Delete the pluggable object instead + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, //['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + + $this->setProvisionableModels([ + 'People', + 'Groups' + ]); + } + + /** + * Define business rules. + * + * @since COmanage Registry v5.0.0 + * @param RulesChecker $rules RulesChecker object + * @return RulesChecker + */ + + public function buildRules(RulesChecker $rules): RulesChecker { + // The requested file must exist and be writeable. + + $rules->add([$this, 'ruleIsFileWriteable'], + 'isFileWriteable', + ['errorField' => 'filename']); + + return $rules; + } + + /** + * Provision object data to the provisioning target. + * + * @param FileProvisioner $provisioningTarget FileProvisioner configuration + * @param string $entityName + * @param object $data Provisioning data in Entity format (eg: \App\Model\Entity\Person) + * @param string $eligibility Provisioning Eligibility Enum + * + * @return array Array of status, comment, and optional identifier + * @since COmanage Registry v5.0.0 + */ + + public function provision( + FileProvisioner $provisioningTarget, + string $entityName, + object $data, + string $eligibility + ): array { + // Default output is an empty record + $output = [ 'id' => $data->id ]; + + if($eligibility == ProvisioningEligibilityEnum::Eligible) { + $output = $data; + } + + if(file_put_contents( + filename: $provisioningTarget->filename, + data: json_encode($output, JSON_INVALID_UTF8_SUBSTITUTE) . "\n", + flags: FILE_APPEND + ) === false) { + throw new \RuntimeException("Write to " . $provisioningTarget->filename . " failed"); + } + + return [ + 'status' => ProvisioningStatusEnum::Provisioned, + 'comment' => "Wrote 1 record to file", + 'identifier' => null + ]; + } + + /** + * Application Rule to determine if the current entity is a writeable file. + * + * @param Entity $entity Entity to be validated + * @param array $options Application rule options + * + * @return string|bool true if the Rule check passes, false otherwise + * @since COmanage Registry v5.0.0 + */ + + public function ruleIsFileWriteable($entity, array $options): string|bool { + if(!is_writable($entity->filename)) { + return __d('file_connector', 'error.filename.writeable', [$entity->filename]); + } + + return true; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + * @throws InvalidArgumentException + * @throws RecordNotFoundException + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('provisioning_target_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('provisioning_target_id'); + + $this->registerStringValidation($validator, $schema, 'filename', true); + + return $validator; + } +} \ No newline at end of file diff --git a/app/availableplugins/FileConnector/src/Model/Table/FileSourcesTable.php b/app/availableplugins/FileConnector/src/Model/Table/FileSourcesTable.php new file mode 100644 index 000000000..c170e3ebb --- /dev/null +++ b/app/availableplugins/FileConnector/src/Model/Table/FileSourcesTable.php @@ -0,0 +1,524 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('ExternalIdentitySources'); + + $this->setDisplayField('filename'); + + $this->setPrimaryLink(['external_identity_source_id']); + $this->setRequiresCO(true); + + $this->setAutoViewVars([ + 'formats' => [ + 'type' => 'enum', + 'class' => 'FileConnector.FileSourceFormatEnum' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, // Delete the pluggable object instead + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, //['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Define business rules. + * + * @since COmanage Registry v5.0.0 + * @param RulesChecker $rules RulesChecker object + * @return RulesChecker + */ + + public function buildRules(RulesChecker $rules): RulesChecker { + // The requested file must exist and be readable. + + $rules->add([$this, 'ruleIsFileReadable'], + 'isFileReadable', + ['errorField' => 'filename']); + +// XXX CFM-117 should we also check that the archive dir, if specified, is writeable? + + return $rules; + } + + /** + * Obtain the file field configuration. + * + * @since COmanage Registry v4.0.0 + * @return array Configuration array + */ + + protected function readFieldConfig( + \FileConnector\Model\Entity\FileSource $filesource + ): array { + if($this->fieldCfg) { + return $this->fieldCfg; + } + + // The only supported format is CSV3, so we don't currently need to check + // $this->pluginCfg['format'] + + $this->fieldCfg = []; + + // The field configuration is described in the first line of the file + $handle = fopen($filesource->filename, "r"); + + if(!$handle) { + throw new \RuntimeException(__d('file_connector', 'error.filename.readable', $file_source->filename)); + } + + // The first line is our configuration + $cfg = fgetcsv($handle); + + fclose($handle); + + if(empty($cfg)) { + throw new \RuntimeException(__d('error.header')); + } + + foreach($cfg as $i => $label) { + // Labels are of the forms described in the switch statement. + // Parse them out into the fieldcfg array. + + $bits = explode('.', $label, 5); + + switch(count($bits)) { + case 1: + // SORID (special case) + $this->fieldCfg[ $bits[0] ] = $i; + break; + case 2: + // external_identity.field + // ad_hoc_attributes.tag (attached to EI) + // related_model.field (not currently used) + $this->fieldCfg[ $bits[0] ][ $bits[1] ] = $i; + break; + case 3: + // related_models.type.field + // external_identity_roles.#.field (special case) + // Note we _no longer_ flip the order model/type/field + // (this is inverted from CSV v2) + // and identifier+login is no longer supported + if($bits[0] == 'external_identity_roles') { + // Store based on role + $this->fieldCfg[ $bits[0] ]['roles'][ $bits[1] ]['fields'][ $bits[2] ] = $i; + } else { + // Store based on type + $this->fieldCfg[ $bits[0] ]['types'][ $bits[1] ][ $bits[2] ] = $i; + } + break; + case 4: + // external_identity_roles.#.ad_hoc_attributes.tag (attached to EIRole) + $this->fieldCfg[ $bits[0] ]['roles'][ $bits[1] ]['related'][ $bits[2] ][ $bits[3] ] = $i; + break; + case 5: + // external_identity_roles.#.related_models.type.field + // Note these are keyed on an SOR Role ID + $this->fieldCfg[ $bits[0] ]['roles'][ $bits[1] ]['related'][ $bits[2] ]['types'][ $bits[3] ][ $bits[4] ] = $i; + break; + } + } + + return $this->fieldCfg; + } + + /** + * Convert a record from the FileSource data to a record suitable for + * construction of an Entity. readFieldConfig() must be called before + * this function. + * + * @since COmanage Registry v5.0.0 + * @param array $result FileSource record + * @return array Entity record (in array format) + */ + + protected function resultToEntityData(array $result): array { + // Build the External Identity as an array + $eidata = []; + + // We copy whatever attributes the inbound file asserts for a given model, + // leaving it to the validation rules to worry about correctness. + + // Start with ExternalIdentity attributes (case 2) + if(!empty($this->fieldCfg['external_identity'])) { + foreach($this->fieldCfg['external_identity'] as $attr => $col) { + if(!empty($result[$col])) { + // Note we don't appear to need to convert date_of_birth manually, + // it appears to correctly marshal to a DateTime object + $eidata[$attr] = $result[$col]; + } + } + } + + // Walk through MVEAs (case 3) + foreach([ + 'addresses', + 'email_addresses', + 'identifiers', + 'names', + 'telephone_numbers', + 'urls' + ] as $model) { + if(!empty($this->fieldCfg[$model])) { + foreach(array_keys($this->fieldCfg[$model]['types']) as $type) { + $rdata = []; + + foreach($this->fieldCfg[$model]['types'][$type] as $attr => $col) { + if(!empty($result[$col])) { + $rdata[$attr] = $result[$col]; + } + } + + if(!empty($rdata)) { + // We found at least one field, so insert the type and the record + $rdata['type'] = $type; + + $eidata[$model][] = $rdata; + } + } + } + } + + // Make sure we have a Primary Name + $primaryNameSet = false; + + foreach($eidata['names'] as $n) { + if(isset($n['primary_name']) && $n['primary_name']) { + $primaryNameSet = true; + break; + } + } + + if(!$primaryNameSet) { + $eidata['names'][0]['primary_name'] = true; + } + + // Process Ad Hoc Attributes (case 2) + if(!empty($this->fieldCfg['ad_hoc_attributes'])) { + foreach($this->fieldCfg['ad_hoc_attributes'] as $tag => $col) { + if(!empty($result[$col])) { + $eidata['ad_hoc_attributes'][] = [ + 'tag' => $tag, + 'value' => $result[$col] + ]; + } + } + } + + // Handle External Identity Roles. This is similar to much of the above. + if(!empty($this->fieldCfg['external_identity_roles'])) { + foreach($this->fieldCfg['external_identity_roles']['roles'] as $roleId => $role) { + $eirdata = [ 'role_key' => $roleId ]; + + // Start with the EIR fields + foreach($role['fields'] as $attr => $col) { + if(!empty($result[$col])) { + $eirdata[$attr] = $result[$col]; + } + } + + // Next add the related models (case 5) + + foreach([ + 'addresses', + 'email_addresses', + 'telephone_numbers', + 'urls' + ] as $model) { + if(!empty($role['related'][$model])) { + foreach(array_keys($role['related'][$model]['types']) as $type) { + $rdata = []; + + foreach($role['related'][$model]['types'][$type] as $attr => $col) { + if(!empty($result[$col])) { + $rdata[$attr] = $result[$col]; + } + } + + if(!empty($rdata)) { + // We found at least one field, so insert the type and the record + $rdata['type'] = $type; + + $eirdata[$model][] = $rdata; + } + } + } + } + + // Finally process any Ad Hoc Attributes (case 4) + if(!empty($role['related']['ad_hoc_attributes'])) { + foreach($role['related']['ad_hoc_attributes'] as $tag => $col) { + if(!empty($result[$col])) { + $eirdata['ad_hoc_attributes'][] = [ + 'tag' => $tag, + 'value' => $result[$col] + ]; + } + } + } + + $eidata['external_identity_roles'][] = $eirdata; + } + } + + // XXX we're back to returning arrays rather than entities here because + // the validation rules get built even though validate = false + return $eidata; + } + + /** + * Retrieve a record from the External Identity Source. + * + * @since COmanage Registry v5.0.0 + * @param ExternalIdentitySource $source EIS Entity with instantiated plugin configuration + * @param string $source_key Backend source key for requested record + * @return array Array of source_key, source_record, and entity_data + * @throws InvalidArgumentException + */ + + public function retrieve( + \App\Model\Entity\ExternalIdentitySource $source, + string $source_key + ): array { + // Read the field configuration (for resultToEntity) + $this->readFieldConfig($source->file_source); + + $ret = [ + 'source_key' => $source_key + ]; + + // In v4 we did a field by field search, but v5 is free form. + + $handle = fopen($source->file_source->filename, "r"); + + if(!$handle) { + throw new \RuntimeException(__d('file_connector', 'error.filename.readable', [$source->file_source->filename])); + } + + // We simply walk through the file until we find the matching record. + // If there is more than one record, we'll return the first one we find. + + // The first line of a CSV v3 file is our configuration + fgetcsv($handle); + + while(($data = fgetcsv($handle)) !== false) { + if($data[0] == $source_key) { + // This is our record + + $ret['source_record'] = json_encode($data); + $ret['entity_data'] = $this->resultToEntityData($data); + + break; + } + } + + fclose($handle); + + if(!isset($ret['source_record'])) { + // We didn't find a record + throw new \InvalidArgumentException(__d('error', 'notfound', [$source_key])); + } + + return $ret; + } + + /** + * Application Rule to determine if the current entity is a readable file. + * + * @param Entity $entity Entity to be validated + * @param array $options Application rule options + * + * @return string|bool true if the Rule check passes, false otherwise + * @since COmanage Registry v5.0.0 + */ + + public function ruleIsFileReadable($entity, array $options): string|bool { + if(!is_readable($entity->filename)) { + return __d('file_connector', 'error.filename.readable', [$entity->filename]); + } + + return true; + } + + /** + * Search the External Identity Source. + * + * @since COmanage Registry v5.0.0 + * @param ExternalIdentitySource $source EIS Entity with instantiated plugin configuration + * @param array $searchAttrs Array of search attributes and values, as configured by searchAttributes() + * @return array Array of matching records + * @throws InvalidArgumentException + */ + + public function search( + \App\Model\Entity\ExternalIdentitySource $source, + array $searchAttrs + ): array { + // Read the field configuration (for resultToEntity) + $this->readFieldConfig($source->file_source); + + $ret = []; + + // In v4 we did a field by field search, but v5 is free form. + + $handle = fopen($source->file_source->filename, "r"); + + if(!$handle) { + throw new \RuntimeException(__d('file_connector', 'error.filename.readable', [$source->file_source->filename])); + } + + // The first line of a CSV v3 file is our configuration + fgetcsv($handle); + + while(($data = fgetcsv($handle)) !== false) { + // strtolower, previous behavior was full string only so dupe that + + $match = array_search(strtolower($searchAttrs['q']), array_map('strtolower', $data)); + + if($match !== false) { + // $match will be the CSV column that matched, but for now we ignore that + // since we just need to know that the row matched somewhere. Note the first + // column is always the SORID. + + $ret[ $data[0] ] = $this->resultToEntityData($data); + } + } + + fclose($handle); + + return $ret; + } + + /** + * Obtain the set of searchable attributes for this backend. + * + * @since COmanage Registry v5.0.0 + * @return array Array of searchable attributes and localized descriptions + */ + + public function searchableAttributes(): array { + // In v4 we accepted structured search attributes (name, email, etc), but + // with CSV v2 (the only currently supported format) it's not clear what + // the benefit of this is anymore, so for PE we switch to a simple search + // string. + + return [ + 'q' => __d('field', 'search.placeholder') + ]; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + * @throws InvalidArgumentException + * @throws RecordNotFoundException + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('external_source_identity_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('external_source_identity_id'); + + $this->registerStringValidation($validator, $schema, 'filename', true); + + $validator->add('format', [ + 'content' => ['rule' => ['inList', FileSourceFormatEnum::getConstValues()]] + ]); + $validator->notEmptyString('format'); + + $this->registerStringValidation($validator, $schema, 'archivedir', false); + + $validator->add('threshold_warn', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->add('threshold_warn', [ + 'range' => ['rule' => 'range', 0, 100] + ]); + $validator->allowEmptyString('threshold_warn'); + + $validator->add('threshold_override', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('threshold_override'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/availableplugins/FileConnector/src/config/plugin.json b/app/availableplugins/FileConnector/src/config/plugin.json new file mode 100644 index 000000000..977e900b2 --- /dev/null +++ b/app/availableplugins/FileConnector/src/config/plugin.json @@ -0,0 +1,38 @@ +{ + "types": { + "provisioner": [ + "FileProvisioners" + ], + "source": [ + "FileSources" + ] + }, + "schema": { + "tables": { + "file_provisioners": { + "columns": { + "id": {}, + "provisioning_target_id": {}, + "filename": { "type": "string", "size": 256 } + }, + "indexes": { + "file_provisioners_i1": { "columns": [ "provisioning_target_id" ]} + } + }, + "file_sources": { + "columns": { + "id": {}, + "external_identity_source_id": {}, + "filename": { "type": "string", "size": 256 }, + "format": { "type": "string", "size": 2 }, + "archivedir": { "type": "string", "size": 256 }, + "threshold_warn": { "type": "integer" }, + "threshold_override": { "type": "boolean" } + }, + "indexes": { + "file_sources_i1": { "columns": [ "external_identity_source_id" ] } + } + } + } + } +} \ No newline at end of file diff --git a/app/availableplugins/FileConnector/templates/FileProvisioners/fields.inc b/app/availableplugins/FileConnector/templates/FileProvisioners/fields.inc new file mode 100644 index 000000000..4980a687a --- /dev/null +++ b/app/availableplugins/FileConnector/templates/FileProvisioners/fields.inc @@ -0,0 +1,31 @@ +Field->control('filename'); +} diff --git a/app/availableplugins/FileConnector/templates/FileSources/fields-nav.inc b/app/availableplugins/FileConnector/templates/FileSources/fields-nav.inc new file mode 100644 index 000000000..4228ce579 --- /dev/null +++ b/app/availableplugins/FileConnector/templates/FileSources/fields-nav.inc @@ -0,0 +1,31 @@ + 'plugin', + 'active' => 'plugin' + ]; \ No newline at end of file diff --git a/app/availableplugins/FileConnector/templates/FileSources/fields.inc b/app/availableplugins/FileConnector/templates/FileSources/fields.inc new file mode 100644 index 000000000..2380fe7bb --- /dev/null +++ b/app/availableplugins/FileConnector/templates/FileSources/fields.inc @@ -0,0 +1,39 @@ +Field->control('filename'); + + print $this->Field->control('format'); + + print $this->Field->control('archivedir'); + + print $this->Field->control('threshold_warn'); + + print $this->Field->control('threshold_override'); +} diff --git a/app/availableplugins/FileConnector/tests/bootstrap.php b/app/availableplugins/FileConnector/tests/bootstrap.php new file mode 100644 index 000000000..040ecb580 --- /dev/null +++ b/app/availableplugins/FileConnector/tests/bootstrap.php @@ -0,0 +1,55 @@ +loadSqlFiles('tests/schema.sql', 'test'); diff --git a/app/availableplugins/FileConnector/tests/schema.sql b/app/availableplugins/FileConnector/tests/schema.sql new file mode 100644 index 000000000..ab2df5081 --- /dev/null +++ b/app/availableplugins/FileConnector/tests/schema.sql @@ -0,0 +1 @@ +-- Test database schema for FileProvisioner diff --git a/app/availableplugins/FileConnector/webroot/.gitkeep b/app/availableplugins/FileConnector/webroot/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/app/availableplugins/SqlConnector/README.md b/app/availableplugins/SqlConnector/README.md new file mode 100644 index 000000000..06ab084d6 --- /dev/null +++ b/app/availableplugins/SqlConnector/README.md @@ -0,0 +1,11 @@ +# SqlConnector plugin for CakePHP + +## Installation + +You can install this plugin into your CakePHP application using [composer](https://getcomposer.org). + +The recommended way to install composer packages is: + +``` +composer require your-name-here/sql-connector +``` diff --git a/app/availableplugins/SqlConnector/composer.json b/app/availableplugins/SqlConnector/composer.json new file mode 100644 index 000000000..25e5666fe --- /dev/null +++ b/app/availableplugins/SqlConnector/composer.json @@ -0,0 +1,24 @@ +{ + "name": "comanage-registry/sql-connector", + "description": "SqlConnector plugin for COmanage Registry", + "type": "cakephp-plugin", + "license": "MIT", + "require": { + "php": ">=7.2", + "cakephp/cakephp": "4.4.*" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.3" + }, + "autoload": { + "psr-4": { + "SqlConnector\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "SqlConnector\\Test\\": "tests/", + "Cake\\Test\\": "vendor/cakephp/cakephp/tests/" + } + } +} diff --git a/app/availableplugins/SqlConnector/phpunit.xml.dist b/app/availableplugins/SqlConnector/phpunit.xml.dist new file mode 100644 index 000000000..ed4972e70 --- /dev/null +++ b/app/availableplugins/SqlConnector/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + + + + + tests/TestCase/ + + + + + + + + + + + src/ + + + diff --git a/app/availableplugins/SqlConnector/resources/locales/en_US/sql_connector.po b/app/availableplugins/SqlConnector/resources/locales/en_US/sql_connector.po new file mode 100644 index 000000000..ceb0662fa --- /dev/null +++ b/app/availableplugins/SqlConnector/resources/locales/en_US/sql_connector.po @@ -0,0 +1,59 @@ +# COmanage Registry Localizations (sql_connector domain) +# +# Portions licensed to the University Corporation for Advanced Internet +# Development, Inc. ("UCAID") under one or more contributor license agreements. +# See the NOTICE file distributed with this work for additional information +# regarding copyright ownership. +# +# UCAID licenses this file to you under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with the +# License. You may obtain a copy of the License at: +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# @link https://www.internet2.edu/comanage COmanage Project +# @package registry +# @since COmanage Registry v5.0.0 +# @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + +msgid "controller.SqlProvisioners" +msgstr "{0,plural,=1{SQL Provisioner} other{SQL Provisioners}}" + +msgid "error.table_prefix" +msgstr "Table Name Prefix must be alphanumeric and end with an underscore" + +msgid "field.SqlProvisioners.table_prefix" +msgstr "Table Name Prefix" + +msgid "field.SqlProvisioners.table_prefix.desc" +msgstr "Prefix used when constructing table names, must be alphanumeric and end with an underscore (_)" + +msgid "operation.reapply" +msgstr "Reapply Target Database Schema" + +msgid "operation.resync" +msgstr "Resync Reference Data" + +msgid "result.prov.added" +msgstr "New record published" + +msgid "result.prov.deleted" +msgstr "Record deleted" + +msgid "result.prov.ineligible" +msgstr "Record is not eligible for provisioning" + +msgid "result.prov.updated" +msgstr "Record updated" + +msgid "result.reapply.ok" +msgstr "Schema Reapplied" + +msgid "result.resync.ok" +msgstr "Reference Data Synced" \ No newline at end of file diff --git a/app/availableplugins/SqlConnector/src/Controller/AppController.php b/app/availableplugins/SqlConnector/src/Controller/AppController.php new file mode 100644 index 000000000..42c5e1b94 --- /dev/null +++ b/app/availableplugins/SqlConnector/src/Controller/AppController.php @@ -0,0 +1,10 @@ + [ + 'SqlProvisioners.id' => 'asc' + ] + ]; + + /** + * Reapply the target database schema. + * + * @since COmanage Registry v5.0.0 + * @param string $id SqlProvisioner ID + */ + + public function reapply(string $id) { + try { + $sp = $this->SqlProvisioners->get((int)$id); + $this->SqlProvisioners->applySchema($sp->id); + + $this->Flash->success(__d('sql_connector', 'result.reapply.ok')); + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + } + + return $this->generateRedirect($sp ?? null); + } + + /** + * Reapply all Reference Data, including Groups. + * + * @since COmanage Registry v5.0.0 + * @param string $id SqlProvisioner ID + */ + + public function resync(string $id) { + try { + $cur_co = $this->getCO(); + + $sp = $this->SqlProvisioners->get((int)$id); + $this->SqlProvisioners->syncReferenceData(id: $sp->id); + + $this->Flash->success(__d('sql_connector', 'result.resync.ok')); + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + } + + return $this->generateRedirect($sp ?? null); + } +} diff --git a/app/availableplugins/SqlConnector/src/Model/Entity/SqlProvisioner.php b/app/availableplugins/SqlConnector/src/Model/Entity/SqlProvisioner.php new file mode 100644 index 000000000..5d5ff2247 --- /dev/null +++ b/app/availableplugins/SqlConnector/src/Model/Entity/SqlProvisioner.php @@ -0,0 +1,49 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/availableplugins/SqlConnector/src/Model/Table/SqlProvisionersTable.php b/app/availableplugins/SqlConnector/src/Model/Table/SqlProvisionersTable.php new file mode 100644 index 000000000..578e1e14e --- /dev/null +++ b/app/availableplugins/SqlConnector/src/Model/Table/SqlProvisionersTable.php @@ -0,0 +1,760 @@ + [ + 'table' => 'people', + 'name' => 'SpPeople', + 'source' => 'People', + 'source_table' => 'people', + 'related' => [ + 'AdHocAttributes', + 'Addresses', + 'EmailAddresses', + 'ExternalIdentities', + 'GroupMembers', + 'Identifiers', + 'Names', + 'PersonRoles', + 'Pronouns', + 'TelephoneNumbers', + 'Urls' + ] + ], + 'Groups' => [ + 'table' => 'groups', + 'name' => 'SpGroups', + 'source' => 'Groups', + 'source_table' => 'groups', + 'related' => [ + 'GroupMembers' + ] + ] + ]; + + // Secondary models that are provisioned along with one or more other models + protected $secondaryModels = [ + 'AdHocAttributes' => [ + 'table' => 'ad_hoc_attributes', + 'name' => 'SpAdHocAttributes', + 'source' => 'AdHocAttributes', + 'source_table' => 'ad_hoc_attributes', + 'related' => [] + ], + 'Addresses' => [ + 'table' => 'addresses', + 'name' => 'SpAddresses', + 'source' => 'Addresses', + 'source_table' => 'addresses', + 'related' => [] + ], + 'EmailAddresses' => [ + 'table' => 'email_addresses', + 'name' => 'SpEmailAddresses', + 'source' => 'EmailAddresses', + 'source_table' => 'email_addresses', + 'related' => [] + ], + 'ExternalIdentities' => [ + 'table' => 'external_identities', + 'name' => 'SpExternalIdentities', + 'source' => 'ExternalIdentities', + 'source_table' => 'external_identities', + 'related' => [ + 'AdHocAttributes', + 'Addresses', + 'EmailAddresses', + 'ExternalIdentityRoles', + 'Identifiers', + 'Names', + 'Pronouns', + 'TelephoneNumbers', + 'Urls' + ] + ], + 'ExternalIdentityRoles' => [ + 'table' => 'external_identity_roles', + 'name' => 'SpExternalIdentityRoles', + 'source' => 'ExternalIdentityRoles', + 'source_table' => 'external_identity_roles', + 'related' => [ + 'AdHocAttributes', + 'Addresses', + 'TelephoneNumbers' + ] + ], + 'GroupMembers' => [ + 'table' => 'group_members', + 'name' => 'SpGroupMembers', + 'source' => 'GroupMembers', + 'source_table' => 'group_members', + 'related' => [] + ], +// XXX Not implementing GroupOwners pending resolution of CO-2508 + 'Identifiers' => [ + 'table' => 'identifiers', + 'name' => 'SpIdentifiers', + 'source' => 'Identifiers', + 'source_table' => 'identifiers', + 'related' => [] + ], + 'Names' => [ + 'table' => 'names', + 'name' => 'SpNames', + 'source' => 'Names', + 'source_table' => 'names', + 'related' => [] + ], + 'PersonRoles' => [ + 'table' => 'person_roles', + 'name' => 'SpPersonRoles', + 'source' => 'PersonRoles', + 'source_table' => 'person_roles', + 'related' => [ + 'AdHocAttributes', + 'Addresses', + 'TelephoneNumbers' + ] + ], + 'Pronouns' => [ + 'table' => 'pronouns', + 'name' => 'SpPronouns', + 'source' => 'Pronouns', + 'source_table' => 'pronouns', + 'related' => [] + ], + 'TelephoneNumbers' => [ + 'table' => 'telephone_numbers', + 'name' => 'SpTelephoneNumbers', + 'source' => 'TelephoneNumbers', + 'source_table' => 'telephone_numbers', + 'related' => [] + ], + 'Urls' => [ + 'table' => 'urls', + 'name' => 'SpUrls', + 'source' => 'Urls', + 'source_table' => 'urls', + 'related' => [] + ] + ]; + + // Models holding reference data + protected $referenceModels = [ + 'Cous' => [ + 'table' => 'cous', + 'name' => 'SpCous', + 'source' => 'Cous', + 'source_table' => 'cous', +// XXX Note as of right now syncReferenceData doesn't look at 'related' +// - if we need it to, that'll break Groups + 'related' => [] + ], + 'Types' => [ + 'table' => 'types', + 'name' => 'SpTypes', + 'source' => 'Types', + 'source_table' => 'types', + 'related' => [] + ] +/* XXX not yet implemented + [ + 'table' => 'co_terms_and_conditions', + // Ordinarily we'd call this SpCoTermsAndConditions, but it's not worth + // fighting cake's inflector + 'name' => 'SpCoTermsAndCondition', + 'source' => 'CoTermsAndConditions', + 'source_table' => 'co_terms_and_conditions' + ], + [ + 'table' => 'org_identity_sources', + 'name' => 'SpOrgIdentitySource', + 'source' => 'OrgIdentitySource', + 'source_table' => 'org_identity_sources' + ]*/ + ]; + + /** + * Perform Cake Model initialization. + * + * @since COmanage Registry v5.0.0 + * @param array $config Configuration options passed to constructor + */ + + public function initialize(array $config): void { + // Timestamp behavior handles created/modified updates + $this->addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('ProvisioningTargets'); + $this->belongsTo('Servers'); + + $this->setDisplayField('server_id'); + + $this->setPrimaryLink(['provisioning_target_id']); + $this->setRequiresCO(true); + $this->setAllowLookupPrimaryLink(['reapply', 'resync']); + + $this->setAutoViewVars([ + 'servers' => [ + 'type' => 'select', + 'model' => 'Servers', + 'where' => ['plugin' => 'CoreServer.SqlServers'] + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, // Delete the pluggable object instead + 'edit' => ['platformAdmin', 'coAdmin'], + 'reapply' => ['platformAdmin', 'coAdmin'], + 'resync' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, //['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + + $this->setProvisionableModels( + array_merge( + array_keys($this->referenceModels), + array_keys($this->primaryModels) + ) + ); + } + + /** + * Apply the Target Database Schema. + * + * @since COmanage Registry v5.0.0 + * @param integer $id SQL Provisioner ID + * @throws InvalidArgumentException + * @throws RuntimeException + */ + + public function applySchema($id) { + // In order to apply the schema, we need to find the underlying + // SqlConnector configuration. There should only be (at most) one... + + $Plugins = TableRegistry::getTableLocator()->get('Plugins'); + + $targetSchema = $Plugins->getPluginConfig(plugin: "SqlConnector", key: "target-schema"); + + if(empty($targetSchema)) { + throw new \RuntimeException("Could not find SqlProvisioner target schema definition"); + } + + // Pull our configuration + + $spcfg = $this->get($id); + + $this->Servers->SqlServers->connect($spcfg->server_id, 'targetdb'); + + $SchemaManager = new SchemaManager(connection: 'targetdb'); + + $SchemaManager->applySchemaObject( + schemaObject: $targetSchema, + tablePrefix: $spcfg->table_prefix + ); + + return true; + } + + /** + * Callback after model save. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Event + * @param EntityInterface $entity Entity (ie: Co) + * @param ArrayObject $options Save options + * @return bool True on success + */ + + public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { + // We may not have a Server configuration yet on first save. + + // Also, When the SQL Provisioner is deleted, neither the + // database schema nor reference data is touched (PAR-SqlProvisioner-4). + if(!empty($entity->server_id) && !$entity->deleted) { + // Apply the database schema (PAR-SqlProvisioner-1) + $this->llog('rule', "PAR-SqlProvisioner-1 Applying database schema for SqlProvisioner " . $entity->id); + $this->applySchema($entity->id); + + // Populate or update the reference data (PAR-SqlProvisioner-2) + $this->llog('rule', "PAR-SqlProvisioner-2 Syncing reference data for SqlProvisioner " . $entity->id); + $this->syncReferenceData($entity->id); + } + + return true; + } + + /** + * Provision object data to the provisioning target. + * + * @since COmanage Registry v5.0.0 + * @param SqlProvisioner $provisioningTarget SqlProvisioner configuration + * @param string $className Class name of primary object being provisioned + * @param object $data Provisioning data in Entity format (eg: \App\Model\Entity\Person) + * @param ProvisioningEligibilityEnum $eligibility Provisioning Eligibility Enum + * @return array Array of status, comment, and optional identifier + */ + + public function provision( + \SqlConnector\Model\Entity\SqlProvisioner $provisioningTarget, + string $className, + object $data, // $data is currently only \App\Model\Entity\Person, but that might change + string $eligibility + ): array { + // Connect to the target database + $this->Servers->SqlServers->connect($provisioningTarget->server_id, 'targetdb'); + + return $this->syncEntity( + $provisioningTarget, + $className, + $data, + $eligibility + ); + } + + /** + * Sync an entity to the target database schema. + * + * @since COmanage Registry v5.0.0 + * @param SqlProvisioner $SqlProvisioner SqlProvisioner configuration + * @param string $entityName Entity name of primary object being provisioned + * @param object $data Provisioning data in Entity format (eg: \App\Model\Entity\Person) + * @param ProvisioningEligibilityEnum $eligibility Provisioning Eligibility Enum + * @param string $dataSource Datasource to provision to + * @return array Array of status, comment, and optional identifier + */ + + protected function syncEntity( + \SqlConnector\Model\Entity\SqlProvisioner $SqlProvisioner, + string $entityName, + $data, + string $eligibility, + string $dataSource='targetdb'): array { + // Find the model config, which may vary depending on the type of entity. + // We don't check secondaryModels because those aren't directly provisioned. + $mconfig = $this->primaryModels[$entityName] + ?? ($this->referenceModels[$entityName] ?? null); + + if(!$mconfig) { + throw new \RuntimeException("Model configuration for $entityName not defined"); + } + + // Pull the current target record +// XXX similar code in syncReferenceData, refactor? + $options = [ + 'table' => $SqlProvisioner->table_prefix . $mconfig['table'], + 'alias' => $mconfig['name'], + 'connection' => ConnectionManager::get($dataSource) + ]; + + $SpTable = TableRegistry::get(alias: $mconfig['name'], options: $options); + + try { + $curEntity = $SpTable->get($data->id); + + if($eligibility == ProvisioningEligibilityEnum::Eligible) { + // We have a currently provisioned record and the subject is Eligible, + // patch it with $data and try saving. + $patchedEntity = $SpTable->patchEntity($curEntity, $data->toArray(), ['validate' => false]); + + $SpTable->saveOrFail( + $patchedEntity, + [ + 'validate' => false, + 'checkRules' => false + ] + ); + + if(!empty($mconfig['related'])) { + // Process related models + foreach($mconfig['related'] as $rmodel) { + $this->syncRelatedEntities( + SqlProvisioner: $SqlProvisioner, + parentEntityName: $entityName, + relatedEntityName: $rmodel, + parentData: $data, + eligibility: $eligibility, + dataSource: $dataSource + ); + } + } + + return [ + 'status' => ProvisioningStatusEnum::Provisioned, + 'comment' => __d('sql_connector', 'result.prov.updated'), + 'identifier' => null + ]; + } else { + // The subject record is deleted or otherwise Ineligible, remove the + // current entity. Remove the related models before the entity. + + if(!empty($mconfig['related'])) { + // Process related models + foreach($mconfig['related'] as $rmodel) { + $this->syncRelatedEntities( + SqlProvisioner: $SqlProvisioner, + parentEntityName: $entityName, + relatedEntityName: $rmodel, + parentData: $data, + eligibility: $eligibility, + dataSource: $dataSource + ); + } + } + + $SpTable->delete($curEntity); + + return [ + 'status' => ProvisioningStatusEnum::NotProvisioned, + 'comment' => __d('sql_connector', 'result.prov.deleted'), + 'identifier' => null + ]; + } + } + catch(\Cake\Datasource\Exception\RecordNotFoundException $e) { + // The record is not yet in the SP table (probably a new record) + if($eligibility == ProvisioningEligibilityEnum::Eligible) { + // The subject is eligible, so provision the record + $newEntity = $SpTable->newEntity($data->toArray(), ['validate' => false]); + + $SpTable->saveOrFail( + $newEntity, + [ + 'validate' => false, + 'checkRules' => false + ] + ); + + if(!empty($mconfig['related'])) { + // Process related models + foreach($mconfig['related'] as $rmodel) { + $this->syncRelatedEntities( + SqlProvisioner: $SqlProvisioner, + parentEntityName: $entityName, + relatedEntityName: $rmodel, + parentData: $data, + eligibility: $eligibility, + dataSource: $dataSource + ); + } + } + + return [ + 'status' => ProvisioningStatusEnum::Provisioned, + 'comment' => __d('sql_connector', 'result.prov.added'), + 'identifier' => null + ]; + } else { + // The subject record is deleted or otherwise Ineligible, nothing to do + + return [ + 'status' => ProvisioningStatusEnum::NotProvisioned, + 'comment' => __d('sql_connector', 'result.prov.ineligible'), + 'identifier' => null + ]; + } + } + catch(\Exception $e) { + return [ + 'status' => ProvisioningStatusEnum::Unknown, + 'comment' => $e->getMessage(), + 'identifier' => null + ]; + } + } + + /** + * Synchronize reference data to the target database. + * + * @since COmanage Registry v5.0.0 + * @param int $id SQL Provisioner ID + * @param string $dataSource DataSource label + */ + + public function syncReferenceData(int $id, string $dataSource='targetdb') { + $spcfg = $this->get($id, ['contain' => ['ProvisioningTargets']]); + + $this->Servers->SqlServers->connect($spcfg->server_id, $dataSource); + + // We treat Groups as Reference Models since they may be referred to + // by other entities. We do NOT sync Group Members here, just the Groups. + + foreach( + // PAR-SqlProvisioner-3 When Reference Data is resynced, Groups are also resynced. + array_merge($this->referenceModels, ['Groups' => $this->primaryModels['Groups']]) + as $mname => $m + ) { + // First construct the model reflecting the target database + + $options = [ + 'table' => $spcfg->table_prefix . $m['table'], + 'alias' => $m['name'], + 'connection' => ConnectionManager::get($dataSource) + ]; + + $SpTable = TableRegistry::get(alias: $m['name'], options: $options); + + // Next get the source table model + +// XXX don't we need to use the "plugin" datasource here and elsewhere? +// (test with job shell - maybe this is an RFE for Reprovision All) + $SrcTable = TableRegistry::get($m['source']); + + // Pull the source records and then sync them to the target table. + // We expect reference data to be no larger than O(100) or maybe + // O(1000) so we don't bother with PaginatedSqlIterator here. + + $srcRecords = []; + + foreach($SrcTable->find() + ->where(['co_id' => $spcfg->provisioning_target->co_id]) + ->toArray() as $r) { + // We shouldn't have to manually convert the entities to arrays + // but toArray() is returning an array of objects instead of an + // array of arrays... (and we only need this because the second + // parameter to patchEntities expects an array since it's typically + // used to process form data) + + // We key on record ID for use in delete, below + $srcRecords[$r->id] = $r->toArray(); + } + + // Pull the current target records + $curRecords = $SpTable->find()->all(); + + // Patch the target with the source. Note this will handle add and + // insert correctly, but will ignore any records from $curRecords that + // are not in $srcRecords. + $patchedRecords = $SpTable->patchEntities($curRecords, $srcRecords, ['validate' => false]); + + $SpTable->saveManyOrFail($patchedRecords, ['validate' => false, 'checkRules' => false]); + + // patchEntities will handle inserts and updates, but not deletes. + + $toDelete = []; + + foreach($curRecords as $c) { + if(!isset($srcRecords[$c->id])) { + $toDelete[] = $c; + } + } + + if(!empty($toDelete)) { + $SpTable->deleteMany($toDelete); + } + } + } + + /** + * Sync related entities to the target database schema. + * + * @since COmanage Registry v5.0.0 + * @param SqlProvisioner $SqlProvisioner SqlProvisioner configuration + * @param string $parentEntityName Entity name of primary object being provisioned + * @param string $relatedEntityName Entity name of related object being provisioned + * @param object $parentData Provisioning data in Entity format (eg: \App\Model\Entity\Person) for parent + * @param ProvisioningEligibilityEnum $eligibility Provisioning Eligibility Enum + * @param string $dataSource Datasource to provision to + */ + + protected function syncRelatedEntities( + \SqlConnector\Model\Entity\SqlProvisioner $SqlProvisioner, + string $parentEntityName, + string $relatedEntityName, + $parentData, + string $eligibility, + string $dataSource='targetdb') { + // eg: person_id + $parentFk = StringUtilities::entityToForeignKey($parentData); + // eg: names + $relatedTable = Inflector::tableize($relatedEntityName); + + // $parentData will have the "new" values for the related model, + // we need to pull the current values from the SP tables + + $mconfig = $this->secondaryModels[$relatedEntityName]; + + if(!$mconfig) { + throw new \RuntimeException("Model configuration for $relatedEntityName not defined"); + } + + $options = [ + 'table' => $SqlProvisioner->table_prefix . $mconfig['table'], + 'alias' => $mconfig['name'], + 'connection' => ConnectionManager::get($dataSource) + ]; + + $SpTable = TableRegistry::get(alias: $mconfig['name'], options: $options); + + // We have the source values, but we need to convert them to arrays + // for patchEntities + $srcEntities = []; + + foreach($parentData->$relatedTable as $r) { + $srcEntities[$r->id] = $r->toArray(); + } + + // Pull the current provisioned data + + $curEntities = $SpTable->find() + ->where([$parentFk => $parentData->id]) + ->all(); + + if($eligibility == ProvisioningEligibilityEnum::Eligible) { + // Patch the target with the source. Note this will handle add and + // insert correctly, but will ignore any records from $curEntities that + // are not in $srcEntities. + $patchedEntities = $SpTable->patchEntities($curEntities, $srcEntities, ['validate' => false]); + + $SpTable->saveManyOrFail($patchedEntities, ['validate' => false, 'checkRules' => false]); + } else { + // Delete all currently provisioned entries, which will force by + // clearing $srcEntities + + $srcEntities = []; + } + + // Sync any related entities. We need to do this after save for Eligible + // records (above) and before delition of ineligible records (below). + // We have to do this once per instance of the parent related model. + // eg: If we're currently syncing parent model People and related model + // PersonRoles, we need to syncRelatedEntities on PersonRoles once for + // _each_ roles attached to the Person. + + if(!empty($mconfig['related'])) { + // Process related models + foreach($mconfig['related'] as $rmodel) { + if(!empty($parentData->$relatedTable)) { + foreach($parentData->$relatedTable as $rmdata) { + $this->syncRelatedEntities( + SqlProvisioner: $SqlProvisioner, + parentEntityName: $relatedEntityName, + relatedEntityName: $rmodel, + parentData: $rmdata, + eligibility: $eligibility, + dataSource: $dataSource + ); + } + } + } + } + + // Delete any dropped related entities + + $toDelete = []; + + foreach($curEntities as $c) { + if(!isset($srcEntities[$c->id])) { + $toDelete[] = $c; + } + } + + if(!empty($toDelete)) { + $SpTable->deleteMany($toDelete); + } + + // We don't currently return errors up the stack, should we? + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + * @throws InvalidArgumentException + * @throws RecordNotFoundException + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('provisioning_target_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('provisioning_target_id'); + + $validator->add('server_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('server_id'); + + $this->registerStringValidation($validator, $schema, 'table_prefix', true); + + // Table prefixes must be alphanumeric and end in an underscore + $validator->add('table_prefix', [ + 'format' => [ + 'rule' => function ($value, $context) { + return (preg_match('/[\w]+_/', $value) ? true : __d('sql_connector', 'error.table_prefix')); + } + ] + ]); + + return $validator; + } +} \ No newline at end of file diff --git a/app/availableplugins/SqlConnector/src/SqlConnectorPlugin.php b/app/availableplugins/SqlConnector/src/SqlConnectorPlugin.php new file mode 100644 index 000000000..a0bcdf884 --- /dev/null +++ b/app/availableplugins/SqlConnector/src/SqlConnectorPlugin.php @@ -0,0 +1,93 @@ +plugin( + 'SqlConnector', + ['path' => '/sql-connector'], + function (RouteBuilder $builder) { + // Add custom routes here + + $builder->fallbacks(); + } + ); + parent::routes($routes); + } + + /** + * Add middleware for the plugin. + * + * @param \Cake\Http\MiddlewareQueue $middlewareQueue The middleware queue to update. + * @return \Cake\Http\MiddlewareQueue + */ + public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue + { + // Add your middlewares here + + return $middlewareQueue; + } + + /** + * Add commands for the plugin. + * + * @param \Cake\Console\CommandCollection $commands The command collection to update. + * @return \Cake\Console\CommandCollection + */ + public function console(CommandCollection $commands): CommandCollection + { + // Add your commands here + + $commands = parent::console($commands); + + return $commands; + } + + /** + * Register application container services. + * + * @param \Cake\Core\ContainerInterface $container The Container to update. + * @return void + * @link https://book.cakephp.org/4/en/development/dependency-injection.html#dependency-injection + */ + public function services(ContainerInterface $container): void + { + // Add your services here + } +} diff --git a/app/availableplugins/SqlConnector/src/config/plugin.json b/app/availableplugins/SqlConnector/src/config/plugin.json new file mode 100644 index 000000000..04a3c185c --- /dev/null +++ b/app/availableplugins/SqlConnector/src/config/plugin.json @@ -0,0 +1,304 @@ +{ + "types": { + "provisioner": [ + "SqlProvisioners" + ] + }, + "schema": { + "tables": { + "sql_provisioners": { + "columns": { + "id": {}, + "provisioning_target_id": {}, + "server_id": { "notnull": false }, + "table_prefix": { "type": "string", "size": 32 } + }, + "indexes": { + "sql_provisioners_i1": { "columns": [ "provisioning_target_id" ]} + } + } + } + }, + "target-schema": { + "not-yet-implemented-tables": { + "terms_and_conditions": "CFM-200", + "external_identity_sources": { + "JIRA": "CFM-265", + "fk from": [ "names" ] + } + }, + + "tables": { + "types": { + "columns": { + "id": {}, + "attribute": { "type": "string", "size": 32, "notnull": true }, + "display_name": { "type": "string", "size": 64, "notnull": true }, + "value": { "type": "string", "size": 32, "notnull": true }, + "edupersonaffiliation": { "type": "string", "size": 32 }, + "status": {} + }, + "indexes": { + "types_i2": { "columns": [ "attribute" ] } + } + }, + + "cous": { + "columns": { + "id": {}, + "name": {}, + "description": {}, + "parent_id": { "type": "integer", "foreignkey": { "table": "cous", "column": "id" } } + }, + "indexes": { + "cous_i3": { "columns": [ "parent_id" ] } + }, + "changelog": false + }, + + "people": { + "columns": { + "id": {}, + "status": {}, + "timezone": { "type": "string", "size": 80 }, + "date_of_birth": { "type": "date" } + }, + "indexes": { + }, + "changelog": false + }, + + "person_roles": { + "columns": { + "id": {}, + "person_id": { "notnull": true }, + "status": {}, + "ordr": {}, + "cou_id": {}, + "affiliation_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "title": { "type": "string", "size": 128 }, + "organization": { "type": "string", "size": 128 }, + "department": { "type": "string", "size": 128 }, + "manager_person_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } }, + "sponsor_person_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } }, + "valid_from": {}, + "valid_through": {} + }, + "indexes": { + "person_roles_i1": { "columns": [ "person_id" ] }, + "person_roles_i2": { "columns": [ "sponsor_person_id" ] }, + "person_roles_i3": { "columns": [ "cou_id" ] }, + "person_roles_i4": { "columns": [ "affiliation_type_id" ] }, + "person_roles_i5": { "columns": [ "manager_person_id" ] } + }, + "changelog": false + }, + + "external_identities": { + "columns": { + "id": {}, + "person_id": { "notnull": true }, + "status": {}, + "date_of_birth": { "type": "date" } + }, + "indexes": { + "external_identities_i1": { "columns": [ "person_id" ] } + }, + "changelog": false + }, + + "external_identity_roles": { + "columns": { + "id": {}, + "external_identity_id": { "notnull": true }, + "status": {}, + "ordr": {}, + "affiliation_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "title": { "type": "string", "size": 128 }, + "organization": { "type": "string", "size": 128 }, + "department": { "type": "string", "size": 128 }, + "manager_identifier": { "type": "string", "size": 512 }, + "sponsor_identifier": { "type": "string", "size": 512 }, + "valid_from": {}, + "valid_through": {} + }, + "indexes": { + "external_identity_roles_i1": { "columns": [ "external_identity_id" ] }, + "external_identity_roles_i2": { "columns": [ "affiliation_type_id" ] } + }, + "changelog": false + }, + + "groups": { + "columns": { + "id": {}, + "cou_id": {}, + "name": {}, + "description": { "size": 256 }, + "open": { "type": "boolean" }, + "status": {}, + "group_type": { "type": "string", "size": 2 } + }, + "indexes": { + "groups_i5": { "columns": [ "cou_id" ]} + }, + "changelog": false + }, + + "ad_hoc_attributes": { + "columns": { + "id": {}, + "tag": { "type": "string", "size": 128 }, + "value": { "type": "string", "size": 256 } + }, + "indexes": { + }, + "mvea": [ "person", "person_role", "external_identity", "external_identity_role" ], + "changelog": false + }, + + "addresses": { + "columns": { + "id": {}, + "street": { "type": "text" }, + "room": { "type": "string", "size": 64 }, + "locality": { "type": "string", "size": 128 }, + "state": { "type": "string", "size": 128 }, + "postal_code": { "type": "string", "size": 16 }, + "country": { "type": "string", "size": 128 }, + "description": {}, + "type_id": {}, + "language": {} + }, + "indexes": { + "addresses_i1": { "columns": [ "type_id" ] } + }, + "mvea": [ "person", "person_role", "external_identity", "external_identity_role" ], + "changelog": false + }, + + "email_addresses": { + "columns": { + "id": {}, + "mail": { "type": "string", "size": 256 }, + "description": {}, + "type_id": {}, + "verified": { "type": "boolean" } + }, + "indexes": { + "email_addresses_i3": { "columns": [ "type_id" ] } + }, + "mvea": [ "person", "external_identity" ], + "changelog": false + }, + + "identifiers": { + "columns": { + "id": {}, + "identifier": { "type": "string", "size": 512 }, + "type_id": {}, + "login": { "type": "boolean" }, + "status": {} + }, + "indexes": { + "identifiers_i3": { "columns": [ "type_id" ] } + }, + "mvea": [ "person", "external_identity", "group" ], + "changelog": false + }, + + "names": { + "columns": { + "id": {}, + "honorific": { "type": "string", "size": 32 }, + "given": { "type": "string", "size": 128 }, + "middle": { "type": "string", "size": 128 }, + "family": { "type": "string", "size": 128 }, + "suffix": { "type": "string", "size": 32 }, + "type_id": {}, + "language": {}, + "primary_name": { "type": "boolean" }, + "display_name": { "type": "string", "size": 256 } + }, + "indexes": { + "names_i1": { "columns": [ "type_id" ] } + }, + "mvea": [ "person", "external_identity" ], + "changelog": false + }, + + "pronouns": { + "columns": { + "id": {}, + "pronouns": { "type": "string", "size": 64 }, + "language": {}, + "type_id": {} + }, + "indexes": { + "pronouns_i1": { "columns": [ "type_id" ] } + }, + "mvea": [ "person", "external_identity" ], + "changelog": false + }, + + "telephone_numbers": { + "columns": { + "id": {}, + "country_code": { "type": "string", "size": 3 }, + "area_code": { "type": "string", "size": 8 }, + "number": { "type": "string", "size": 64 }, + "extension": { "type": "string", "size": 16 }, + "description": {}, + "type_id": {} + }, + "indexes": { + "telephone_numbers_i1": { "columns": [ "type_id" ] } + }, + "mvea": [ "person", "person_role", "external_identity", "external_identity_role" ], + "changelog": false + }, + + "urls": { + "columns": { + "id": {}, + "url": { "type": "string", "size": 256 }, + "description": {}, + "type_id": {} + }, + "indexes": { + "urls_i1": { "columns": [ "type_id" ] } + }, + "mvea": [ "person", "external_identity" ], + "changelog": false + }, + + "group_members": { + "columns": { + "id": {}, + "group_id": {}, + "person_id": {}, + "valid_from": {}, + "valid_through": {} + }, + "indexes": { + "group_members_i1": { "columns": [ "group_id" ]}, + "group_members_i2": { "columns": [ "person_id" ]} + }, + "changelog": false + }, + + "group_owners": { + "columns": { + "id": {}, + "group_id": {}, + "person_id": {} + }, + "indexes": { + "group_owners_i1": { "columns": [ "group_id" ]}, + "group_owners_i2": { "columns": [ "person_id" ]} + }, + "changelog": false + } + } + } +} \ No newline at end of file diff --git a/app/availableplugins/SqlConnector/templates/SqlProvisioners/fields-links.inc b/app/availableplugins/SqlConnector/templates/SqlProvisioners/fields-links.inc new file mode 100644 index 000000000..6788eb97a --- /dev/null +++ b/app/availableplugins/SqlConnector/templates/SqlProvisioners/fields-links.inc @@ -0,0 +1,48 @@ + 'history', + 'order' => 'Default', + 'label' => __d('sql_connector', 'operation.reapply'), + 'link' => [ + 'action' => 'reapply', + $vv_obj->id + ], + 'class' => '' +]; + +$topLinks[] = [ + 'icon' => 'history', + 'order' => 'Default', + 'label' => __d('sql_connector', 'operation.resync'), + 'link' => [ + 'action' => 'resync', + $vv_obj->id + ], + 'class' => '' +]; diff --git a/app/availableplugins/SqlConnector/templates/SqlProvisioners/fields-nav.inc b/app/availableplugins/SqlConnector/templates/SqlProvisioners/fields-nav.inc new file mode 100644 index 000000000..3e1722b8a --- /dev/null +++ b/app/availableplugins/SqlConnector/templates/SqlProvisioners/fields-nav.inc @@ -0,0 +1,31 @@ + 'plugin', + 'active' => 'plugin' + ]; \ No newline at end of file diff --git a/app/availableplugins/SqlConnector/templates/SqlProvisioners/fields.inc b/app/availableplugins/SqlConnector/templates/SqlProvisioners/fields.inc new file mode 100644 index 000000000..2b23c2634 --- /dev/null +++ b/app/availableplugins/SqlConnector/templates/SqlProvisioners/fields.inc @@ -0,0 +1,33 @@ +Field->control('server_id'); + + print $this->Field->control('table_prefix', ['default' => 'sp_']); +} diff --git a/app/availableplugins/SqlConnector/tests/bootstrap.php b/app/availableplugins/SqlConnector/tests/bootstrap.php new file mode 100644 index 000000000..7539568b5 --- /dev/null +++ b/app/availableplugins/SqlConnector/tests/bootstrap.php @@ -0,0 +1,55 @@ +loadSqlFiles('tests/schema.sql', 'test'); diff --git a/app/availableplugins/SqlConnector/tests/schema.sql b/app/availableplugins/SqlConnector/tests/schema.sql new file mode 100644 index 000000000..b3c02dbcb --- /dev/null +++ b/app/availableplugins/SqlConnector/tests/schema.sql @@ -0,0 +1 @@ +-- Test database schema for SqlConnector diff --git a/app/availableplugins/SqlConnector/webroot/.gitkeep b/app/availableplugins/SqlConnector/webroot/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/app/bin/cake b/app/bin/cake new file mode 100755 index 000000000..4b696c883 --- /dev/null +++ b/app/bin/cake @@ -0,0 +1,75 @@ +#!/usr/bin/env sh +################################################################################ +# +# Cake is a shell script for invoking CakePHP shell commands +# +# CakePHP(tm) : Rapid Development Framework (https://cakephp.org) +# Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) +# +# Licensed under The MIT License +# For full copyright and license information, please see the LICENSE.txt +# Redistributions of files must retain the above copyright notice. +# +# @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) +# @link https://cakephp.org CakePHP(tm) Project +# @since 1.2.0 +# @license https://opensource.org/licenses/mit-license.php MIT License +# +################################################################################ + +# Canonicalize by following every symlink of the given name recursively +canonicalize() { + NAME="$1" + if [ -f "$NAME" ] + then + DIR=$(dirname -- "$NAME") + NAME=$(cd -P "$DIR" > /dev/null && pwd -P)/$(basename -- "$NAME") + fi + while [ -h "$NAME" ]; do + DIR=$(dirname -- "$NAME") + SYM=$(readlink "$NAME") + NAME=$(cd "$DIR" > /dev/null && cd "$(dirname -- "$SYM")" > /dev/null && pwd)/$(basename -- "$SYM") + done + echo "$NAME" +} + +# Find a CLI version of PHP +findCliPhp() { + for TESTEXEC in php php-cli /usr/local/bin/php + do + SAPI=$(echo "" | $TESTEXEC 2>/dev/null) + if [ "$SAPI" = "cli" ] + then + echo $TESTEXEC + return + fi + done + echo "Failed to find a CLI version of PHP; falling back to system standard php executable" >&2 + echo "php"; +} + +# If current path is a symlink, resolve to real path +realname="$0" +if [ -L "$realname" ] +then + realname=$(readlink -f "$0") +fi + +CONSOLE=$(dirname -- "$(canonicalize "$realname")") +APP=$(dirname "$CONSOLE") + +# If your CLI PHP is somewhere that this doesn't find, you can define a PHP environment +# variable with the correct path in it. +if [ -z "$PHP" ] +then + PHP=$(findCliPhp) +fi + +if [ "$(basename "$realname")" != 'cake' ] +then + exec "$PHP" "$CONSOLE"/cake.php "$(basename "$realname")" "$@" +else + exec "$PHP" "$CONSOLE"/cake.php "$@" +fi + +exit diff --git a/app/bin/cake.bat b/app/bin/cake.bat new file mode 100644 index 000000000..ad1378229 --- /dev/null +++ b/app/bin/cake.bat @@ -0,0 +1,27 @@ +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:: +:: Cake is a Windows batch script for invoking CakePHP shell commands +:: +:: CakePHP(tm) : Rapid Development Framework (https://cakephp.org) +:: Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) +:: +:: Licensed under The MIT License +:: Redistributions of files must retain the above copyright notice. +:: +:: @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) +:: @link https://cakephp.org CakePHP(tm) Project +:: @since 2.0.0 +:: @license https://opensource.org/licenses/mit-license.php MIT License +:: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: + +@echo off + +SET app=%0 +SET lib=%~dp0 + +php "%lib%cake.php" %* + +echo. + +exit /B %ERRORLEVEL% diff --git a/app/bin/cake.php b/app/bin/cake.php new file mode 100644 index 000000000..320ee3643 --- /dev/null +++ b/app/bin/cake.php @@ -0,0 +1,12 @@ +#!/usr/bin/php -q +run($argv)); diff --git a/app/composer.json b/app/composer.json new file mode 100644 index 000000000..29cb6562f --- /dev/null +++ b/app/composer.json @@ -0,0 +1,71 @@ +{ + "name": "cakephp/app", + "description": "CakePHP skeleton app", + "homepage": "https://cakephp.org", + "type": "project", + "license": "MIT", + "require": { + "php": ">=8.0", + "cakephp/cakephp": "4.4.*", + "cakephp/migrations": "^3.2", + "cakephp/plugin-installer": "^1.3", + "doctrine/dbal": "^3.3", + "league/container": "^4.2.0", + "mobiledetect/mobiledetectlib": "^2.8", + "psr/log": "^2.0" + }, + "require-dev": { + "cakephp/bake": "^2.6", + "cakephp/cakephp-codesniffer": "^4.5", + "cakephp/debug_kit": "^4.5", + "josegonzalez/dotenv": "^3.2", + "phpunit/phpunit": "~8.5.0 || ^9.3", + "psy/psysh": "@stable" + }, + "suggest": { + "markstory/asset_compress": "An asset compression plugin which provides file concatenation and a flexible filter system for preprocessing and minification.", + "dereuromark/cakephp-ide-helper": "After baking your code, this keeps your annotations in sync with the code evolving from there on for maximum IDE and PHPStan/Psalm compatibility.", + "phpstan/phpstan": "PHPStan focuses on finding errors in your code without actually running it. It catches whole classes of bugs even before you write tests for the code." + }, + "autoload": { + "psr-4": { + "App\\": "src/", + "ApiConnector\\": "availableplugins/ApiConnector/src/", + "CoreAssigner\\": "plugins/CoreAssigner/src/", + "CoreServer\\": "plugins/CoreServer/src/", + "FileConnector\\": "availableplugins/FileConnector/src/", + "SqlConnector\\": "availableplugins/SqlConnector/src/" + } + }, + "autoload-dev": { + "psr-4": { + "App\\Test\\": "tests/", + "ApiConnector\\Test\\": "availableplugins/ApiConnector/tests/", + "Cake\\Test\\": "vendor/cakephp/cakephp/tests/", + "CoreAssigner\\Test\\": "plugins/CoreAssigner/tests/", + "CoreServer\\Test\\": "plugins/CoreServer/tests/", + "FileConnector\\Test\\": "availableplugins/FileConnector/tests/", + "SqlConnector\\Test\\": "availableplugins/SqlConnector/tests/" + } + }, + "scripts": { + "post-install-cmd": "App\\Console\\Installer::postInstall", + "post-create-project-cmd": "App\\Console\\Installer::postInstall", + "check": [ + "@test", + "@cs-check" + ], + "cs-check": "phpcs --colors -p src/ tests/", + "cs-fix": "phpcbf --colors -p src/ tests/", + "stan": "phpstan analyse", + "test": "phpunit --colors=always" + }, + "prefer-stable": true, + "config": { + "sort-packages": true, + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true, + "cakephp/plugin-installer": true + } + } +} diff --git a/app/composer.lock b/app/composer.lock new file mode 100644 index 000000000..387b557d1 --- /dev/null +++ b/app/composer.lock @@ -0,0 +1,6416 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "b8641c704e99c64fa4e1c3bfd3c27959", + "packages": [ + { + "name": "cakephp/cakephp", + "version": "4.4.16", + "source": { + "type": "git", + "url": "https://github.com/cakephp/cakephp.git", + "reference": "f68c3c6f24d65cb869cceaa8975f2ca2e1d0f5b7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/cakephp/cakephp/zipball/f68c3c6f24d65cb869cceaa8975f2ca2e1d0f5b7", + "reference": "f68c3c6f24d65cb869cceaa8975f2ca2e1d0f5b7", + "shasum": "" + }, + "require": { + "cakephp/chronos": "^2.2", + "composer/ca-bundle": "^1.2", + "ext-intl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "laminas/laminas-diactoros": "^2.2.2", + "laminas/laminas-httphandlerrunner": "^1.1 || ^2.0", + "league/container": "^4.2.0", + "php": ">=7.4.0", + "psr/container": "^1.1 || ^2.0", + "psr/http-client": "^1.0", + "psr/http-server-handler": "^1.0", + "psr/http-server-middleware": "^1.0", + "psr/log": "^1.0 || ^2.0", + "psr/simple-cache": "^1.0 || ^2.0" + }, + "provide": { + "psr/container-implementation": "^1.0 || ^2.0", + "psr/http-client-implementation": "^1.0", + "psr/http-server-handler-implementation": "^1.0", + "psr/http-server-middleware-implementation": "^1.0", + "psr/log-implementation": "^1.0 || ^2.0", + "psr/simple-cache-implementation": "^1.0 || ^2.0" + }, + "replace": { + "cakephp/cache": "self.version", + "cakephp/collection": "self.version", + "cakephp/console": "self.version", + "cakephp/core": "self.version", + "cakephp/database": "self.version", + "cakephp/datasource": "self.version", + "cakephp/event": "self.version", + "cakephp/filesystem": "self.version", + "cakephp/form": "self.version", + "cakephp/http": "self.version", + "cakephp/i18n": "self.version", + "cakephp/log": "self.version", + "cakephp/orm": "self.version", + "cakephp/utility": "self.version", + "cakephp/validation": "self.version" + }, + "require-dev": { + "cakephp/cakephp-codesniffer": "^4.5", + "mikey179/vfsstream": "^1.6.10", + "paragonie/csp-builder": "^2.3", + "phpunit/phpunit": "^8.5 || ^9.3" + }, + "suggest": { + "ext-curl": "To enable more efficient network calls in Http\\Client.", + "ext-openssl": "To use Security::encrypt() or have secure CSRF token generation.", + "lib-ICU": "To use locale-aware features in the I18n and Database packages", + "paragonie/csp-builder": "CSP builder, to use the CSP Middleware" + }, + "type": "library", + "autoload": { + "files": [ + "src/Core/functions.php", + "src/Collection/functions.php", + "src/I18n/functions.php", + "src/Routing/functions.php", + "src/Utility/bootstrap.php" + ], + "psr-4": { + "Cake\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "CakePHP Community", + "homepage": "https://github.com/cakephp/cakephp/graphs/contributors" + } + ], + "description": "The CakePHP framework", + "homepage": "https://cakephp.org", + "keywords": [ + "conventions over configuration", + "dry", + "form", + "framework", + "mvc", + "orm", + "psr-7", + "rapid-development", + "validation" + ], + "support": { + "forum": "https://stackoverflow.com/tags/cakephp", + "irc": "irc://irc.freenode.org/cakephp", + "issues": "https://github.com/cakephp/cakephp/issues", + "source": "https://github.com/cakephp/cakephp" + }, + "time": "2023-08-05T15:33:05+00:00" + }, + { + "name": "cakephp/chronos", + "version": "2.4.0", + "source": { + "type": "git", + "url": "https://github.com/cakephp/chronos.git", + "reference": "9c7e438cba4eed1796ec19ad3874defa9eb9aeac" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/cakephp/chronos/zipball/9c7e438cba4eed1796ec19ad3874defa9eb9aeac", + "reference": "9c7e438cba4eed1796ec19ad3874defa9eb9aeac", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "require-dev": { + "cakephp/cakephp-codesniffer": "^4.5", + "phpunit/phpunit": "^8.0 || ^9.0" + }, + "type": "library", + "autoload": { + "files": [ + "src/carbon_compat.php" + ], + "psr-4": { + "Cake\\Chronos\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "http://nesbot.com" + }, + { + "name": "The CakePHP Team", + "homepage": "https://cakephp.org" + } + ], + "description": "A simple API extension for DateTime.", + "homepage": "https://cakephp.org", + "keywords": [ + "date", + "datetime", + "time" + ], + "support": { + "issues": "https://github.com/cakephp/chronos/issues", + "source": "https://github.com/cakephp/chronos" + }, + "time": "2023-08-06T22:54:27+00:00" + }, + { + "name": "cakephp/migrations", + "version": "3.8.2", + "source": { + "type": "git", + "url": "https://github.com/cakephp/migrations.git", + "reference": "1c7c060f8d91d62b0716f9b08e3d41bfece5386e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/cakephp/migrations/zipball/1c7c060f8d91d62b0716f9b08e3d41bfece5386e", + "reference": "1c7c060f8d91d62b0716f9b08e3d41bfece5386e", + "shasum": "" + }, + "require": { + "cakephp/cache": "^4.3.0", + "cakephp/orm": "^4.3.0", + "php": ">=7.4.0", + "robmorgan/phinx": "^0.13.2" + }, + "require-dev": { + "cakephp/bake": "^2.6.0", + "cakephp/cakephp": "^4.3.0", + "cakephp/cakephp-codesniffer": "^4.1", + "phpunit/phpunit": "^9.5.0" + }, + "suggest": { + "cakephp/bake": "If you want to generate migrations.", + "dereuromark/cakephp-ide-helper": "If you want to have IDE suggest/autocomplete when creating migrations." + }, + "type": "cakephp-plugin", + "autoload": { + "psr-4": { + "Migrations\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "CakePHP Community", + "homepage": "https://github.com/cakephp/migrations/graphs/contributors" + } + ], + "description": "Database Migration plugin for CakePHP based on Phinx", + "homepage": "https://github.com/cakephp/migrations", + "keywords": [ + "cakephp", + "migrations" + ], + "support": { + "forum": "https://stackoverflow.com/tags/cakephp", + "irc": "irc://irc.freenode.org/cakephp", + "issues": "https://github.com/cakephp/migrations/issues", + "source": "https://github.com/cakephp/migrations" + }, + "time": "2023-06-12T01:40:01+00:00" + }, + { + "name": "cakephp/plugin-installer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/cakephp/plugin-installer.git", + "reference": "e27027aa2d3d8ab64452c6817629558685a064cb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/cakephp/plugin-installer/zipball/e27027aa2d3d8ab64452c6817629558685a064cb", + "reference": "e27027aa2d3d8ab64452c6817629558685a064cb", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0 || ^2.0", + "php": ">=5.6.0" + }, + "require-dev": { + "cakephp/cakephp-codesniffer": "^3.3", + "composer/composer": "^2.0", + "phpunit/phpunit": "^5.7 || ^6.5 || ^8.5 || ^9.3" + }, + "type": "composer-plugin", + "extra": { + "class": "Cake\\Composer\\Plugin" + }, + "autoload": { + "psr-4": { + "Cake\\Composer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "CakePHP Community", + "homepage": "https://cakephp.org" + } + ], + "description": "A composer installer for CakePHP 3.0+ plugins.", + "support": { + "issues": "https://github.com/cakephp/plugin-installer/issues", + "source": "https://github.com/cakephp/plugin-installer/tree/1.3.1" + }, + "time": "2020-10-29T04:00:42+00:00" + }, + { + "name": "composer/ca-bundle", + "version": "1.3.6", + "source": { + "type": "git", + "url": "https://github.com/composer/ca-bundle.git", + "reference": "90d087e988ff194065333d16bc5cf649872d9cdb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/ca-bundle/zipball/90d087e988ff194065333d16bc5cf649872d9cdb", + "reference": "90d087e988ff194065333d16bc5cf649872d9cdb", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "ext-pcre": "*", + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.55", + "psr/log": "^1.0", + "symfony/phpunit-bridge": "^4.2 || ^5", + "symfony/process": "^2.5 || ^3.0 || ^4.0 || ^5.0 || ^6.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\CaBundle\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "Lets you find a path to the system CA bundle, and includes a fallback to the Mozilla CA bundle.", + "keywords": [ + "cabundle", + "cacert", + "certificate", + "ssl", + "tls" + ], + "support": { + "irc": "irc://irc.freenode.org/composer", + "issues": "https://github.com/composer/ca-bundle/issues", + "source": "https://github.com/composer/ca-bundle/tree/1.3.6" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2023-06-06T12:02:59+00:00" + }, + { + "name": "doctrine/cache", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/cache.git", + "reference": "1ca8f21980e770095a31456042471a57bc4c68fb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/cache/zipball/1ca8f21980e770095a31456042471a57bc4c68fb", + "reference": "1ca8f21980e770095a31456042471a57bc4c68fb", + "shasum": "" + }, + "require": { + "php": "~7.1 || ^8.0" + }, + "conflict": { + "doctrine/common": ">2.2,<2.4" + }, + "require-dev": { + "cache/integration-tests": "dev-master", + "doctrine/coding-standard": "^9", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", + "psr/cache": "^1.0 || ^2.0 || ^3.0", + "symfony/cache": "^4.4 || ^5.4 || ^6", + "symfony/var-exporter": "^4.4 || ^5.4 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Cache\\": "lib/Doctrine/Common/Cache" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Cache library is a popular cache implementation that supports many different drivers such as redis, memcache, apc, mongodb and others.", + "homepage": "https://www.doctrine-project.org/projects/cache.html", + "keywords": [ + "abstraction", + "apcu", + "cache", + "caching", + "couchdb", + "memcached", + "php", + "redis", + "xcache" + ], + "support": { + "issues": "https://github.com/doctrine/cache/issues", + "source": "https://github.com/doctrine/cache/tree/2.2.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fcache", + "type": "tidelift" + } + ], + "time": "2022-05-20T20:07:39+00:00" + }, + { + "name": "doctrine/dbal", + "version": "3.6.6", + "source": { + "type": "git", + "url": "https://github.com/doctrine/dbal.git", + "reference": "63646ffd71d1676d2f747f871be31b7e921c7864" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/63646ffd71d1676d2f747f871be31b7e921c7864", + "reference": "63646ffd71d1676d2f747f871be31b7e921c7864", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2", + "doctrine/cache": "^1.11|^2.0", + "doctrine/deprecations": "^0.5.3|^1", + "doctrine/event-manager": "^1|^2", + "php": "^7.4 || ^8.0", + "psr/cache": "^1|^2|^3", + "psr/log": "^1|^2|^3" + }, + "require-dev": { + "doctrine/coding-standard": "12.0.0", + "fig/log-test": "^1", + "jetbrains/phpstorm-stubs": "2023.1", + "phpstan/phpstan": "1.10.29", + "phpstan/phpstan-strict-rules": "^1.5", + "phpunit/phpunit": "9.6.9", + "psalm/plugin-phpunit": "0.18.4", + "slevomat/coding-standard": "8.13.1", + "squizlabs/php_codesniffer": "3.7.2", + "symfony/cache": "^5.4|^6.0", + "symfony/console": "^4.4|^5.4|^6.0", + "vimeo/psalm": "4.30.0" + }, + "suggest": { + "symfony/console": "For helpful console commands such as SQL execution and import of files." + }, + "bin": [ + "bin/doctrine-dbal" + ], + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\DBAL\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + } + ], + "description": "Powerful PHP database abstraction layer (DBAL) with many features for database schema introspection and management.", + "homepage": "https://www.doctrine-project.org/projects/dbal.html", + "keywords": [ + "abstraction", + "database", + "db2", + "dbal", + "mariadb", + "mssql", + "mysql", + "oci8", + "oracle", + "pdo", + "pgsql", + "postgresql", + "queryobject", + "sasql", + "sql", + "sqlite", + "sqlserver", + "sqlsrv" + ], + "support": { + "issues": "https://github.com/doctrine/dbal/issues", + "source": "https://github.com/doctrine/dbal/tree/3.6.6" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdbal", + "type": "tidelift" + } + ], + "time": "2023-08-17T05:38:17+00:00" + }, + { + "name": "doctrine/deprecations", + "version": "v1.1.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "612a3ee5ab0d5dd97b7cf3874a6efe24325efac3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/612a3ee5ab0d5dd97b7cf3874a6efe24325efac3", + "reference": "612a3ee5ab0d5dd97b7cf3874a6efe24325efac3", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^9", + "phpstan/phpstan": "1.4.10 || 1.10.15", + "phpstan/phpstan-phpunit": "^1.0", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", + "psalm/plugin-phpunit": "0.18.4", + "psr/log": "^1 || ^2 || ^3", + "vimeo/psalm": "4.30.0 || 5.12.0" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "lib/Doctrine/Deprecations" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/v1.1.1" + }, + "time": "2023-06-03T09:27:29+00:00" + }, + { + "name": "doctrine/event-manager", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/event-manager.git", + "reference": "750671534e0241a7c50ea5b43f67e23eb5c96f32" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/event-manager/zipball/750671534e0241a7c50ea5b43f67e23eb5c96f32", + "reference": "750671534e0241a7c50ea5b43f67e23eb5c96f32", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "conflict": { + "doctrine/common": "<2.9" + }, + "require-dev": { + "doctrine/coding-standard": "^10", + "phpstan/phpstan": "^1.8.8", + "phpunit/phpunit": "^9.5", + "vimeo/psalm": "^4.28" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + }, + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com" + } + ], + "description": "The Doctrine Event Manager is a simple PHP event system that was built to be used with the various Doctrine projects.", + "homepage": "https://www.doctrine-project.org/projects/event-manager.html", + "keywords": [ + "event", + "event dispatcher", + "event manager", + "event system", + "events" + ], + "support": { + "issues": "https://github.com/doctrine/event-manager/issues", + "source": "https://github.com/doctrine/event-manager/tree/2.0.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fevent-manager", + "type": "tidelift" + } + ], + "time": "2022-10-12T20:59:15+00:00" + }, + { + "name": "laminas/laminas-diactoros", + "version": "2.25.2", + "source": { + "type": "git", + "url": "https://github.com/laminas/laminas-diactoros.git", + "reference": "9f3f4bf5b99c9538b6f1dbcc20f6fec357914f9e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laminas/laminas-diactoros/zipball/9f3f4bf5b99c9538b6f1dbcc20f6fec357914f9e", + "reference": "9f3f4bf5b99c9538b6f1dbcc20f6fec357914f9e", + "shasum": "" + }, + "require": { + "php": "~8.0.0 || ~8.1.0 || ~8.2.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1" + }, + "conflict": { + "zendframework/zend-diactoros": "*" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "ext-curl": "*", + "ext-dom": "*", + "ext-gd": "*", + "ext-libxml": "*", + "http-interop/http-factory-tests": "^0.9.0", + "laminas/laminas-coding-standard": "^2.5", + "php-http/psr7-integration-tests": "^1.2", + "phpunit/phpunit": "^9.5.28", + "psalm/plugin-phpunit": "^0.18.4", + "vimeo/psalm": "^5.6" + }, + "type": "library", + "extra": { + "laminas": { + "config-provider": "Laminas\\Diactoros\\ConfigProvider", + "module": "Laminas\\Diactoros" + } + }, + "autoload": { + "files": [ + "src/functions/create_uploaded_file.php", + "src/functions/marshal_headers_from_sapi.php", + "src/functions/marshal_method_from_sapi.php", + "src/functions/marshal_protocol_version_from_sapi.php", + "src/functions/marshal_uri_from_sapi.php", + "src/functions/normalize_server.php", + "src/functions/normalize_uploaded_files.php", + "src/functions/parse_cookie_header.php", + "src/functions/create_uploaded_file.legacy.php", + "src/functions/marshal_headers_from_sapi.legacy.php", + "src/functions/marshal_method_from_sapi.legacy.php", + "src/functions/marshal_protocol_version_from_sapi.legacy.php", + "src/functions/marshal_uri_from_sapi.legacy.php", + "src/functions/normalize_server.legacy.php", + "src/functions/normalize_uploaded_files.legacy.php", + "src/functions/parse_cookie_header.legacy.php" + ], + "psr-4": { + "Laminas\\Diactoros\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "PSR HTTP Message implementations", + "homepage": "https://laminas.dev", + "keywords": [ + "http", + "laminas", + "psr", + "psr-17", + "psr-7" + ], + "support": { + "chat": "https://laminas.dev/chat", + "docs": "https://docs.laminas.dev/laminas-diactoros/", + "forum": "https://discourse.laminas.dev", + "issues": "https://github.com/laminas/laminas-diactoros/issues", + "rss": "https://github.com/laminas/laminas-diactoros/releases.atom", + "source": "https://github.com/laminas/laminas-diactoros" + }, + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2023-04-17T15:44:17+00:00" + }, + { + "name": "laminas/laminas-httphandlerrunner", + "version": "2.7.0", + "source": { + "type": "git", + "url": "https://github.com/laminas/laminas-httphandlerrunner.git", + "reference": "ab1fe48f1c367953d7e8f5cfbaf902672551d524" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laminas/laminas-httphandlerrunner/zipball/ab1fe48f1c367953d7e8f5cfbaf902672551d524", + "reference": "ab1fe48f1c367953d7e8f5cfbaf902672551d524", + "shasum": "" + }, + "require": { + "php": "~8.1.0 || ~8.2.0", + "psr/http-message": "^1.0 || ^2.0", + "psr/http-message-implementation": "^1.0 || ^2.0", + "psr/http-server-handler": "^1.0" + }, + "require-dev": { + "laminas/laminas-coding-standard": "~2.5.0", + "laminas/laminas-diactoros": "^3.0.0", + "phpunit/phpunit": "^10.1.2", + "psalm/plugin-phpunit": "^0.18.4", + "vimeo/psalm": "^5.11" + }, + "type": "library", + "extra": { + "laminas": { + "config-provider": "Laminas\\HttpHandlerRunner\\ConfigProvider" + } + }, + "autoload": { + "psr-4": { + "Laminas\\HttpHandlerRunner\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "Execute PSR-15 RequestHandlerInterface instances and emit responses they generate.", + "homepage": "https://laminas.dev", + "keywords": [ + "components", + "laminas", + "mezzio", + "psr-15", + "psr-7" + ], + "support": { + "chat": "https://laminas.dev/chat", + "docs": "https://docs.laminas.dev/laminas-httphandlerrunner/", + "forum": "https://discourse.laminas.dev", + "issues": "https://github.com/laminas/laminas-httphandlerrunner/issues", + "rss": "https://github.com/laminas/laminas-httphandlerrunner/releases.atom", + "source": "https://github.com/laminas/laminas-httphandlerrunner" + }, + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2023-07-25T20:20:46+00:00" + }, + { + "name": "league/container", + "version": "4.2.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/container.git", + "reference": "375d13cb828649599ef5d48a339c4af7a26cd0ab" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/container/zipball/375d13cb828649599ef5d48a339c4af7a26cd0ab", + "reference": "375d13cb828649599ef5d48a339c4af7a26cd0ab", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0", + "psr/container": "^1.1 || ^2.0" + }, + "provide": { + "psr/container-implementation": "^1.0" + }, + "replace": { + "orno/di": "~2.0" + }, + "require-dev": { + "nette/php-generator": "^3.4", + "nikic/php-parser": "^4.10", + "phpstan/phpstan": "^0.12.47", + "phpunit/phpunit": "^8.5.17", + "roave/security-advisories": "dev-latest", + "scrutinizer/ocular": "^1.8", + "squizlabs/php_codesniffer": "^3.6" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.x-dev", + "dev-4.x": "4.x-dev", + "dev-3.x": "3.x-dev", + "dev-2.x": "2.x-dev", + "dev-1.x": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Container\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Phil Bennett", + "email": "mail@philbennett.co.uk", + "role": "Developer" + } + ], + "description": "A fast and intuitive dependency injection container.", + "homepage": "https://github.com/thephpleague/container", + "keywords": [ + "container", + "dependency", + "di", + "injection", + "league", + "provider", + "service" + ], + "support": { + "issues": "https://github.com/thephpleague/container/issues", + "source": "https://github.com/thephpleague/container/tree/4.2.0" + }, + "funding": [ + { + "url": "https://github.com/philipobenito", + "type": "github" + } + ], + "time": "2021-11-16T10:29:06+00:00" + }, + { + "name": "mobiledetect/mobiledetectlib", + "version": "2.8.41", + "source": { + "type": "git", + "url": "https://github.com/serbanghita/Mobile-Detect.git", + "reference": "fc9cccd4d3706d5a7537b562b59cc18f9e4c0cb1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/serbanghita/Mobile-Detect/zipball/fc9cccd4d3706d5a7537b562b59cc18f9e4c0cb1", + "reference": "fc9cccd4d3706d5a7537b562b59cc18f9e4c0cb1", + "shasum": "" + }, + "require": { + "php": ">=5.0.0" + }, + "require-dev": { + "phpunit/phpunit": "~4.8.35||~5.7" + }, + "type": "library", + "autoload": { + "psr-0": { + "Detection": "namespaced/" + }, + "classmap": [ + "Mobile_Detect.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Serban Ghita", + "email": "serbanghita@gmail.com", + "homepage": "http://mobiledetect.net", + "role": "Developer" + } + ], + "description": "Mobile_Detect is a lightweight PHP class for detecting mobile devices. It uses the User-Agent string combined with specific HTTP headers to detect the mobile environment.", + "homepage": "https://github.com/serbanghita/Mobile-Detect", + "keywords": [ + "detect mobile devices", + "mobile", + "mobile detect", + "mobile detector", + "php mobile detect" + ], + "support": { + "issues": "https://github.com/serbanghita/Mobile-Detect/issues", + "source": "https://github.com/serbanghita/Mobile-Detect/tree/2.8.41" + }, + "time": "2022-11-08T18:31:26+00:00" + }, + { + "name": "psr/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/3.0.0" + }, + "time": "2021-02-03T23:26:27+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "0955afe48220520692d2d09f7ab7e0f93ffd6a31" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/0955afe48220520692d2d09f7ab7e0f93ffd6a31", + "reference": "0955afe48220520692d2d09f7ab7e0f93ffd6a31", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client/tree/1.0.2" + }, + "time": "2023-04-10T20:12:12+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "e616d01114759c4c489f93b099585439f795fe35" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/e616d01114759c4c489f93b099585439f795fe35", + "reference": "e616d01114759c4c489f93b099585439f795fe35", + "shasum": "" + }, + "require": { + "php": ">=7.0.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory/tree/1.0.2" + }, + "time": "2023-04-10T20:10:41+00:00" + }, + { + "name": "psr/http-message", + "version": "1.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/cb6ce4845ce34a8ad9e68117c10ee90a29919eba", + "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/1.1" + }, + "time": "2023-04-04T09:50:52+00:00" + }, + { + "name": "psr/http-server-handler", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-handler.git", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/84c4fb66179be4caaf8e97bd239203245302e7d4", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side request handler", + "keywords": [ + "handler", + "http", + "http-interop", + "psr", + "psr-15", + "psr-7", + "request", + "response", + "server" + ], + "support": { + "source": "https://github.com/php-fig/http-server-handler/tree/1.0.2" + }, + "time": "2023-04-10T20:06:20+00:00" + }, + { + "name": "psr/http-server-middleware", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-middleware.git", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-middleware/zipball/c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0", + "psr/http-server-handler": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side middleware", + "keywords": [ + "http", + "http-interop", + "middleware", + "psr", + "psr-15", + "psr-7", + "request", + "response" + ], + "support": { + "issues": "https://github.com/php-fig/http-server-middleware/issues", + "source": "https://github.com/php-fig/http-server-middleware/tree/1.0.2" + }, + "time": "2023-04-11T06:14:47+00:00" + }, + { + "name": "psr/log", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "ef29f6d262798707a9edd554e2b82517ef3a9376" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/ef29f6d262798707a9edd554e2b82517ef3a9376", + "reference": "ef29f6d262798707a9edd554e2b82517ef3a9376", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/2.0.0" + }, + "time": "2021-07-14T16:41:46+00:00" + }, + { + "name": "psr/simple-cache", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "8707bf3cea6f710bf6ef05491234e3ab06f6432a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/8707bf3cea6f710bf6ef05491234e3ab06f6432a", + "reference": "8707bf3cea6f710bf6ef05491234e3ab06f6432a", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/2.0.0" + }, + "time": "2021-10-29T13:22:09+00:00" + }, + { + "name": "robmorgan/phinx", + "version": "0.13.4", + "source": { + "type": "git", + "url": "https://github.com/cakephp/phinx.git", + "reference": "18e06e4a2b18947663438afd2f467e17c62e867d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/cakephp/phinx/zipball/18e06e4a2b18947663438afd2f467e17c62e867d", + "reference": "18e06e4a2b18947663438afd2f467e17c62e867d", + "shasum": "" + }, + "require": { + "cakephp/database": "^4.0", + "php": ">=7.2", + "psr/container": "^1.0 || ^2.0", + "symfony/config": "^3.4|^4.0|^5.0|^6.0", + "symfony/console": "^3.4|^4.0|^5.0|^6.0" + }, + "require-dev": { + "cakephp/cakephp-codesniffer": "^4.0", + "ext-json": "*", + "ext-pdo": "*", + "phpunit/phpunit": "^8.5|^9.3", + "sebastian/comparator": ">=1.2.3", + "symfony/yaml": "^3.4|^4.0|^5.0" + }, + "suggest": { + "ext-json": "Install if using JSON configuration format", + "ext-pdo": "PDO extension is needed", + "symfony/yaml": "Install if using YAML configuration format" + }, + "bin": [ + "bin/phinx" + ], + "type": "library", + "autoload": { + "psr-4": { + "Phinx\\": "src/Phinx/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Rob Morgan", + "email": "robbym@gmail.com", + "homepage": "https://robmorgan.id.au", + "role": "Lead Developer" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com", + "homepage": "https://shadowhand.me", + "role": "Developer" + }, + { + "name": "Richard Quadling", + "email": "rquadling@gmail.com", + "role": "Developer" + }, + { + "name": "CakePHP Community", + "homepage": "https://github.com/cakephp/phinx/graphs/contributors", + "role": "Developer" + } + ], + "description": "Phinx makes it ridiculously easy to manage the database migrations for your PHP app.", + "homepage": "https://phinx.org", + "keywords": [ + "database", + "database migrations", + "db", + "migrations", + "phinx" + ], + "support": { + "issues": "https://github.com/cakephp/phinx/issues", + "source": "https://github.com/cakephp/phinx/tree/0.13.4" + }, + "time": "2023-01-07T00:42:55+00:00" + }, + { + "name": "symfony/config", + "version": "v6.3.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/config.git", + "reference": "b47ca238b03e7b0d7880ffd1cf06e8d637ca1467" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/config/zipball/b47ca238b03e7b0d7880ffd1cf06e8d637ca1467", + "reference": "b47ca238b03e7b0d7880ffd1cf06e8d637ca1467", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/filesystem": "^5.4|^6.0", + "symfony/polyfill-ctype": "~1.8" + }, + "conflict": { + "symfony/finder": "<5.4", + "symfony/service-contracts": "<2.5" + }, + "require-dev": { + "symfony/event-dispatcher": "^5.4|^6.0", + "symfony/finder": "^5.4|^6.0", + "symfony/messenger": "^5.4|^6.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^5.4|^6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Config\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/config/tree/v6.3.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-07-19T20:22:16+00:00" + }, + { + "name": "symfony/console", + "version": "v6.3.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "aa5d64ad3f63f2e48964fc81ee45cb318a723898" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/aa5d64ad3f63f2e48964fc81ee45cb318a723898", + "reference": "aa5d64ad3f63f2e48964fc81ee45cb318a723898", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^5.4|^6.0" + }, + "conflict": { + "symfony/dependency-injection": "<5.4", + "symfony/dotenv": "<5.4", + "symfony/event-dispatcher": "<5.4", + "symfony/lock": "<5.4", + "symfony/process": "<5.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^5.4|^6.0", + "symfony/dependency-injection": "^5.4|^6.0", + "symfony/event-dispatcher": "^5.4|^6.0", + "symfony/lock": "^5.4|^6.0", + "symfony/process": "^5.4|^6.0", + "symfony/var-dumper": "^5.4|^6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v6.3.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-07-19T20:17:28+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "7c3aff79d10325257a001fcf92d991f24fc967cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/7c3aff79d10325257a001fcf92d991f24fc967cf", + "reference": "7c3aff79d10325257a001fcf92d991f24fc967cf", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.4-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-05-23T14:45:45+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v6.3.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "edd36776956f2a6fcf577edb5b05eb0e3bdc52ae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/edd36776956f2a6fcf577edb5b05eb0e3bdc52ae", + "reference": "edd36776956f2a6fcf577edb5b05eb0e3bdc52ae", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v6.3.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-06-01T08:30:39+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.27.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "5bbc823adecdae860bb64756d639ecfec17b050a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/5bbc823adecdae860bb64756d639ecfec17b050a", + "reference": "5bbc823adecdae860bb64756d639ecfec17b050a", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.27-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.27.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-11-03T14:55:06+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.27.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "511a08c03c1960e08a883f4cffcacd219b758354" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/511a08c03c1960e08a883f4cffcacd219b758354", + "reference": "511a08c03c1960e08a883f4cffcacd219b758354", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.27-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.27.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-11-03T14:55:06+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.27.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "19bd1e4fcd5b91116f14d8533c57831ed00571b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/19bd1e4fcd5b91116f14d8533c57831ed00571b6", + "reference": "19bd1e4fcd5b91116f14d8533c57831ed00571b6", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.27-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.27.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-11-03T14:55:06+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.27.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "8ad114f6b39e2c98a8b0e3bd907732c207c2b534" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/8ad114f6b39e2c98a8b0e3bd907732c207c2b534", + "reference": "8ad114f6b39e2c98a8b0e3bd907732c207c2b534", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.27-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.27.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-11-03T14:55:06+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "40da9cc13ec349d9e4966ce18b5fbcd724ab10a4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/40da9cc13ec349d9e4966ce18b5fbcd724ab10a4", + "reference": "40da9cc13ec349d9e4966ce18b5fbcd724ab10a4", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^2.0" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.4-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-05-23T14:45:45+00:00" + }, + { + "name": "symfony/string", + "version": "v6.3.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "53d1a83225002635bca3482fcbf963001313fb68" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/53d1a83225002635bca3482fcbf963001313fb68", + "reference": "53d1a83225002635bca3482fcbf963001313fb68", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.0", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/error-handler": "^5.4|^6.0", + "symfony/http-client": "^5.4|^6.0", + "symfony/intl": "^6.2", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^5.4|^6.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v6.3.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-07-05T08:41:27+00:00" + } + ], + "packages-dev": [ + { + "name": "brick/varexporter", + "version": "0.3.8", + "source": { + "type": "git", + "url": "https://github.com/brick/varexporter.git", + "reference": "b5853edea6204ff8fa10633c3a4cccc4058410ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/brick/varexporter/zipball/b5853edea6204ff8fa10633c3a4cccc4058410ed", + "reference": "b5853edea6204ff8fa10633c3a4cccc4058410ed", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.0", + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.2", + "phpunit/phpunit": "^8.5 || ^9.0", + "vimeo/psalm": "4.23.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Brick\\VarExporter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A powerful alternative to var_export(), which can export closures and objects without __set_state()", + "keywords": [ + "var_export" + ], + "support": { + "issues": "https://github.com/brick/varexporter/issues", + "source": "https://github.com/brick/varexporter/tree/0.3.8" + }, + "funding": [ + { + "url": "https://github.com/BenMorel", + "type": "github" + } + ], + "time": "2023-01-21T23:05:38+00:00" + }, + { + "name": "cakephp/bake", + "version": "2.9.3", + "source": { + "type": "git", + "url": "https://github.com/cakephp/bake.git", + "reference": "a9b02fb6a5f96e8fb9887be55cccea501468907b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/cakephp/bake/zipball/a9b02fb6a5f96e8fb9887be55cccea501468907b", + "reference": "a9b02fb6a5f96e8fb9887be55cccea501468907b", + "shasum": "" + }, + "require": { + "brick/varexporter": "^0.3.5", + "cakephp/cakephp": "^4.3.0", + "cakephp/twig-view": "^1.0.2", + "nikic/php-parser": "^4.13.2", + "php": ">=7.2" + }, + "require-dev": { + "cakephp/cakephp-codesniffer": "^4.0", + "cakephp/debug_kit": "^4.1", + "cakephp/plugin-installer": "^1.3", + "phpunit/phpunit": "^8.5 || ^9.3" + }, + "type": "cakephp-plugin", + "autoload": { + "psr-4": { + "Bake\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "CakePHP Community", + "homepage": "https://github.com/cakephp/bake/graphs/contributors" + } + ], + "description": "Bake plugin for CakePHP", + "homepage": "https://github.com/cakephp/bake", + "keywords": [ + "bake", + "cakephp" + ], + "support": { + "forum": "https://stackoverflow.com/tags/cakephp", + "irc": "irc://irc.freenode.org/cakephp", + "issues": "https://github.com/cakephp/bake/issues", + "source": "https://github.com/cakephp/bake" + }, + "time": "2023-03-18T19:26:16+00:00" + }, + { + "name": "cakephp/cakephp-codesniffer", + "version": "4.7.0", + "source": { + "type": "git", + "url": "https://github.com/cakephp/cakephp-codesniffer.git", + "reference": "24fa2321d54e5251ac2f59dd92dd2066f0b0bdae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/cakephp/cakephp-codesniffer/zipball/24fa2321d54e5251ac2f59dd92dd2066f0b0bdae", + "reference": "24fa2321d54e5251ac2f59dd92dd2066f0b0bdae", + "shasum": "" + }, + "require": { + "php": ">=7.2.0", + "slevomat/coding-standard": "^7.0 || ^8.0", + "squizlabs/php_codesniffer": "^3.6" + }, + "require-dev": { + "phpunit/phpunit": "^7.1" + }, + "type": "phpcodesniffer-standard", + "autoload": { + "psr-4": { + "CakePHP\\": "CakePHP/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "CakePHP Community", + "homepage": "https://github.com/cakephp/cakephp-codesniffer/graphs/contributors" + } + ], + "description": "CakePHP CodeSniffer Standards", + "homepage": "https://cakephp.org", + "keywords": [ + "codesniffer", + "framework" + ], + "support": { + "forum": "https://stackoverflow.com/tags/cakephp", + "irc": "irc://irc.freenode.org/cakephp", + "issues": "https://github.com/cakephp/cakephp-codesniffer/issues", + "source": "https://github.com/cakephp/cakephp-codesniffer" + }, + "time": "2023-04-10T06:35:04+00:00" + }, + { + "name": "cakephp/debug_kit", + "version": "4.9.4", + "source": { + "type": "git", + "url": "https://github.com/cakephp/debug_kit.git", + "reference": "663491edec4a6b9111f1cf4733ebd471450df71e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/cakephp/debug_kit/zipball/663491edec4a6b9111f1cf4733ebd471450df71e", + "reference": "663491edec4a6b9111f1cf4733ebd471450df71e", + "shasum": "" + }, + "require": { + "cakephp/cakephp": "^4.4.0", + "cakephp/chronos": "^2.0", + "composer/composer": "^1.3 | ^2.0", + "jdorn/sql-formatter": "^1.2", + "php": ">=7.4" + }, + "require-dev": { + "cakephp/authorization": "^2.0", + "cakephp/cakephp-codesniffer": "^4.0", + "phpunit/phpunit": "~8.5.0 | ^9.3" + }, + "suggest": { + "ext-pdo_sqlite": "DebugKit needs to store panel data in a database. SQLite is simple and easy to use." + }, + "type": "cakephp-plugin", + "autoload": { + "psr-4": { + "DebugKit\\": "src/", + "DebugKit\\Test\\Fixture\\": "tests/Fixture/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Story", + "homepage": "https://mark-story.com", + "role": "Author" + }, + { + "name": "CakePHP Community", + "homepage": "https://github.com/cakephp/debug_kit/graphs/contributors" + } + ], + "description": "CakePHP Debug Kit", + "homepage": "https://github.com/cakephp/debug_kit", + "keywords": [ + "cakephp", + "debug", + "kit" + ], + "support": { + "forum": "https://stackoverflow.com/tags/cakephp", + "irc": "irc://irc.freenode.org/cakephp", + "issues": "https://github.com/cakephp/debug_kit/issues", + "source": "https://github.com/cakephp/debug_kit" + }, + "time": "2023-07-05T16:04:04+00:00" + }, + { + "name": "cakephp/twig-view", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/cakephp/twig-view.git", + "reference": "14df50360b809a171d0688020fbdfe513763f89b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/cakephp/twig-view/zipball/14df50360b809a171d0688020fbdfe513763f89b", + "reference": "14df50360b809a171d0688020fbdfe513763f89b", + "shasum": "" + }, + "require": { + "cakephp/cakephp": "^4.0", + "jasny/twig-extensions": "^1.3", + "php": ">=7.2", + "twig/markdown-extra": "^3.0", + "twig/twig": "^3.0" + }, + "conflict": { + "wyrihaximus/twig-view": "*" + }, + "require-dev": { + "cakephp/cakephp-codesniffer": "^4.0", + "cakephp/debug_kit": "^4.0", + "cakephp/plugin-installer": "^1.3", + "michelf/php-markdown": "^1.9", + "mikey179/vfsstream": "^1.6", + "phpunit/phpunit": "^8.5 || ^9.3" + }, + "type": "cakephp-plugin", + "autoload": { + "psr-4": { + "Cake\\TwigView\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "CakePHP Community", + "homepage": "https://github.com/cakephp/cakephp/graphs/contributors" + } + ], + "description": "Twig powered View for CakePHP", + "keywords": [ + "cakephp", + "template", + "twig", + "view" + ], + "support": { + "forum": "https://stackoverflow.com/tags/cakephp", + "irc": "irc://irc.freenode.org/cakephp", + "issues": "https://github.com/cakephp/twig-view/issues", + "source": "https://github.com/cakephp/twig-view" + }, + "time": "2021-09-17T14:07:52+00:00" + }, + { + "name": "composer/class-map-generator", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/composer/class-map-generator.git", + "reference": "953cc4ea32e0c31f2185549c7d216d7921f03da9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/class-map-generator/zipball/953cc4ea32e0c31f2185549c7d216d7921f03da9", + "reference": "953cc4ea32e0c31f2185549c7d216d7921f03da9", + "shasum": "" + }, + "require": { + "composer/pcre": "^2.1 || ^3.1", + "php": "^7.2 || ^8.0", + "symfony/finder": "^4.4 || ^5.3 || ^6 || ^7" + }, + "require-dev": { + "phpstan/phpstan": "^1.6", + "phpstan/phpstan-deprecation-rules": "^1", + "phpstan/phpstan-phpunit": "^1", + "phpstan/phpstan-strict-rules": "^1.1", + "symfony/filesystem": "^5.4 || ^6", + "symfony/phpunit-bridge": "^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\ClassMapGenerator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Utilities to scan PHP code and generate class maps.", + "keywords": [ + "classmap" + ], + "support": { + "issues": "https://github.com/composer/class-map-generator/issues", + "source": "https://github.com/composer/class-map-generator/tree/1.1.0" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2023-06-30T13:58:57+00:00" + }, + { + "name": "composer/composer", + "version": "2.5.8", + "source": { + "type": "git", + "url": "https://github.com/composer/composer.git", + "reference": "4c516146167d1392c8b9b269bb7c24115d262164" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/composer/zipball/4c516146167d1392c8b9b269bb7c24115d262164", + "reference": "4c516146167d1392c8b9b269bb7c24115d262164", + "shasum": "" + }, + "require": { + "composer/ca-bundle": "^1.0", + "composer/class-map-generator": "^1.0", + "composer/metadata-minifier": "^1.0", + "composer/pcre": "^2.1 || ^3.1", + "composer/semver": "^3.0", + "composer/spdx-licenses": "^1.5.7", + "composer/xdebug-handler": "^2.0.2 || ^3.0.3", + "justinrainbow/json-schema": "^5.2.11", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "react/promise": "^2.8", + "seld/jsonlint": "^1.4", + "seld/phar-utils": "^1.2", + "seld/signal-handler": "^2.0", + "symfony/console": "^5.4.11 || ^6.0.11", + "symfony/filesystem": "^5.4 || ^6.0", + "symfony/finder": "^5.4 || ^6.0", + "symfony/polyfill-php73": "^1.24", + "symfony/polyfill-php80": "^1.24", + "symfony/polyfill-php81": "^1.24", + "symfony/process": "^5.4 || ^6.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.9.3", + "phpstan/phpstan-deprecation-rules": "^1", + "phpstan/phpstan-phpunit": "^1.0", + "phpstan/phpstan-strict-rules": "^1", + "phpstan/phpstan-symfony": "^1.2.10", + "symfony/phpunit-bridge": "^6.0" + }, + "suggest": { + "ext-openssl": "Enabling the openssl extension allows you to access https URLs for repositories and packages", + "ext-zip": "Enabling the zip extension allows you to unzip archives", + "ext-zlib": "Allow gzip compression of HTTP requests" + }, + "bin": [ + "bin/composer" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.5-dev" + }, + "phpstan": { + "includes": [ + "phpstan/rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "Composer\\": "src/Composer" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "https://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Composer helps you declare, manage and install dependencies of PHP projects. It ensures you have the right stack everywhere.", + "homepage": "https://getcomposer.org/", + "keywords": [ + "autoload", + "dependency", + "package" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/composer/issues", + "source": "https://github.com/composer/composer/tree/2.5.8" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2023-06-09T15:13:21+00:00" + }, + { + "name": "composer/metadata-minifier", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/composer/metadata-minifier.git", + "reference": "c549d23829536f0d0e984aaabbf02af91f443207" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/metadata-minifier/zipball/c549d23829536f0d0e984aaabbf02af91f443207", + "reference": "c549d23829536f0d0e984aaabbf02af91f443207", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "composer/composer": "^2", + "phpstan/phpstan": "^0.12.55", + "symfony/phpunit-bridge": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\MetadataMinifier\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "Small utility library that handles metadata minification and expansion.", + "keywords": [ + "composer", + "compression" + ], + "support": { + "issues": "https://github.com/composer/metadata-minifier/issues", + "source": "https://github.com/composer/metadata-minifier/tree/1.0.0" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2021-04-07T13:37:33+00:00" + }, + { + "name": "composer/pcre", + "version": "3.1.0", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "4bff79ddd77851fe3cdd11616ed3f92841ba5bd2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/4bff79ddd77851fe3cdd11616ed3f92841ba5bd2", + "reference": "4bff79ddd77851fe3cdd11616ed3f92841ba5bd2", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.3", + "phpstan/phpstan-strict-rules": "^1.1", + "symfony/phpunit-bridge": "^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.1.0" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2022-11-17T09:50:14+00:00" + }, + { + "name": "composer/semver", + "version": "3.3.2", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "3953f23262f2bff1919fc82183ad9acb13ff62c9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/3953f23262f2bff1919fc82183ad9acb13ff62c9", + "reference": "3953f23262f2bff1919fc82183ad9acb13ff62c9", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.4", + "symfony/phpunit-bridge": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "irc://irc.freenode.org/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.3.2" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2022-04-01T19:23:25+00:00" + }, + { + "name": "composer/spdx-licenses", + "version": "1.5.7", + "source": { + "type": "git", + "url": "https://github.com/composer/spdx-licenses.git", + "reference": "c848241796da2abf65837d51dce1fae55a960149" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/spdx-licenses/zipball/c848241796da2abf65837d51dce1fae55a960149", + "reference": "c848241796da2abf65837d51dce1fae55a960149", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.55", + "symfony/phpunit-bridge": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Spdx\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "SPDX licenses list and validation library.", + "keywords": [ + "license", + "spdx", + "validator" + ], + "support": { + "irc": "irc://irc.freenode.org/composer", + "issues": "https://github.com/composer/spdx-licenses/issues", + "source": "https://github.com/composer/spdx-licenses/tree/1.5.7" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2022-05-23T07:37:50+00:00" + }, + { + "name": "composer/xdebug-handler", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "ced299686f41dce890debac69273b47ffe98a40c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/ced299686f41dce890debac69273b47ffe98a40c", + "reference": "ced299686f41dce890debac69273b47ffe98a40c", + "shasum": "" + }, + "require": { + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" + }, + "require-dev": { + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "symfony/phpunit-bridge": "^6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Composer\\XdebugHandler\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" + } + ], + "description": "Restarts a process without Xdebug.", + "keywords": [ + "Xdebug", + "performance" + ], + "support": { + "irc": "irc://irc.freenode.org/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.3" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2022-02-25T21:32:43+00:00" + }, + { + "name": "dealerdirect/phpcodesniffer-composer-installer", + "version": "v1.0.0", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/composer-installer.git", + "reference": "4be43904336affa5c2f70744a348312336afd0da" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/4be43904336affa5c2f70744a348312336afd0da", + "reference": "4be43904336affa5c2f70744a348312336afd0da", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0 || ^2.0", + "php": ">=5.4", + "squizlabs/php_codesniffer": "^2.0 || ^3.1.0 || ^4.0" + }, + "require-dev": { + "composer/composer": "*", + "ext-json": "*", + "ext-zip": "*", + "php-parallel-lint/php-parallel-lint": "^1.3.1", + "phpcompatibility/php-compatibility": "^9.0", + "yoast/phpunit-polyfills": "^1.0" + }, + "type": "composer-plugin", + "extra": { + "class": "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Plugin" + }, + "autoload": { + "psr-4": { + "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Franck Nijhof", + "email": "franck.nijhof@dealerdirect.com", + "homepage": "http://www.frenck.nl", + "role": "Developer / IT Manager" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/composer-installer/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer Standards Composer Installer Plugin", + "homepage": "http://www.dealerdirect.com", + "keywords": [ + "PHPCodeSniffer", + "PHP_CodeSniffer", + "code quality", + "codesniffer", + "composer", + "installer", + "phpcbf", + "phpcs", + "plugin", + "qa", + "quality", + "standard", + "standards", + "style guide", + "stylecheck", + "tests" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/composer-installer/issues", + "source": "https://github.com/PHPCSStandards/composer-installer" + }, + "time": "2023-01-05T11:28:13+00:00" + }, + { + "name": "doctrine/instantiator", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/instantiator.git", + "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", + "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^11", + "ext-pdo": "*", + "ext-phar": "*", + "phpbench/phpbench": "^1.2", + "phpstan/phpstan": "^1.9.4", + "phpstan/phpstan-phpunit": "^1.3", + "phpunit/phpunit": "^9.5.27", + "vimeo/psalm": "^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com", + "homepage": "https://ocramius.github.io/" + } + ], + "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", + "homepage": "https://www.doctrine-project.org/projects/instantiator.html", + "keywords": [ + "constructor", + "instantiate" + ], + "support": { + "issues": "https://github.com/doctrine/instantiator/issues", + "source": "https://github.com/doctrine/instantiator/tree/2.0.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator", + "type": "tidelift" + } + ], + "time": "2022-12-30T00:23:10+00:00" + }, + { + "name": "jasny/twig-extensions", + "version": "v1.3.0", + "source": { + "type": "git", + "url": "https://github.com/jasny/twig-extensions.git", + "reference": "a694eb02f6fc14ff8e2fceb8b80882c0c926102b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jasny/twig-extensions/zipball/a694eb02f6fc14ff8e2fceb8b80882c0c926102b", + "reference": "a694eb02f6fc14ff8e2fceb8b80882c0c926102b", + "shasum": "" + }, + "require": { + "php": ">=7.0.0", + "twig/twig": "^2.0 | ^3.0" + }, + "require-dev": { + "ext-intl": "*", + "ext-pcre": "*", + "jasny/php-code-quality": "^2.5", + "php": ">=7.2.0" + }, + "suggest": { + "ext-intl": "Required for the use of the LocalDate Twig extension", + "ext-pcre": "Required for the use of the PCRE Twig extension" + }, + "type": "library", + "autoload": { + "psr-4": { + "Jasny\\Twig\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Arnold Daniels", + "email": "arnold@jasny.net", + "homepage": "http://www.jasny.net" + } + ], + "description": "A set of useful Twig filters", + "homepage": "http://github.com/jasny/twig-extensions#README", + "keywords": [ + "PCRE", + "array", + "date", + "datetime", + "preg", + "regex", + "templating", + "text", + "time" + ], + "support": { + "issues": "https://github.com/jasny/twig-extensions/issues", + "source": "https://github.com/jasny/twig-extensions" + }, + "time": "2019-12-10T16:04:23+00:00" + }, + { + "name": "jdorn/sql-formatter", + "version": "v1.2.17", + "source": { + "type": "git", + "url": "https://github.com/jdorn/sql-formatter.git", + "reference": "64990d96e0959dff8e059dfcdc1af130728d92bc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jdorn/sql-formatter/zipball/64990d96e0959dff8e059dfcdc1af130728d92bc", + "reference": "64990d96e0959dff8e059dfcdc1af130728d92bc", + "shasum": "" + }, + "require": { + "php": ">=5.2.4" + }, + "require-dev": { + "phpunit/phpunit": "3.7.*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3.x-dev" + } + }, + "autoload": { + "classmap": [ + "lib" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jeremy Dorn", + "email": "jeremy@jeremydorn.com", + "homepage": "http://jeremydorn.com/" + } + ], + "description": "a PHP SQL highlighting library", + "homepage": "https://github.com/jdorn/sql-formatter/", + "keywords": [ + "highlight", + "sql" + ], + "support": { + "issues": "https://github.com/jdorn/sql-formatter/issues", + "source": "https://github.com/jdorn/sql-formatter/tree/v1.2.17" + }, + "time": "2014-01-12T16:20:24+00:00" + }, + { + "name": "josegonzalez/dotenv", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/josegonzalez/php-dotenv.git", + "reference": "f19174d9d7213a6c20e8e5e268aa7dd042d821ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/josegonzalez/php-dotenv/zipball/f19174d9d7213a6c20e8e5e268aa7dd042d821ca", + "reference": "f19174d9d7213a6c20e8e5e268aa7dd042d821ca", + "shasum": "" + }, + "require": { + "m1/env": "2.*", + "php": ">=5.5.0" + }, + "require-dev": { + "php-mock/php-mock-phpunit": "^1.1", + "satooshi/php-coveralls": "1.*", + "squizlabs/php_codesniffer": "2.*" + }, + "type": "library", + "autoload": { + "psr-0": { + "josegonzalez\\Dotenv": [ + "src", + "tests" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jose Diaz-Gonzalez", + "email": "dotenv@josegonzalez.com", + "homepage": "http://josediazgonzalez.com", + "role": "Maintainer" + } + ], + "description": "dotenv file parsing for PHP", + "homepage": "https://github.com/josegonzalez/php-dotenv", + "keywords": [ + "configuration", + "dotenv", + "php" + ], + "support": { + "issues": "https://github.com/josegonzalez/php-dotenv/issues", + "source": "https://github.com/josegonzalez/php-dotenv/tree/master" + }, + "time": "2017-09-19T15:49:58+00:00" + }, + { + "name": "justinrainbow/json-schema", + "version": "5.2.12", + "source": { + "type": "git", + "url": "https://github.com/justinrainbow/json-schema.git", + "reference": "ad87d5a5ca981228e0e205c2bc7dfb8e24559b60" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/justinrainbow/json-schema/zipball/ad87d5a5ca981228e0e205c2bc7dfb8e24559b60", + "reference": "ad87d5a5ca981228e0e205c2bc7dfb8e24559b60", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "~2.2.20||~2.15.1", + "json-schema/json-schema-test-suite": "1.2.0", + "phpunit/phpunit": "^4.8.35" + }, + "bin": [ + "bin/validate-json" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "JsonSchema\\": "src/JsonSchema/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bruno Prieto Reis", + "email": "bruno.p.reis@gmail.com" + }, + { + "name": "Justin Rainbow", + "email": "justin.rainbow@gmail.com" + }, + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + }, + { + "name": "Robert Schönthal", + "email": "seroscho@googlemail.com" + } + ], + "description": "A library to validate a json schema.", + "homepage": "https://github.com/justinrainbow/json-schema", + "keywords": [ + "json", + "schema" + ], + "support": { + "issues": "https://github.com/justinrainbow/json-schema/issues", + "source": "https://github.com/justinrainbow/json-schema/tree/5.2.12" + }, + "time": "2022-04-13T08:02:27+00:00" + }, + { + "name": "m1/env", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/m1/Env.git", + "reference": "5c296e3e13450a207e12b343f3af1d7ab569f6f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/m1/Env/zipball/5c296e3e13450a207e12b343f3af1d7ab569f6f3", + "reference": "5c296e3e13450a207e12b343f3af1d7ab569f6f3", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "4.*", + "scrutinizer/ocular": "~1.1", + "squizlabs/php_codesniffer": "^2.3" + }, + "suggest": { + "josegonzalez/dotenv": "For loading of .env", + "m1/vars": "For loading of configs" + }, + "type": "library", + "autoload": { + "psr-4": { + "M1\\Env\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Miles Croxford", + "email": "hello@milescroxford.com", + "homepage": "http://milescroxford.com", + "role": "Developer" + } + ], + "description": "Env is a lightweight library bringing .env file parser compatibility to PHP. In short - it enables you to read .env files with PHP.", + "homepage": "https://github.com/m1/Env", + "keywords": [ + ".env", + "config", + "dotenv", + "env", + "loader", + "m1", + "parser", + "support" + ], + "support": { + "issues": "https://github.com/m1/Env/issues", + "source": "https://github.com/m1/Env/tree/2.2.0" + }, + "time": "2020-02-19T09:02:13+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.11.1", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "7284c22080590fb39f2ffa3e9057f10a4ddd0e0c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/7284c22080590fb39f2ffa3e9057f10a4ddd0e0c", + "reference": "7284c22080590fb39f2ffa3e9057f10a4ddd0e0c", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3,<3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.11.1" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2023-03-08T13:26:56+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v4.17.1", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "a6303e50c90c355c7eeee2c4a8b27fe8dc8fef1d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/a6303e50c90c355c7eeee2c4a8b27fe8dc8fef1d", + "reference": "a6303e50c90c355c7eeee2c4a8b27fe8dc8fef1d", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": ">=7.0" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^6.5 || ^7.0 || ^8.0 || ^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v4.17.1" + }, + "time": "2023-08-13T19:53:39+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "97803eca37d319dfa7826cc2437fc020857acb53" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/97803eca37d319dfa7826cc2437fc020857acb53", + "reference": "97803eca37d319dfa7826cc2437fc020857acb53", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.3" + }, + "time": "2021-07-20T11:28:43+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpstan/phpdoc-parser", + "version": "1.23.1", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "846ae76eef31c6d7790fac9bc399ecee45160b26" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/846ae76eef31c6d7790fac9bc399ecee45160b26", + "reference": "846ae76eef31c6d7790fac9bc399ecee45160b26", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^4.15", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^1.5", + "phpstan/phpstan-phpunit": "^1.1", + "phpstan/phpstan-strict-rules": "^1.0", + "phpunit/phpunit": "^9.5", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/1.23.1" + }, + "time": "2023-08-03T16:32:59+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "9.2.27", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "b0a88255cb70d52653d80c890bd7f38740ea50d1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/b0a88255cb70d52653d80c890bd7f38740ea50d1", + "reference": "b0a88255cb70d52653d80c890bd7f38740ea50d1", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^4.15", + "php": ">=7.3", + "phpunit/php-file-iterator": "^3.0.3", + "phpunit/php-text-template": "^2.0.2", + "sebastian/code-unit-reverse-lookup": "^2.0.2", + "sebastian/complexity": "^2.0", + "sebastian/environment": "^5.1.2", + "sebastian/lines-of-code": "^1.0.3", + "sebastian/version": "^3.0.1", + "theseer/tokenizer": "^1.2.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "9.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.27" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-07-26T13:44:30+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "3.0.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", + "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2021-12-02T12:48:52+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "3.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67", + "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/3.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T05:58:55+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T05:33:50+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "5.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "source": "https://github.com/sebastianbergmann/php-timer/tree/5.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:16:10+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "9.6.10", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "a6d351645c3fe5a30f5e86be6577d946af65a328" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/a6d351645c3fe5a30f5e86be6577d946af65a328", + "reference": "a6d351645c3fe5a30f5e86be6577d946af65a328", + "shasum": "" + }, + "require": { + "doctrine/instantiator": "^1.3.1 || ^2", + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.10.1", + "phar-io/manifest": "^2.0.3", + "phar-io/version": "^3.0.2", + "php": ">=7.3", + "phpunit/php-code-coverage": "^9.2.13", + "phpunit/php-file-iterator": "^3.0.5", + "phpunit/php-invoker": "^3.1.1", + "phpunit/php-text-template": "^2.0.3", + "phpunit/php-timer": "^5.0.2", + "sebastian/cli-parser": "^1.0.1", + "sebastian/code-unit": "^1.0.6", + "sebastian/comparator": "^4.0.8", + "sebastian/diff": "^4.0.3", + "sebastian/environment": "^5.1.3", + "sebastian/exporter": "^4.0.5", + "sebastian/global-state": "^5.0.1", + "sebastian/object-enumerator": "^4.0.3", + "sebastian/resource-operations": "^3.0.3", + "sebastian/type": "^3.2", + "sebastian/version": "^3.0.2" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "9.6-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.10" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "time": "2023-07-10T04:04:23+00:00" + }, + { + "name": "psy/psysh", + "version": "v0.11.20", + "source": { + "type": "git", + "url": "https://github.com/bobthecow/psysh.git", + "reference": "0fa27040553d1d280a67a4393194df5228afea5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/0fa27040553d1d280a67a4393194df5228afea5b", + "reference": "0fa27040553d1d280a67a4393194df5228afea5b", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "nikic/php-parser": "^4.0 || ^3.1", + "php": "^8.0 || ^7.0.8", + "symfony/console": "^6.0 || ^5.0 || ^4.0 || ^3.4", + "symfony/var-dumper": "^6.0 || ^5.0 || ^4.0 || ^3.4" + }, + "conflict": { + "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.2" + }, + "suggest": { + "ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)", + "ext-pdo-sqlite": "The doc command requires SQLite to work.", + "ext-posix": "If you have PCNTL, you'll want the POSIX extension as well.", + "ext-readline": "Enables support for arrow-key history navigation, and showing and manipulating command history." + }, + "bin": [ + "bin/psysh" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "0.11.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Psy\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Justin Hileman", + "email": "justin@justinhileman.info", + "homepage": "http://justinhileman.com" + } + ], + "description": "An interactive shell for modern PHP.", + "homepage": "http://psysh.org", + "keywords": [ + "REPL", + "console", + "interactive", + "shell" + ], + "support": { + "issues": "https://github.com/bobthecow/psysh/issues", + "source": "https://github.com/bobthecow/psysh/tree/v0.11.20" + }, + "time": "2023-07-31T14:32:22+00:00" + }, + { + "name": "react/promise", + "version": "v2.10.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "f913fb8cceba1e6644b7b90c4bfb678ed8a3ef38" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/f913fb8cceba1e6644b7b90c4bfb678ed8a3ef38", + "reference": "f913fb8cceba1e6644b7b90c4bfb678ed8a3ef38", + "shasum": "" + }, + "require": { + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "support": { + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v2.10.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2023-05-02T15:15:43+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "442e7c7e687e42adc03470c7b668bc4b2402c0b2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/442e7c7e687e42adc03470c7b668bc4b2402c0b2", + "reference": "442e7c7e687e42adc03470c7b668bc4b2402c0b2", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T06:08:49+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "1.0.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120", + "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "source": "https://github.com/sebastianbergmann/code-unit/tree/1.0.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:08:54+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/2.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T05:30:19+00:00" + }, + { + "name": "sebastian/comparator", + "version": "4.0.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "fa0f136dd2334583309d32b62544682ee972b51a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/fa0f136dd2334583309d32b62544682ee972b51a", + "reference": "fa0f136dd2334583309d32b62544682ee972b51a", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/diff": "^4.0", + "sebastian/exporter": "^4.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2022-09-14T12:41:17+00:00" + }, + { + "name": "sebastian/complexity", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "739b35e53379900cc9ac327b2147867b8b6efd88" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/739b35e53379900cc9ac327b2147867b8b6efd88", + "reference": "739b35e53379900cc9ac327b2147867b8b6efd88", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.7", + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T15:52:27+00:00" + }, + { + "name": "sebastian/diff", + "version": "4.0.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "74be17022044ebaaecfdf0c5cd504fc9cd5a7131" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/74be17022044ebaaecfdf0c5cd504fc9cd5a7131", + "reference": "74be17022044ebaaecfdf0c5cd504fc9cd5a7131", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "source": "https://github.com/sebastianbergmann/diff/tree/4.0.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-05-07T05:35:17+00:00" + }, + { + "name": "sebastian/environment", + "version": "5.1.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", + "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "http://www.github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "source": "https://github.com/sebastianbergmann/environment/tree/5.1.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:03:51+00:00" + }, + { + "name": "sebastian/exporter", + "version": "4.0.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d", + "reference": "ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "ext-mbstring": "*", + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2022-09-14T06:03:37+00:00" + }, + { + "name": "sebastian/global-state", + "version": "5.0.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "bde739e7565280bda77be70044ac1047bc007e34" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/bde739e7565280bda77be70044ac1047bc007e34", + "reference": "bde739e7565280bda77be70044ac1047bc007e34", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/object-reflector": "^2.0", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-uopz": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "http://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-08-02T09:26:13+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "c1c2e997aa3146983ed888ad08b15470a2e22ecc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/c1c2e997aa3146983ed888ad08b15470a2e22ecc", + "reference": "c1c2e997aa3146983ed888ad08b15470a2e22ecc", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.6", + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-11-28T06:42:11+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "5c9eeac41b290a3712d88851518825ad78f45c71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71", + "reference": "5c9eeac41b290a3712d88851518825ad78f45c71", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/object-reflector": "^2.0", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:12:34+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:14:26+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "4.0.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1", + "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:07:39+00:00" + }, + { + "name": "sebastian/resource-operations", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/resource-operations.git", + "reference": "0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8", + "reference": "0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides a list of PHP built-in functions that operate on resources", + "homepage": "https://www.github.com/sebastianbergmann/resource-operations", + "support": { + "issues": "https://github.com/sebastianbergmann/resource-operations/issues", + "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T06:45:17+00:00" + }, + { + "name": "sebastian/type", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", + "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "source": "https://github.com/sebastianbergmann/type/tree/3.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:13:03+00:00" + }, + { + "name": "sebastian/version", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c6c1022351a901512170118436c764e473f6de8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c", + "reference": "c6c1022351a901512170118436c764e473f6de8c", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "source": "https://github.com/sebastianbergmann/version/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T06:39:44+00:00" + }, + { + "name": "seld/jsonlint", + "version": "1.10.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/jsonlint.git", + "reference": "594fd6462aad8ecee0b45ca5045acea4776667f1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/jsonlint/zipball/594fd6462aad8ecee0b45ca5045acea4776667f1", + "reference": "594fd6462aad8ecee0b45ca5045acea4776667f1", + "shasum": "" + }, + "require": { + "php": "^5.3 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.5", + "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.0 || ^8.5.13" + }, + "bin": [ + "bin/jsonlint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Seld\\JsonLint\\": "src/Seld/JsonLint/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "JSON Linter", + "keywords": [ + "json", + "linter", + "parser", + "validator" + ], + "support": { + "issues": "https://github.com/Seldaek/jsonlint/issues", + "source": "https://github.com/Seldaek/jsonlint/tree/1.10.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/seld/jsonlint", + "type": "tidelift" + } + ], + "time": "2023-05-11T13:16:46+00:00" + }, + { + "name": "seld/phar-utils", + "version": "1.2.1", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/phar-utils.git", + "reference": "ea2f4014f163c1be4c601b9b7bd6af81ba8d701c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/phar-utils/zipball/ea2f4014f163c1be4c601b9b7bd6af81ba8d701c", + "reference": "ea2f4014f163c1be4c601b9b7bd6af81ba8d701c", + "shasum": "" + }, + "require": { + "php": ">=5.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Seld\\PharUtils\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be" + } + ], + "description": "PHAR file format utilities, for when PHP phars you up", + "keywords": [ + "phar" + ], + "support": { + "issues": "https://github.com/Seldaek/phar-utils/issues", + "source": "https://github.com/Seldaek/phar-utils/tree/1.2.1" + }, + "time": "2022-08-31T10:31:18+00:00" + }, + { + "name": "seld/signal-handler", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/signal-handler.git", + "reference": "f69d119511dc0360440cdbdaa71829c149b7be75" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/signal-handler/zipball/f69d119511dc0360440cdbdaa71829c149b7be75", + "reference": "f69d119511dc0360440cdbdaa71829c149b7be75", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "require-dev": { + "phpstan/phpstan": "^1", + "phpstan/phpstan-deprecation-rules": "^1.0", + "phpstan/phpstan-phpunit": "^1", + "phpstan/phpstan-strict-rules": "^1.3", + "phpunit/phpunit": "^7.5.20 || ^8.5.23", + "psr/log": "^1 || ^2 || ^3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Seld\\Signal\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "Simple unix signal handler that silently fails where signals are not supported for easy cross-platform development", + "keywords": [ + "posix", + "sigint", + "signal", + "sigterm", + "unix" + ], + "support": { + "issues": "https://github.com/Seldaek/signal-handler/issues", + "source": "https://github.com/Seldaek/signal-handler/tree/2.0.1" + }, + "time": "2022-07-20T18:31:45+00:00" + }, + { + "name": "slevomat/coding-standard", + "version": "8.13.4", + "source": { + "type": "git", + "url": "https://github.com/slevomat/coding-standard.git", + "reference": "4b2af2fb17773656d02fbfb5d18024ebd19fe322" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/slevomat/coding-standard/zipball/4b2af2fb17773656d02fbfb5d18024ebd19fe322", + "reference": "4b2af2fb17773656d02fbfb5d18024ebd19fe322", + "shasum": "" + }, + "require": { + "dealerdirect/phpcodesniffer-composer-installer": "^0.6.2 || ^0.7 || ^1.0", + "php": "^7.2 || ^8.0", + "phpstan/phpdoc-parser": "^1.23.0", + "squizlabs/php_codesniffer": "^3.7.1" + }, + "require-dev": { + "phing/phing": "2.17.4", + "php-parallel-lint/php-parallel-lint": "1.3.2", + "phpstan/phpstan": "1.10.26", + "phpstan/phpstan-deprecation-rules": "1.1.3", + "phpstan/phpstan-phpunit": "1.3.13", + "phpstan/phpstan-strict-rules": "1.5.1", + "phpunit/phpunit": "7.5.20|8.5.21|9.6.8|10.2.6" + }, + "type": "phpcodesniffer-standard", + "extra": { + "branch-alias": { + "dev-master": "8.x-dev" + } + }, + "autoload": { + "psr-4": { + "SlevomatCodingStandard\\": "SlevomatCodingStandard/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Slevomat Coding Standard for PHP_CodeSniffer complements Consistence Coding Standard by providing sniffs with additional checks.", + "keywords": [ + "dev", + "phpcs" + ], + "support": { + "issues": "https://github.com/slevomat/coding-standard/issues", + "source": "https://github.com/slevomat/coding-standard/tree/8.13.4" + }, + "funding": [ + { + "url": "https://github.com/kukulich", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/slevomat/coding-standard", + "type": "tidelift" + } + ], + "time": "2023-07-25T10:28:55+00:00" + }, + { + "name": "squizlabs/php_codesniffer", + "version": "3.7.2", + "source": { + "type": "git", + "url": "https://github.com/squizlabs/PHP_CodeSniffer.git", + "reference": "ed8e00df0a83aa96acf703f8c2979ff33341f879" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/ed8e00df0a83aa96acf703f8c2979ff33341f879", + "reference": "ed8e00df0a83aa96acf703f8c2979ff33341f879", + "shasum": "" + }, + "require": { + "ext-simplexml": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0" + }, + "bin": [ + "bin/phpcs", + "bin/phpcbf" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Greg Sherwood", + "role": "lead" + } + ], + "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", + "homepage": "https://github.com/squizlabs/PHP_CodeSniffer", + "keywords": [ + "phpcs", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/squizlabs/PHP_CodeSniffer/issues", + "source": "https://github.com/squizlabs/PHP_CodeSniffer", + "wiki": "https://github.com/squizlabs/PHP_CodeSniffer/wiki" + }, + "time": "2023-02-22T23:07:41+00:00" + }, + { + "name": "symfony/finder", + "version": "v6.3.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "9915db259f67d21eefee768c1abcf1cc61b1fc9e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/9915db259f67d21eefee768c1abcf1cc61b1fc9e", + "reference": "9915db259f67d21eefee768c1abcf1cc61b1fc9e", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "symfony/filesystem": "^6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v6.3.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-07-31T08:31:44+00:00" + }, + { + "name": "symfony/polyfill-php73", + "version": "v1.27.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php73.git", + "reference": "9e8ecb5f92152187c4799efd3c96b78ccab18ff9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/9e8ecb5f92152187c4799efd3c96b78ccab18ff9", + "reference": "9e8ecb5f92152187c4799efd3c96b78ccab18ff9", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.27-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php73\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php73/tree/v1.27.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-11-03T14:55:06+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.27.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "7a6ff3f1959bb01aefccb463a0f2cd3d3d2fd936" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/7a6ff3f1959bb01aefccb463a0f2cd3d3d2fd936", + "reference": "7a6ff3f1959bb01aefccb463a0f2cd3d3d2fd936", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.27-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.27.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-11-03T14:55:06+00:00" + }, + { + "name": "symfony/polyfill-php81", + "version": "v1.27.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php81.git", + "reference": "707403074c8ea6e2edaf8794b0157a0bfa52157a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/707403074c8ea6e2edaf8794b0157a0bfa52157a", + "reference": "707403074c8ea6e2edaf8794b0157a0bfa52157a", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.27-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php81\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php81/tree/v1.27.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-11-03T14:55:06+00:00" + }, + { + "name": "symfony/process", + "version": "v6.3.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "c5ce962db0d9b6e80247ca5eb9af6472bd4d7b5d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/c5ce962db0d9b6e80247ca5eb9af6472bd4d7b5d", + "reference": "c5ce962db0d9b6e80247ca5eb9af6472bd4d7b5d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v6.3.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-07-12T16:00:22+00:00" + }, + { + "name": "symfony/var-dumper", + "version": "v6.3.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "77fb4f2927f6991a9843633925d111147449ee7a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/77fb4f2927f6991a9843633925d111147449ee7a", + "reference": "77fb4f2927f6991a9843633925d111147449ee7a", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/console": "<5.4" + }, + "require-dev": { + "ext-iconv": "*", + "symfony/console": "^5.4|^6.0", + "symfony/http-kernel": "^5.4|^6.0", + "symfony/process": "^5.4|^6.0", + "symfony/uid": "^5.4|^6.0", + "twig/twig": "^2.13|^3.0.4" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "support": { + "source": "https://github.com/symfony/var-dumper/tree/v6.3.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-07-31T07:08:24+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.2.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "34a41e998c2183e22995f158c581e7b5e755ab9e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/34a41e998c2183e22995f158c581e7b5e755ab9e", + "reference": "34a41e998c2183e22995f158c581e7b5e755ab9e", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.2.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2021-07-28T10:34:58+00:00" + }, + { + "name": "twig/markdown-extra", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/twigphp/markdown-extra.git", + "reference": "8f1179e279cea6ef14066a4560b859df58acd5d8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/markdown-extra/zipball/8f1179e279cea6ef14066a4560b859df58acd5d8", + "reference": "8f1179e279cea6ef14066a4560b859df58acd5d8", + "shasum": "" + }, + "require": { + "php": ">=7.1.3", + "twig/twig": "^2.7|^3.0" + }, + "require-dev": { + "erusev/parsedown": "^1.7", + "league/commonmark": "^1.0|^2.0", + "league/html-to-markdown": "^4.8|^5.0", + "michelf/php-markdown": "^1.8|^2.0", + "symfony/phpunit-bridge": "^4.4.9|^5.0.9|^6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Twig\\Extra\\Markdown\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + } + ], + "description": "A Twig extension for Markdown", + "homepage": "https://twig.symfony.com", + "keywords": [ + "html", + "markdown", + "twig" + ], + "support": { + "source": "https://github.com/twigphp/markdown-extra/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2023-02-09T06:45:16+00:00" + }, + { + "name": "twig/twig", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/twigphp/Twig.git", + "reference": "5cf942bbab3df42afa918caeba947f1b690af64b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/5cf942bbab3df42afa918caeba947f1b690af64b", + "reference": "5cf942bbab3df42afa918caeba947f1b690af64b", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.3" + }, + "require-dev": { + "psr/container": "^1.0|^2.0", + "symfony/phpunit-bridge": "^4.4.9|^5.0.9|^6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Twig\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + }, + { + "name": "Twig Team", + "role": "Contributors" + }, + { + "name": "Armin Ronacher", + "email": "armin.ronacher@active-4.com", + "role": "Project Founder" + } + ], + "description": "Twig, the flexible, fast, and secure template language for PHP", + "homepage": "https://twig.symfony.com", + "keywords": [ + "templating" + ], + "support": { + "issues": "https://github.com/twigphp/Twig/issues", + "source": "https://github.com/twigphp/Twig/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2023-07-26T07:16:09+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": { + "psy/psysh": 0 + }, + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": ">=8.0" + }, + "platform-dev": [], + "plugin-api-version": "2.3.0" +} diff --git a/app/config/.env.example b/app/config/.env.example new file mode 100644 index 000000000..15060f7be --- /dev/null +++ b/app/config/.env.example @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Used as a default to seed config/.env which +# enables you to use environment variables to configure +# the aspects of your application that vary by +# environment. +# +# Having this file in production is considered a **SECURITY RISK** and also decreases +# the boostrap performance of your application. +# +# To use this file, first copy it into `config/.env`. Also ensure the related +# code block for loading this file is uncommented in `config/boostrap.php` +# +# In development .env files are parsed by PHP +# and set into the environment. This provides a simpler +# development workflow over standard environment variables. +export APP_NAME="__APP_NAME__" +export DEBUG="true" +export APP_ENCODING="UTF-8" +export APP_DEFAULT_LOCALE="en_US" +export APP_DEFAULT_TIMEZONE="UTC" +export SECURITY_SALT="__SALT__" + +# Uncomment these to define cache configuration via environment variables. +#export CACHE_DURATION="+2 minutes" +#export CACHE_DEFAULT_URL="file://tmp/cache/?prefix=${APP_NAME}_default&duration=${CACHE_DURATION}" +#export CACHE_CAKECORE_URL="file://tmp/cache/persistent?prefix=${APP_NAME}_cake_core&serialize=true&duration=${CACHE_DURATION}" +#export CACHE_CAKEMODEL_URL="file://tmp/cache/models?prefix=${APP_NAME}_cake_model&serialize=true&duration=${CACHE_DURATION}" + +# Uncomment these to define email transport configuration via environment variables. +#export EMAIL_TRANSPORT_DEFAULT_URL="" + +# Uncomment these to define database configuration via environment variables. +#export DATABASE_URL="mysql://my_app:secret@localhost/${APP_NAME}?encoding=utf8&timezone=UTC&cacheMetadata=true"eIdentifiers=false&persistent=false" +#export DATABASE_TEST_URL="mysql://my_app:secret@localhost/test_${APP_NAME}?encoding=utf8&timezone=UTC&cacheMetadata=true"eIdentifiers=false&persistent=false" + +# Uncomment these to define logging configuration via environment variables. +#export LOG_DEBUG_URL="file://logs/?levels[]=notice&levels[]=info&levels[]=debug&file=debug" +#export LOG_ERROR_URL="file://logs/?levels[]=warning&levels[]=error&levels[]=critical&levels[]=alert&levels[]=emergency&file=error" diff --git a/app/config/VERSION b/app/config/VERSION new file mode 100644 index 000000000..0062ac971 --- /dev/null +++ b/app/config/VERSION @@ -0,0 +1 @@ +5.0.0 diff --git a/app/config/app.php b/app/config/app.php new file mode 100644 index 000000000..0acaef633 --- /dev/null +++ b/app/config/app.php @@ -0,0 +1,474 @@ + filter_var(env('DEBUG', false), FILTER_VALIDATE_BOOLEAN), + + /** + * Configure basic information about the application. + * + * - namespace - The namespace to find app classes under. + * - defaultLocale - The default locale for translation, formatting currencies and numbers, date and time. + * - encoding - The encoding used for HTML + database connections. + * - base - The base directory the app resides in. If false this + * will be auto detected. + * - dir - Name of app directory. + * - webroot - The webroot directory. + * - wwwRoot - The file path to webroot. + * - baseUrl - To configure CakePHP to *not* use mod_rewrite and to + * use CakePHP pretty URLs, remove these .htaccess + * files: + * /.htaccess + * /webroot/.htaccess + * And uncomment the baseUrl key below. + * - fullBaseUrl - A base URL to use for absolute links. When set to false (default) + * CakePHP generates required value based on `HTTP_HOST` environment variable. + * However, you can define it manually to optimize performance or if you + * are concerned about people manipulating the `Host` header. + * - imageBaseUrl - Web path to the public images directory under webroot. + * - cssBaseUrl - Web path to the public css directory under webroot. + * - jsBaseUrl - Web path to the public js directory under webroot. + * - paths - Configure paths for non class based resources. Supports the + * `plugins`, `templates`, `locales` subkeys, which allow the definition of + * paths for plugins, view templates and locale files respectively. + */ + 'App' => [ + 'namespace' => 'App', + 'encoding' => env('APP_ENCODING', 'UTF-8'), + 'defaultLocale' => env('APP_DEFAULT_LOCALE', 'en_US'), + 'defaultTimezone' => env('APP_DEFAULT_TIMEZONE', 'UTC'), + 'base' => false, + 'dir' => 'src', + 'webroot' => 'webroot', + 'wwwRoot' => WWW_ROOT, + //'baseUrl' => env('SCRIPT_NAME'), + 'fullBaseUrl' => false, + 'imageBaseUrl' => 'img/', + 'cssBaseUrl' => 'css/', + 'jsBaseUrl' => 'js/', + 'paths' => [ + 'plugins' => [ + // Core plugins, always active + ROOT . DS . 'plugins' . DS, + // Optional plugins, must be activated + ROOT . DS . 'availableplugins' . DS, + // Local plugins, must be activated + LOCAL . DS . 'plugins' . DS + ], + 'templates' => [ROOT . DS . 'templates' . DS], + 'locales' => [ROOT . DS . 'resources' . DS . 'locales' . DS], + ], + ], + + /** + * Security and encryption configuration + * + * - salt - A random string used in security hashing methods. + * The salt value is also used as the encryption key. + * You should treat it as extremely sensitive data. + */ + 'Security' => [ + // Note that we (COmanage) override this in bootstrap.php + //'salt' => env('SECURITY_SALT', 'd6ded009aad8fe73e7ebf4f9c170e39e3b0ed0ab9253ed3eb4db03ad6fc07ab4'), + ], + + /** + * Apply timestamps with the last modified time to static assets (js, css, images). + * Will append a querystring parameter containing the time the file was modified. + * This is useful for busting browser caches. + * + * Set to true to apply timestamps when debug is true. Set to 'force' to always + * enable timestamping regardless of debug value. + */ + 'Asset' => [ + //'timestamp' => true, + // 'cacheTime' => '+1 year' + ], + + /** + * Configure the cache adapters. + */ + 'Cache' => [ + 'default' => [ + 'className' => 'Cake\Cache\Engine\FileEngine', + 'path' => CACHE, + 'url' => env('CACHE_DEFAULT_URL', null), + ], + + /** + * Configure the cache used for general framework caching. + * Translation cache files are stored with this configuration. + * Duration will be set to '+2 minutes' in bootstrap.php when debug = true + * If you set 'className' => 'Null' core cache will be disabled. + */ + '_cake_core_' => [ + 'className' => 'Cake\Cache\Engine\FileEngine', + 'prefix' => 'myapp_cake_core_', + 'path' => CACHE . 'persistent/', + 'serialize' => true, + 'duration' => '+1 years', + 'url' => env('CACHE_CAKECORE_URL', null), + ], + + /** + * Configure the cache for model and datasource caches. This cache + * configuration is used to store schema descriptions, and table listings + * in connections. + * Duration will be set to '+2 minutes' in bootstrap.php when debug = true + */ + '_cake_model_' => [ + 'className' => 'Cake\Cache\Engine\FileEngine', + 'prefix' => 'myapp_cake_model_', + 'path' => CACHE . 'models/', + 'serialize' => true, + 'duration' => '+1 years', + 'url' => env('CACHE_CAKEMODEL_URL', null), + ], + + /** + * Configure the cache for routes. The cached routes collection is built the + * first time the routes are processed via `config/routes.php`. + * Duration will be set to '+2 seconds' in bootstrap.php when debug = true + */ + '_cake_routes_' => [ + 'className' => 'Cake\Cache\Engine\FileEngine', + 'prefix' => 'myapp_cake_routes_', + 'path' => CACHE, + 'serialize' => true, + 'duration' => '+1 years', + 'url' => env('CACHE_CAKEROUTES_URL', null), + ], + ], + + /** + * Configure the Error and Exception handlers used by your application. + * + * By default errors are displayed using Debugger, when debug is true and logged + * by Cake\Log\Log when debug is false. + * + * In CLI environments exceptions will be printed to stderr with a backtrace. + * In web environments an HTML page will be displayed for the exception. + * With debug true, framework errors like Missing Controller will be displayed. + * When debug is false, framework errors will be coerced into generic HTTP errors. + * + * Options: + * + * - `errorLevel` - int - The level of errors you are interested in capturing. + * - `trace` - boolean - Whether or not backtraces should be included in + * logged errors/exceptions. + * - `log` - boolean - Whether or not you want exceptions logged. + * - `exceptionRenderer` - string - The class responsible for rendering + * uncaught exceptions. If you choose a custom class you should place + * the file for that class in src/Error. This class needs to implement a + * render method. + * - `skipLog` - array - List of exceptions to skip for logging. Exceptions that + * extend one of the listed exceptions will also be skipped for logging. + * E.g.: + * `'skipLog' => ['Cake\Http\Exception\NotFoundException', 'Cake\Http\Exception\UnauthorizedException']` + * - `extraFatalErrorMemory` - int - The number of megabytes to increase + * the memory limit by when a fatal error is encountered. This allows + * breathing room to complete logging or error handling. + */ + 'Error' => [ + 'errorLevel' => E_ALL, + 'exceptionRenderer' => 'Cake\Error\ExceptionRenderer', + 'skipLog' => [], + 'log' => true, + 'trace' => true, + ], + + /** + * Email configuration. + * + * By defining transports separately from delivery profiles you can easily + * re-use transport configuration across multiple profiles. + * + * You can specify multiple configurations for production, development and + * testing. + * + * Each transport needs a `className`. Valid options are as follows: + * + * Mail - Send using PHP mail function + * Smtp - Send using SMTP + * Debug - Do not send the email, just return the result + * + * You can add custom transports (or override existing transports) by adding the + * appropriate file to src/Mailer/Transport. Transports should be named + * 'YourTransport.php', where 'Your' is the name of the transport. + */ + 'EmailTransport' => [ + 'default' => [ + 'className' => 'Cake\Mailer\Transport\MailTransport', + /* + * The following keys are used in SMTP transports: + */ + 'host' => 'localhost', + 'port' => 25, + 'timeout' => 30, + 'username' => null, + 'password' => null, + 'client' => null, + 'tls' => null, + 'url' => env('EMAIL_TRANSPORT_DEFAULT_URL', null), + ], + ], + + /** + * Email delivery profiles + * + * Delivery profiles allow you to predefine various properties about email + * messages from your application and give the settings a name. This saves + * duplication across your application and makes maintenance and development + * easier. Each profile accepts a number of keys. See `Cake\Mailer\Email` + * for more information. + */ + 'Email' => [ + 'default' => [ + 'transport' => 'default', + 'from' => 'you@localhost', + //'charset' => 'utf-8', + //'headerCharset' => 'utf-8', + ], + ], + + /** + * Connection information used by the ORM to connect + * to your application's datastores. + * + * ### Notes + * - Drivers include Mysql Postgres Sqlite Sqlserver + * See vendor\cakephp\cakephp\src\Database\Driver for complete list + * - Do not use periods in database name - it may lead to error. + * See https://github.com/cakephp/cakephp/issues/6471 for details. + * - 'encoding' is recommended to be set to full UTF-8 4-Byte support. + * E.g set it to 'utf8mb4' in MariaDB and MySQL and 'utf8' for any + * other RDBMS. + * + * Note for COmanage we read in local/Config/database.php instead + */ + 'Datasources' => [ + 'default' => [ + 'className' => 'Cake\Database\Connection', + 'driver' => 'Cake\Database\Driver\Mysql', + 'persistent' => false, + 'host' => 'localhost', + /* + * CakePHP will use the default DB port based on the driver selected + * MySQL on MAMP uses port 8889, MAMP users will want to uncomment + * the following line and set the port accordingly + */ + //'port' => 'non_standard_port_number', + 'username' => 'my_app', + 'password' => 'secret', + 'database' => 'my_app', + /* + * You do not need to set this flag to use full utf-8 encoding (internal default since CakePHP 3.6). + */ + //'encoding' => 'utf8mb4', + 'timezone' => 'UTC', + 'flags' => [], + 'cacheMetadata' => true, + // Set to true to get query log for debugging + 'log' => false, + + /** + * Set identifier quoting to true if you are using reserved words or + * special characters in your table or column names. Enabling this + * setting will result in queries built using the Query Builder having + * identifiers quoted when creating SQL. It should be noted that this + * decreases performance because each query needs to be traversed and + * manipulated before being executed. + */ + 'quoteIdentifiers' => false, + + /** + * During development, if using MySQL < 5.6, uncommenting the + * following line could boost the speed at which schema metadata is + * fetched from the database. It can also be set directly with the + * mysql configuration directive 'innodb_stats_on_metadata = 0' + * which is the recommended value in production environments + */ + //'init' => ['SET GLOBAL innodb_stats_on_metadata = 0'], + + 'url' => env('DATABASE_URL', null), + ], + + /** + * The test connection is used during the test suite. + */ + 'test' => [ + 'className' => 'Cake\Database\Connection', + 'driver' => 'Cake\Database\Driver\Mysql', + 'persistent' => false, + 'host' => 'localhost', + //'port' => 'non_standard_port_number', + 'username' => 'my_app', + 'password' => 'secret', + 'database' => 'test_myapp', + //'encoding' => 'utf8mb4', + 'timezone' => 'UTC', + 'cacheMetadata' => true, + 'quoteIdentifiers' => false, + 'log' => false, + //'init' => ['SET GLOBAL innodb_stats_on_metadata = 0'], + 'url' => env('DATABASE_TEST_URL', null), + ], + ], + + /** + * Configures logging options + */ + 'Log' => null !== env('COMANAGE_REGISTRY_CONTAINER', null) + // Configuration for container deployments + ? [ + 'debug' => [ + 'className' => 'Cake\Log\Engine\ConsoleLog', + 'stream' => 'php://stdout', + 'outputAs' => 0, + 'scopes' => false, + 'levels' => ['notice', 'info', 'debug'], + ], + 'error' => [ + 'className' => 'Cake\Log\Engine\ConsoleLog', + 'stream' => 'php://stderr', + 'outputAs' => 0, + 'scopes' => false, + 'levels' => ['warning', 'error', 'critical', 'alert', 'emergency'], + ], + 'queries' => [ + 'className' => 'Cake\Log\Engine\ConsoleLog', + 'stream' => 'php://stdout', + 'outputAs' => 0, + 'scopes' => ['queriesLog'] + ], + 'trace' => [ + 'className' => 'Cake\Log\Engine\ConsoleLog', + 'stream' => 'php://stdout', + 'outputAs' => 0, + 'scopes' => ['trace'], + ] + ] + // Configuration for tranditional deployments + : [ + 'debug' => [ + 'className' => 'Cake\Log\Engine\FileLog', + 'path' => LOGS, + 'file' => 'debug', + 'url' => env('LOG_DEBUG_URL', null), + 'scopes' => false, + 'levels' => ['notice', 'info', 'debug'], + ], + 'error' => [ + 'className' => 'Cake\Log\Engine\FileLog', + 'path' => LOGS, + 'file' => 'error', + 'url' => env('LOG_ERROR_URL', null), + 'scopes' => false, + 'levels' => ['warning', 'error', 'critical', 'alert', 'emergency'], + ], + // To enable this dedicated query log, you need set your datasource's log flag to true + 'queries' => [ + 'className' => 'Cake\Log\Engine\FileLog', + 'path' => LOGS, + 'file' => 'queries', + 'url' => env('LOG_QUERIES_URL', null), + 'scopes' => ['queriesLog'], + ], + // We define a trace level for what is really debugging, except debug level + // will write to stdout instead of the log when debug=true + 'trace' => [ + 'className' => 'Cake\Log\Engine\FileLog', + 'path' => LOGS, + 'file' => 'trace', + 'url' => env('LOG_TRACE_URL', null), + 'scopes' => ['trace'], + ], + // We define a rules level to record application rule execution + 'rule' => [ + 'className' => 'Cake\Log\Engine\FileLog', + 'path' => LOGS, + 'file' => 'rule', + 'url' => env('LOG_TRACE_URL', null), + 'scopes' => ['rule'], + ] + ], + + /** + * Session configuration. + * + * Contains an array of settings to use for session configuration. The + * `defaults` key is used to define a default preset to use for sessions, any + * settings declared here will override the settings of the default config. + * + * ## Options + * + * - `cookie` - The name of the cookie to use. Defaults to 'CAKEPHP'. Avoid using `.` in cookie names, + * as PHP will drop sessions from cookies with `.` in the name. + * - `cookiePath` - The url path for which session cookie is set. Maps to the + * `session.cookie_path` php.ini config. Defaults to base path of app. + * - `timeout` - The time in minutes the session should be valid for. + * Pass 0 to disable checking timeout. + * Please note that php.ini's session.gc_maxlifetime must be equal to or greater + * than the largest Session['timeout'] in all served websites for it to have the + * desired effect. + * - `defaults` - The default configuration set to use as a basis for your session. + * There are four built-in options: php, cake, cache, database. + * - `handler` - Can be used to enable a custom session handler. Expects an + * array with at least the `engine` key, being the name of the Session engine + * class to use for managing the session. CakePHP bundles the `CacheSession` + * and `DatabaseSession` engines. + * - `ini` - An associative array of additional ini values to set. + * + * The built-in `defaults` options are: + * + * - 'php' - Uses settings defined in your php.ini. + * - 'cake' - Saves session files in CakePHP's /tmp directory. + * - 'database' - Uses CakePHP's database sessions. + * - 'cache' - Use the Cache class to save sessions. + * + * To define a custom session handler, save it at src/Network/Session/.php. + * Make sure the class implements PHP's `SessionHandlerInterface` and set + * Session.handler to + * + * To use database sessions, load the SQL file located at config/schema/sessions.sql + */ + 'Session' => [ + 'defaults' => 'php', + // Switch cookie name to avoid conflict with older versions of Registry + // Note this name must match the name used in webroot/auth/*/* + 'cookie' => 'REGISTRYPECAKEPHP' + ], +]; diff --git a/app/config/bootstrap.php b/app/config/bootstrap.php new file mode 100644 index 000000000..2d3d5f66e --- /dev/null +++ b/app/config/bootstrap.php @@ -0,0 +1,244 @@ +parse() +// ->putenv() +// ->toEnv() +// ->toServer(); +// } + +/* + * Read configuration file and inject configuration into various + * CakePHP classes. + * + * By default there is only one configuration file. It is often a good + * idea to create multiple configuration files, and separate the configuration + * that changes from configuration that does not. This makes deployment simpler. + */ +try { + Configure::config('default', new PhpConfig()); + Configure::load('app', 'default', false); + // Read site specific configurations from the COmanage Registry local directory + Configure::config('default', new PhpConfig(LOCAL . 'config' . DS)); + Configure::load('database', 'default'); +} catch (\Exception $e) { + exit($e->getMessage() . "\n"); +} + +/* + * Load an environment local configuration file to provide overrides to your configuration. + * Notice: For security reasons app_local.php will not be included in your git repo. + * +if (file_exists(CONFIG . 'app_local.php')) { + Configure::load('app_local', 'default'); +}*/ + +/* + * When debug = true the metadata cache should only last + * for a short time. + */ +if (Configure::read('debug')) { + Cache::disable(); + //Configure::write('Cache._cake_model_.duration', '+2 minutes'); + //Configure::write('Cache._cake_core_.duration', '+2 minutes'); + // disable router cache during development + //Configure::write('Cache._cake_routes_.duration', '+2 seconds'); +} + +/* + * Set the default server timezone. Using UTC makes time calculations / conversions easier. + * Check http://php.net/manual/en/timezones.php for list of valid timezone strings. + */ +date_default_timezone_set(Configure::read('App.defaultTimezone')); + +/* + * Configure the mbstring extension to use the correct encoding. + */ +mb_internal_encoding(Configure::read('App.encoding')); + +/* + * Set the default locale. This controls how dates, number and currency is + * formatted and sets the default language to use for translations. + */ +ini_set('intl.default_locale', Configure::read('App.defaultLocale')); + +/* + * Register application error and exception handlers. + */ +$isCli = PHP_SAPI === 'cli'; +(new ErrorTrap(Configure::read('Error')))->register(); +(new ExceptionTrap(Configure::read('Error')))->register(); +/* + * Include the CLI bootstrap overrides. + */ +if ($isCli) { + require CONFIG . 'bootstrap_cli.php'; +} + +/* + * Set the full base URL. + * This URL is used as the base of all absolute links. + */ +$fullBaseUrl = Configure::read('App.fullBaseUrl'); +if (!$fullBaseUrl) { + $s = null; + if (env('HTTPS')) { + $s = 's'; + } + + $httpHost = env('HTTP_HOST'); + if (isset($httpHost)) { + $fullBaseUrl = 'http' . $s . '://' . $httpHost; + } + unset($httpHost, $s); +} +if ($fullBaseUrl) { + Router::fullBaseUrl($fullBaseUrl); +} +unset($fullBaseUrl); + +Cache::setConfig(Configure::consume('Cache')); +ConnectionManager::setConfig(Configure::consume('Datasources')); +TransportFactory::setConfig(Configure::consume('EmailTransport')); +Mailer::setConfig(Configure::consume('Email')); +Log::setConfig(Configure::consume('Log')); + +// Set the salt from the environment if available, else from the filesystem, +// and if the salt cannot be determined we're probably in SetupCommand, +// which will create it. +$salt = env('SECURITY_SALT', null); + +if(is_null($salt)) { + $securitySaltFile = LOCAL . "config" . DS . "security.salt"; + if(file_exists($securitySaltFile)) { + $salt = file_get_contents($securitySaltFile); + } +} + +if($salt) { + Security::setSalt($salt); +} + +/* + * Setup detectors for mobile and tablet. + */ +ServerRequest::addDetector('mobile', function ($request) { + $detector = new \Detection\MobileDetect(); + + return $detector->isMobile(); +}); +ServerRequest::addDetector('tablet', function ($request) { + $detector = new \Detection\MobileDetect(); + + return $detector->isTablet(); +}); + +/* + * You can set whether the ORM uses immutable or mutable Time types. + * The default changed in 4.0 to immutable types. You can uncomment + * below to switch back to mutable types. + * + * You can enable default locale format parsing by adding calls + * to `useLocaleParser()`. This enables the automatic conversion of + * locale specific date formats. For details see + * @link https://book.cakephp.org/4/en/core-libraries/internationalization-and-localization.html#parsing-localized-datetime-data + */ +// \Cake\Database\TypeFactory::build('time') +// ->useMutable(); +// \Cake\Database\TypeFactory::build('date') +// ->useMutable(); +// \Cake\Database\TypeFactory::build('datetime') +// ->useMutable(); +// \Cake\Database\TypeFactory::build('timestamp') +// ->useMutable(); +// \Cake\Database\TypeFactory::build('datetimefractional') +// ->useMutable(); +// \Cake\Database\TypeFactory::build('timestampfractional') +// ->useMutable(); +// \Cake\Database\TypeFactory::build('datetimetimezone') +// ->useMutable(); +// \Cake\Database\TypeFactory::build('timestamptimezone') +// ->useMutable(); +// There is no time-specific type in Cake +TypeFactory::map('time', StringType::class); + +/* + * Custom Inflector rules, can be set to correctly pluralize or singularize + * table, model, controller names or whatever other string is passed to the + * inflection functions. + */ +//Inflector::rules('plural', ['/^(inflect)or$/i' => '\1ables']); +//Inflector::rules('irregular', ['red' => 'redlings']); +//Inflector::rules('uninflected', ['dontinflectme']); +//Inflector::rules('transliteration', ['/å/' => 'aa']); + +Inflector::rules('irregular', ['co_terms_and_condition' => 'co_terms_and_conditions']); +Inflector::rules('irregular', ['cou' => 'cous']); +Inflector::rules('irregular', ['meta' => 'meta']); + +/* + * Define some constants + */ +define('DEF_GLOBAL_SEARCH_LIMIT', 500); \ No newline at end of file diff --git a/app/config/bootstrap_cli.php b/app/config/bootstrap_cli.php new file mode 100644 index 000000000..fc0dc30bb --- /dev/null +++ b/app/config/bootstrap_cli.php @@ -0,0 +1,35 @@ + [ + 'default' => [ + 'className' => 'Cake\Database\Connection', + // Cake supports "Mysql", "Postgres", "Sqlite", or "Sqlserver", + // but Registry only supports the first two. + 'driver' => 'Cake\Database\Driver\Postgres', + 'persistent' => false, + 'host' => 'localhost', + 'username' => 'comanage', + 'password' => 'somepasswordhere', + 'database' => 'registry', + /** + * CakePHP will use the default DB port based on the driver selected + * MySQL on MAMP uses port 8889, MAMP users will want to uncomment + * the following line and set the port accordingly + */ + //'port' => 'non_standard_port_number', + 'encoding' => 'utf8', + 'timezone' => 'UTC', + 'flags' => [], + 'cacheMetadata' => true, + 'log' => false, + + /** + * Set identifier quoting to true if you are using reserved words or + * special characters in your table or column names. Enabling this + * setting will result in queries built using the Query Builder having + * identifiers quoted when creating SQL. It should be noted that this + * decreases performance because each query needs to be traversed and + * manipulated before being executed. + */ + // Set this to true for MySQL + 'quoteIdentifiers' => false, + + /** + * During development, if using MySQL < 5.6, uncommenting the + * following line could boost the speed at which schema metadata is + * fetched from the database. It can also be set directly with the + * mysql configuration directive 'innodb_stats_on_metadata = 0' + * which is the recommended value in production environments + */ + //'init' => ['SET GLOBAL innodb_stats_on_metadata = 0'], + + 'url' => env('DATABASE_URL', null), + ], + + /** + * The test connection is used during the test suite. + */ + 'test' => [ + 'className' => 'Cake\Database\Connection', + 'driver' => 'Cake\Database\Driver\Postgres', + 'persistent' => false, + 'host' => 'localhost', + //'port' => 'non_standard_port_number', + 'username' => 'my_app', + 'password' => 'secret', + 'database' => 'test_myapp', + 'encoding' => 'utf8', + 'timezone' => 'UTC', + 'cacheMetadata' => true, + 'quoteIdentifiers' => false, + 'log' => false, + //'init' => ['SET GLOBAL innodb_stats_on_metadata = 0'], + 'url' => env('DATABASE_TEST_URL', null), + ], + + /** + * For data migration from v4 to v5 + */ + 'transmogrify' => [ + 'className' => 'Cake\Database\Connection', + // Cake supports "Mysql", "Postgres", "Sqlite", or "Sqlserver", + // but Registry only supports the first two. + 'driver' => 'Cake\Database\Driver\Postgres', + 'persistent' => false, + 'host' => 'localhost', + 'username' => 'comanage', + 'password' => 'somepasswordhere', + 'database' => 'registryv4', + /** + * CakePHP will use the default DB port based on the driver selected + * MySQL on MAMP uses port 8889, MAMP users will want to uncomment + * the following line and set the port accordingly + */ + //'port' => 'non_standard_port_number', + 'encoding' => 'utf8', + 'timezone' => 'UTC', + 'flags' => [], + 'cacheMetadata' => true, + 'log' => false, + + /** + * Set identifier quoting to true if you are using reserved words or + * special characters in your table or column names. Enabling this + * setting will result in queries built using the Query Builder having + * identifiers quoted when creating SQL. It should be noted that this + * decreases performance because each query needs to be traversed and + * manipulated before being executed. + */ + // Set to true for MySQL + 'quoteIdentifiers' => false, + + /** + * During development, if using MySQL < 5.6, uncommenting the + * following line could boost the speed at which schema metadata is + * fetched from the database. It can also be set directly with the + * mysql configuration directive 'innodb_stats_on_metadata = 0' + * which is the recommended value in production environments + */ + //'init' => ['SET GLOBAL innodb_stats_on_metadata = 0'], + + 'url' => env('DATABASE_URL', null), + ] + ] +]; diff --git a/app/config/paths.php b/app/config/paths.php new file mode 100644 index 000000000..d00dcaa55 --- /dev/null +++ b/app/config/paths.php @@ -0,0 +1,99 @@ += 50.1 is needed to use CakePHP. Please update the `libicu` package of your system.' . PHP_EOL, E_USER_ERROR); +} + +/* + * You can remove this if you are confident you have mbstring installed. + */ +if (!extension_loaded('mbstring')) { + trigger_error('You must enable the mbstring extension to use CakePHP.', E_USER_ERROR); +} diff --git a/app/config/routes.php b/app/config/routes.php new file mode 100644 index 000000000..cde13ebb2 --- /dev/null +++ b/app/config/routes.php @@ -0,0 +1,195 @@ +setRouteClass(DashedRoute::class); + +// Registry API routes + +//// API routes +$routes->scope('/api/v2', function (RouteBuilder $builder) { + // BodyParserMiddleware will automatically parse JSON bodies, but we only + // want that for API transactions, so we only apply it to the /api scope. + $builder->registerMiddleware('bodyparser', new BodyParserMiddleware()); + /* + * Apply a middleware to the current route scope. + * Requires middleware to be registered through `Application::routes()` with `registerMiddleware()` + */ + $builder->setExtensions(['json']); + $builder->applyMiddleware('bodyparser'); + // Use setPass to make parameter show up as function parameter + // Model specific actions, which will usually have more specific URLs: + $builder->post( + '/api_users/generate/{id}', + ['controller' => 'ApiV2', 'action' => 'generateApiKey', 'model' => 'api_users']) + ->setPass(['id']) + ->setPatterns(['id' => '[0-9]+']); + // These establish the usual CRUD options on all models: + $builder->delete( + '/{model}/{id}', ['controller' => 'ApiV2', 'action' => 'delete']) + ->setPass(['id']) + ->setPatterns(['id' => '[0-9]+']); + $builder->get( + '/{model}', + ['controller' => 'ApiV2', 'action' => 'index']); + $builder->get( + '/{model}/{id}', + ['controller' => 'ApiV2', 'action' => 'view']) + ->setPass(['id']) + ->setPatterns(['id' => '[0-9]+']); + $builder->post( + '/{model}', + ['controller' => 'ApiV2', 'action' => 'add']); + $builder->put( + '/{model}/{id}', + ['controller' => 'ApiV2', 'action' => 'edit']) + ->setPass(['id']) + ->setPatterns(['id' => '[0-9]+']); +}); + + +//// API Ajax routes +$routes->scope('/api/ajax/v2', + ['_namePrefix' => 'apiAjaxV2:'], + function (RouteBuilder $builder) { + // Register scoped middleware for in scopes. + $builder->registerMiddleware('csrf', new CsrfProtectionMiddleware(['httponly' => true])); + // BodyParserMiddleware will automatically parse JSON bodies, but we only + // want that for API transactions, so we only apply it to the /api scope. + $builder->registerMiddleware('bodyparser', new BodyParserMiddleware()); + /* + * Apply a middleware to the current route scope. + * Requires middleware to be registered through `Application::routes()` with `registerMiddleware()` + */ + $builder->applyMiddleware('csrf'); + $builder->setExtensions(['json']); + $builder->applyMiddleware('bodyparser'); + // Use setPass to make parameter show up as function parameter + // Model specific actions, which will usually have more specific URLs: + $builder->post( + '/api_users/generate/{id}', + ['controller' => 'ApiV2', 'action' => 'generateApiKey', 'model' => 'api_users']) + ->setPass(['id']) + ->setPatterns(['id' => '[0-9]+']); + // These establish the usual CRUD options on all models: + $builder->delete( + '/{model}/{id}', ['controller' => 'ApiV2', 'action' => 'delete']) + ->setPass(['id']) + ->setPatterns(['id' => '[0-9]+']); + $builder->get( + '/{model}', + ['controller' => 'ApiV2', 'action' => 'index']); + $builder->get( + '/{model}/{id}', + ['controller' => 'ApiV2', 'action' => 'view']) + ->setPass(['id']) + ->setPatterns(['id' => '[0-9]+']); + $builder->post( + '/{model}', + ['controller' => 'ApiV2', 'action' => 'add']); + $builder->put( + '/{model}/{id}', + ['controller' => 'ApiV2', 'action' => 'edit']) + ->setPass(['id']) + ->setPatterns(['id' => '[0-9]+']); +}); + +// Main application routes +$routes->scope('/', function (RouteBuilder $builder) { + // Register scoped middleware for in scopes. + $builder->registerMiddleware('csrf', new CsrfProtectionMiddleware([ + 'httponly' => true, + ])); + + // BodyParserMiddleware will automatically parse JSON bodies, but we only + // want that for API transactions, so we only apply it to the /api scope. + $builder->registerMiddleware('bodyparser', new BodyParserMiddleware()); + + /* + * Apply a middleware to the current route scope. + * Requires middleware to be registered through `Application::routes()` with `registerMiddleware()` + */ + $builder->applyMiddleware('csrf'); + + /* + * Here, we are connecting '/' (base path) to a controller called 'Pages', + * its action called 'display', and we pass a param to select the view file + * to use (in this case, templates/Pages/home.php)... + */ + $builder->connect('/', ['controller' => 'Pages', 'action' => 'display', 'home']); + + /* + * ...and connect the rest of 'Pages' controller's URLs. + */ + $builder->connect('/pages/*', ['controller' => 'Pages', 'action' => 'display']); + + /* + * Connect catchall routes for all controllers. + * + * The `fallbacks` method is a shortcut for + * + * ``` + * $builder->connect('/:controller', ['action' => 'index']); + * $builder->connect('/:controller/:action/*', []); + * ``` + * + * You can remove these routes once you've connected the + * routes you want in your application. + */ + $builder->fallbacks(); +}); + +/* + * If you need a different set of middleware or none at all, + * open new scope and define routes there. + * + * ``` + * $routes->scope('/api', function (RouteBuilder $builder) { + * // No $builder->applyMiddleware() here. + * // Connect API actions here. + * }); + * ``` + */ diff --git a/app/config/schema/i18n.sql b/app/config/schema/i18n.sql new file mode 100644 index 000000000..e59d1e651 --- /dev/null +++ b/app/config/schema/i18n.sql @@ -0,0 +1,18 @@ +# Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) +# +# Licensed under The MIT License +# For full copyright and license information, please see the LICENSE.txt +# Redistributions of files must retain the above copyright notice. +# MIT License (https://opensource.org/licenses/mit-license.php) + +CREATE TABLE i18n ( + id int NOT NULL auto_increment, + locale varchar(6) NOT NULL, + model varchar(255) NOT NULL, + foreign_key int(10) NOT NULL, + field varchar(255) NOT NULL, + content text, + PRIMARY KEY (id), + UNIQUE INDEX I18N_LOCALE_FIELD(locale, model, foreign_key, field), + INDEX I18N_FIELD(model, foreign_key, field) +); diff --git a/app/config/schema/schema.json b/app/config/schema/schema.json new file mode 100644 index 000000000..52e689317 --- /dev/null +++ b/app/config/schema/schema.json @@ -0,0 +1,674 @@ +{ + "description": "COmanage Registry Database Schema File", + "format": "1", + "since": "Registry v5.0.0", + "license": "Apache 2, see LICENSE for details", + "documentation": "https://spaces.at.internet2.edu/display/COmanage/Database+Schema+Definition", + + "columnLibrary": { + "comment": "Columns with names matching those defined here will by default inherit these properties", + + "columns": { + "api_user_id": { "type": "integer", "foreignkey": { "table": "api_users", "column": "id" } }, + "co_id": { "type": "integer", "foreignkey": { "table": "cos", "column": "id" }, "notnull": true }, + "comment": { "type": "string", "size": 256 }, + "context": { "type": "string", "size": 2 }, + "cou_id": { "type": "integer", "foreignkey": { "table": "cous", "column": "id" } }, + "description": { "type": "string", "size": 128 }, + "external_identity_id": { "type": "integer", "foreignkey": { "table": "external_identities", "column": "id" } }, + "external_identity_role_id": { "type": "integer", "foreignkey": { "table": "external_identity_roles", "column": "id" } }, + "external_identity_source_id": { "type": "integer", "foreignkey": { "table": "external_identity_sources", "column": "id" } }, + "frozen": { "type": "boolean" }, + "group_id": { "type": "integer", "foreignkey": { "table": "groups", "column": "id" } }, + "id": { "type": "integer", "autoincrement": true, "primarykey": true }, + "identifier_assignment_id": { "type": "integer", "foreignkey": { "table": "identifier_assignments", "column": "id" }, "notnull": true }, + "language": { "type": "string", "size": 16 }, + "name": { "type": "string", "size": 128, "notnull": true }, + "ordr": { "type": "integer" }, + "person_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } }, + "person_role_id": { "type": "integer", "foreignkey": { "table": "person_roles", "column": "id" } }, + "plugin": { "type": "string", "size": 80 }, + "provisioning_target_id": { "type": "integer", "foreignkey": { "table": "provisioning_targets", "column": "id" }, "notnull": true }, + "reference_identifier": { "type": "string", "size": 40 }, + "report_id": { "type": "integer", "foreignkey": { "table": "reports", "column": "id" }, "notnull": true }, + "server_id": { "type": "integer", "foreignkey": { "table": "servers", "column": "id" }, "notnull": true }, + "status": { "type": "string", "size": 2 }, + "type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" }, "notnull": true }, + "valid_from": { "type": "datetime" }, + "valid_through": { "type": "datetime" } + } + }, + + "tables": { + "meta": { + "columns": { + "upgrade_version": { "type": "string", "size": 16 } + }, + "changelog": false, + "timestamps": false + }, + + "plugins": { + "columns": { + "id": {}, + "plugin": {}, + "location": { "type": "string", "size": 32 }, + "status": {}, + "comment": {} + }, + "indexes": { + }, + "changelog": false + }, + + "cos": { + "columns": { + "id": {}, + "name": {}, + "description": {}, + "status": {} + }, + "indexes": { + "cos_i1": { + "columns": [ "name" ] + } + }, + "changelog": true + }, + + "types": { + "columns": { + "id": {}, + "co_id": {}, + "attribute": { "type": "string", "size": 32, "notnull": true }, + "display_name": { "type": "string", "size": 64, "notnull": true }, + "value": { "type": "string", "size": 32, "notnull": true }, + "edupersonaffiliation": { "type": "string", "size": 32 }, + "status": {} + }, + "indexes": { + "types_i1": { "columns": [ "co_id" ] }, + "types_i2": { "columns": [ "co_id", "attribute" ] }, + "types_i3": { "columns": [ "co_id", "attribute", "value" ] } + } + }, + + "co_settings": { + "comment": "Table definition not yet complete (CFM-80)", + + "columns": { + "id": {}, + "co_id": {}, + "default_address_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "default_email_address_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "default_identifier_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "default_name_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "default_pronoun_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "default_telephone_number_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "default_url_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "permitted_fields_name": { "type": "string", "size": 160 }, + "permitted_fields_telephone_number": { "type": "string", "size": 160 }, + "required_fields_address": { "type": "string", "size": 160 }, + "required_fields_name": { "type": "string", "size": 160 }, + "search_global_limit": { "type": "integer" }, + "search_global_limited_models": { "type": "boolean" } + }, + "indexes": { + "co_settings_i1": { "columns": [ "co_id" ]}, + "co_settings_i2": { + "comment": [ + "We don't really need an index, but DBAL will create one for all foreign keys if none exists", + "typeIsDefault will make queries using these columns, but rarely and won't usually have enough rows to need the index" + ], + "needed": false, + "columns": [ "default_name_type_id" ] + }, + "co_settings_i3": { "columns": [ "default_email_address_type_id" ] }, + "co_settings_i4": { "columns": [ "default_identifier_type_id" ] }, + "co_settings_i5": { "columns": [ "default_address_type_id" ] }, + "co_settings_i6": { "columns": [ "default_pronoun_type_id" ] }, + "co_settings_i7": { "columns": [ "default_telephone_number_type_id" ] }, + "co_settings_i8": { "columns": [ "default_url_type_id" ] } + } + }, + + "authentication_events": { + "columns": { + "id": {}, + "authenticated_identifier": { "type": "string", "size": 256 }, + "authentication_event": { "type": "string", "size": 2 }, + "remote_ip": { "type": "string", "size": 40 } + }, + "indexes": { + "authentication_events_i1": { "columns": [ "authenticated_identifier" ] } + }, + "changelog": false + }, + + "api_users": { + "columns": { + "id": {}, + "co_id": {}, + "username": { "type": "string", "size": 64, "notnull": true }, + "api_key": { "type": "string", "size": 256 }, + "status": {}, + "privileged": { "type": "boolean" }, + "valid_from": {}, + "valid_through": {}, + "remote_ip": { "type": "string", "size": 80 } + }, + "indexes": { + "api_users_i1": { "columns": [ "co_id" ] }, + "api_users_i2": { "columns": [ "username" ] } + } + }, + + "cous": { + "columns": { + "id": {}, + "co_id": {}, + "name": {}, + "description": {}, + "parent_id": { "type": "integer", "foreignkey": { "table": "cous", "column": "id" } }, + "lft": { "type": "integer" }, + "rght": { "type": "integer" } + }, + "indexes": { + "cous_i1": { "columns": [ "co_id" ] }, + "cous_i2": { "columns": [ "name" ] }, + "cous_i3": { "columns": [ "co_id", "name" ] }, + "cous_i4": { "needed": false, "columns": [ "parent_id" ] } + } + }, + + "dashboards": { + "columns": { + "id": {}, + "co_id": {}, + "name": {}, + "description": {} + }, + "indexes": { + "dashboards_i1": { "columns": [ "co_id"] } + } + }, + + "people": { + "columns": { + "id": {}, + "co_id": {}, + "status": {}, + "timezone": { "type": "string", "size": 80 }, + "date_of_birth": { "type": "date" } + }, + "indexes": { + "people_i1": { "columns": [ "co_id" ] } + } + }, + + "external_identities": { + "columns": { + "id": {}, + "person_id": { "notnull": true }, + "source_key": { "type": "string", "size": 512 }, + "status": {}, + "date_of_birth": { "type": "date" } + }, + "indexes": { + "external_identities_i1": { "columns": [ "person_id" ] }, + "external_identities_i2": { "columns": [ "source_key" ] } + } + }, + + "external_identity_roles": { + "columns": { + "id": {}, + "external_identity_id": { "notnull": true }, + "role_key": { "type": "string", "size": 512 }, + "status": {}, + "ordr": {}, + "affiliation_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "title": { "type": "string", "size": 128 }, + "organization": { "type": "string", "size": 128 }, + "department": { "type": "string", "size": 128 }, + "manager_identifier": { "type": "string", "size": 512 }, + "sponsor_identifier": { "type": "string", "size": 512 }, + "valid_from": {}, + "valid_through": {} + }, + "indexes": { + "external_identity_roles_i1": { "columns": [ "external_identity_id" ] }, + "external_identity_roles_i2": { "columns": [ "affiliation_type_id" ] } + } + }, + + "person_roles": { + "columns": { + "id": {}, + "person_id": { "notnull": true }, + "status": {}, + "ordr": {}, + "cou_id": {}, + "affiliation_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "title": { "type": "string", "size": 128 }, + "organization": { "type": "string", "size": 128 }, + "department": { "type": "string", "size": 128 }, + "manager_person_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } }, + "sponsor_person_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } }, + "valid_from": {}, + "valid_through": {}, + "source_external_identity_role_id": { "type": "integer", "foreignkey": { "table": "external_identity_roles", "column": "id" } }, + "frozen": {} + }, + "indexes": { + "person_roles_i1": { "columns": [ "person_id" ] }, + "person_roles_i2": { "columns": [ "sponsor_person_id" ] }, + "person_roles_i3": { "columns": [ "cou_id" ] }, + "person_roles_i4": { "columns": [ "affiliation_type_id" ] }, + "person_roles_i5": { "columns": [ "manager_person_id" ] }, + "person_roles_i6": { "columns": [ "source_external_identity_role_id" ] } + } + }, + + "groups": { + "columns": { + "id": {}, + "co_id": {}, + "cou_id": {}, + "name": {}, + "description": { "size": 256 }, + "open": { "type": "boolean" }, + "status": {}, + "group_type": { "type": "string", "size": 2 }, + "nesting_mode_all": { "type": "boolean" }, + "owners_group_id": { "type": "integer", "foreignkey": { "table": "groups", "column": "id" } } + }, + "indexes": { + "groups_i1": { "columns": [ "co_id" ] }, + "groups_i2": { "columns": [ "co_id", "name" ] }, + "groups_i3": { "columns": [ "co_id", "group_type" ] }, + "groups_i4": { "columns": [ "cou_id", "group_type" ] }, + "groups_i5": { "needed": false, "columns": [ "cou_id" ]}, + "groups_i6": { "needed": false, "columns": [ "owners_group_id" ]} + } + }, + + "group_nestings": { + "columns": { + "id": {}, + "group_id": { "comment": "Calling this 'source_group_id makes things complicated'" }, + "target_group_id": { "type": "integer", "foreignkey": { "table": "groups", "column": "id" } }, + "negate": { "type": "boolean" } + }, + "indexes": { + "group_nestings_i1": { "columns": [ "group_id" ] }, + "group_nestings_i2": { "columns": [ "target_group_id" ] } + } + }, + + "group_members": { + "columns": { + "id": {}, + "group_id": {}, + "person_id": {}, + "valid_from": {}, + "valid_through": {}, + "group_nesting_id": { "type": "integer", "foreignkey": { "table": "group_nestings", "column": "id" }} + }, + "indexes": { + "group_members_i1": { "columns": [ "group_id" ]}, + "group_members_i2": { "columns": [ "person_id" ]}, + "group_members_i3": { "columns": [ "group_id", "person_id" ]}, + "group_members_i4": { "columns": [ "group_nesting_id" ]} + } + }, + + "names": { + "columns": { + "id": {}, + "honorific": { "type": "string", "size": 32 }, + "given": { "type": "string", "size": 128 }, + "middle": { "type": "string", "size": 128 }, + "family": { "type": "string", "size": 128 }, + "suffix": { "type": "string", "size": 32 }, + "type_id": {}, + "language": {}, + "primary_name": { "type": "boolean" }, + "display_name": { "type": "string", "size": 256 } + }, + "indexes": { + "names_i1": { "columns": [ "type_id" ] } + }, + "mvea": [ "person", "external_identity" ], + "sourced": true + }, + + "ad_hoc_attributes": { + "comment": "we use 'tag' instead of 'key' since the latter is reserved by mysql", + + "columns": { + "id": {}, + "tag": { "type": "string", "size": 128 }, + "value": { "type": "string", "size": 256 } + }, + "indexes": { + "ad_hoc_attributes_i1": { "columns": [ "tag" ] } + }, + "mvea": [ "person", "person_role", "external_identity", "external_identity_role" ], + "sourced": true + }, + + "addresses": { + "columns": { + "id": {}, + "street": { "type": "text" }, + "room": { "type": "string", "size": 64 }, + "locality": { "type": "string", "size": 128 }, + "state": { "type": "string", "size": 128 }, + "postal_code": { "type": "string", "size": 16 }, + "country": { "type": "string", "size": 128 }, + "description": {}, + "type_id": {}, + "language": {} + }, + "indexes": { + "addresses_i1": { "columns": [ "type_id" ] } + }, + "mvea": [ "person", "person_role", "external_identity", "external_identity_role" ], + "sourced": true + }, + + "email_addresses": { + "columns": { + "id": {}, + "mail": { "type": "string", "size": 256 }, + "description": {}, + "type_id": {}, + "verified": { "type": "boolean" } + }, + "indexes": { + "email_addresses_i1": { "columns": [ "mail", "type_id", "person_id" ] }, + "email_addresses_i2": { "columns": [ "mail", "type_id", "external_identity_id" ] }, + "email_addresses_i3": { "columns": [ "type_id" ] } + }, + "mvea": [ "person", "external_identity" ], + "sourced": true + }, + + "provisioning_targets": { + "columns": { + "id": {}, + "co_id": {}, + "description": {}, + "plugin": {}, + "status": {}, + "provisioning_group_id": { "type": "integer", "foreignkey": { "table": "groups", "column": "id" } }, + "retry_interval": { "type": "integer" }, + "ordr": {} + }, + "indexes": { + "provisioning_targets_i1": { "columns": [ "co_id" ]}, + "provisioning_targets_i2": { "needed": false, "columns": [ "provisioning_group_id" ] } + } + }, + + "provisioning_history_records": { + "columns": { + "id": {}, + "provisioning_target_id": {}, + "subject_model": { "type": "string", "size": 80 }, + "subjectid": { "type": "integer", "comment": "This is a foreign key, but not at the database level" }, + "person_id": {}, + "group_id": {}, + "status": {}, + "comment": {} + }, + "indexes": { + "provisioning_history_records_i1": { "columns": [ "provisioning_target_id" ] }, + "provisioning_history_records_i2": { "columns": [ "person_id" ] }, + "provisioning_history_records_i3": { "columns": [ "group_id" ] } + } + }, + + "identifiers": { + "columns": { + "id": {}, + "identifier": { "type": "string", "size": 512 }, + "type_id": {}, + "login": { "type": "boolean" }, + "status": {}, + "provisioning_target_id": { "notnull": false } + }, + "indexes": { + "identifiers_i1": { "columns": [ "identifier", "type_id", "person_id" ] }, + "identifiers_i2": { "columns": [ "identifier", "type_id", "external_identity_id" ] }, + "identifiers_i3": { "columns": [ "type_id" ] }, + "identifiers_i4": { "needed": false, "columns": [ "provisioning_target_id" ] } + }, + "mvea": [ "person", "external_identity", "group" ], + "sourced": true + }, + + "pronouns": { + "columns": { + "id": {}, + "pronouns": { "type": "string", "size": 64 }, + "language": {}, + "type_id": {} + }, + "indexes": { + "pronouns_i1": { "columns": [ "type_id" ] } + }, + "mvea": [ "person", "external_identity" ], + "sourced": true + }, + + "telephone_numbers": { + "columns": { + "id": {}, + "country_code": { "type": "string", "size": 3 }, + "area_code": { "type": "string", "size": 8 }, + "number": { "type": "string", "size": 64 }, + "extension": { "type": "string", "size": 16 }, + "description": {}, + "type_id": {} + }, + "indexes": { + "telephone_numbers_i1": { "columns": [ "type_id" ] } + }, + "mvea": [ "person", "person_role", "external_identity", "external_identity_role" ], + "sourced": true + }, + + "urls": { + "columns": { + "id": {}, + "url": { "type": "string", "size": 256 }, + "description": {}, + "type_id": {} + }, + "indexes": { + "urls_i1": { "columns": [ "type_id" ] } + }, + "mvea": [ "person", "external_identity" ], + "sourced": true + }, + + "history_records": { + "columns": { + "id": {}, + "action": { "type": "string", "size": 4 }, + "comment": { "type": "string", "size": 256 }, + "person_id": {}, + "person_role_id": {}, + "external_identity_id": {}, + "external_identity_role_id": {}, + "group_id": {}, + "actor_person_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } }, + "actor_api_user_id": { "type": "integer", "foreignkey": { "table": "api_users", "column": "id" } } + }, + "indexes": { + "history_records_i1": { "columns": [ "person_id" ] }, + "history_records_i2": { "columns": [ "external_identity_id" ] }, + "history_records_i3": { "columns": [ "actor_person_id" ] }, + "history_records_i4": { "columns": [ "person_role_id" ] }, + "history_records_i5": { "columns": [ "external_identity_role_id" ] }, + "history_records_i6": { "columns": [ "group_id" ] }, + "history_records_i7": { "columns": [ "actor_api_user_id" ] } + } + }, + + "jobs": { + "columns": { + "id": {}, + "co_id": {}, + "plugin": {}, + "parameters": { "type": "text" }, + "requeue_interval": { "type": "integer" }, + "retry_interval": { "type": "integer" }, + "requeued_from_job_id": { "type": "integer", "foreignkey": { "table": "jobs", "column": "id" }}, + "status": {}, + "assigned_host": { "type": "string", "size": 64 }, + "assigned_pid": { "type": "integer" }, + "register_summary": { "type": "string", "size": 256 }, + "start_summary": { "type": "string", "size": 256 }, + "finish_summary": { "type": "string", "size": 256 }, + "register_time": { "type": "datetime" }, + "start_after_time": { "type": "datetime" }, + "start_time": { "type": "datetime" }, + "finish_time": { "type": "datetime" }, + "percent_complete": { "type": "integer" } + }, + "indexes": { + "jobs_i1": { "columns": [ "co_id" ] }, + "jobs_i3": { "columns": [ "assigned_host", "assigned_pid" ] }, + "jobs_i4": { "needed": false, "columns": [ "requeued_from_job_id" ] } + }, + "skip-indexes-for-now": { + "jobs_i2": { "columns": [ "co_id", "plugin", "parameters", "status" ] } + } + }, + + "job_history_records": { + "columns": { + "id": {}, + "job_id": { "type": "integer", "foreignkey": { "table": "jobs", "column": "id" }}, + "record_key": { "type": "string", "size": 64 }, + "person_id": {}, + "external_identity_id": {}, + "comment": {}, + "status": {} + }, + "indexes": { + "job_history_records_i1": { "columns": [ "job_id" ] }, + "job_history_records_i2": { "columns": [ "person_id" ] }, + "job_history_records_i3": { "columns": [ "external_identity_id" ] }, + "job_history_records_i4": { "columns": [ "job_id", "record_key" ] } + } + }, + + "servers": { + "columns": { + "id": {}, + "co_id": {}, + "description": {}, + "plugin": {}, + "status": {} + }, + "indexes": { + "servers_i1": { "columns": [ "co_id" ] } + } + }, + + "identifier_assignments": { + "columns": { + "id": {}, + "co_id": {}, + "description": {}, + "plugin": {}, + "status": {}, + "context": {}, + "group_id": {}, + "identifier_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" }, "notnull": false }, + "login": { "type": "boolean" }, + "email_address_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" }, "notnull": false }, + "ordr": {} + }, + "indexes": { + "identifier_assignments_i1": { "columns": [ "co_id" ] }, + "identifier_assignments_i2": { "needed": false, "columns": [ "email_address_type_id" ] }, + "identifier_assignments_i3": { "needed": false, "columns": [ "identifier_type_id" ] } + } + }, + + "pipelines": { + "columns": { + "id": {}, + "co_id": {}, + "description": {}, + "status": {}, + "match_strategy": { "type": "string", "size": 2 }, + "match_email_address_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "match_identifier_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "match_server_id": { "type": "integer", "foreignkey": { "table": "servers", "column": "id" } }, + "sync_status_on_delete": { "type": "string", "size": 2 }, + "sync_affiliation_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "sync_cou_id": { "type": "integer", "foreignkey": { "table": "cous", "column": "id" } }, + "sync_replace_cou_id": { "type": "integer", "foreignkey": { "table": "cous", "column": "id" } }, + "sync_identifier_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } } + }, + "indexes": { + "pipelines_i1": { "columns": [ "co_id" ] }, + "pipelines_i2": { "needed": false, "columns": [ "match_email_address_type_id" ] }, + "pipelines_i3": { "needed": false, "columns": [ "match_server_id" ] }, + "pipelines_i4": { "needed": false, "columns": [ "sync_affiliation_type_id" ] }, + "pipelines_i5": { "needed": false, "columns": [ "sync_cou_id" ] }, + "pipelines_i6": { "needed": false, "columns": [ "sync_replace_cou_id" ] }, + "pipelines_i7": { "needed": false, "columns": [ "sync_identifier_type_id" ] }, + "pipelines_i8": { "needed": false, "columns": [ "match_identifier_type_id" ] } + } + }, + + "external_identity_sources": { + "columns": { + "id": {}, + "co_id": {}, + "description": {}, + "plugin": {}, + "status": {}, + "sor_label": { "type": "string", "size": 40 }, + "pipeline_id": { "type": "integer", "foreignkey": { "table": "pipelines", "column": "id" }, "notnull": true }, + "hash_source_record": { "type": "boolean" } + }, + "indexes": { + "external_identity_sources_i1": { "columns": [ "co_id" ] }, + "external_identity_sources_i2": { "columns": [ "sor_label"] }, + "external_identity_sources_i3": { "needed": false, "columns": [ "pipeline_id" ] } + } + }, + + "ext_identity_source_records": { + "comment": "This table should be called external_identity_source_records but then we exceed Cake's 61 character alias.field limit with ExternalIdentitySourceRecords.external_identity_source_record_id", + "columns": { + "id": {}, + "external_identity_source_id": { "type": "integer", "foreignkey": { "table": "external_identity_sources", "column": "id" }, "notnull": true }, + "source_key": { "type": "string", "size": 1024 }, + "source_record": { "type": "text" }, + "last_update": { "type": "datetime" }, + "external_identity_id": {}, + "reference_identifier": {} + }, + "indexes": { + "ext_identity_sources_records_i1": { "columns": [ "external_identity_source_id" ] }, + "ext_identity_sources_records_i2": { "columns": [ "external_identity_id" ] }, + "ext_identity_sources_records_i3": { "columns": [ "external_identity_source_id", "source_key" ] } + } + } + }, + + "drop-tables":[ + { + "comment": "A list of tables to manually drop, not yet implemented -- actually are we going to need this? DBAL seems to be able to figure it out... (CO-672)" + } + ] +} \ No newline at end of file diff --git a/app/config/schema/sessions.sql b/app/config/schema/sessions.sql new file mode 100644 index 000000000..1aa0a0f54 --- /dev/null +++ b/app/config/schema/sessions.sql @@ -0,0 +1,15 @@ +# Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) +# +# Licensed under The MIT License +# For full copyright and license information, please see the LICENSE.txt +# Redistributions of files must retain the above copyright notice. +# MIT License (https://opensource.org/licenses/mit-license.php) + +CREATE TABLE `sessions` ( + `id` char(40) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `created` datetime DEFAULT CURRENT_TIMESTAMP, -- optional, requires MySQL 5.6.5+ + `modified` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, -- optional, requires MySQL 5.6.5+ + `data` blob DEFAULT NULL, -- for PostgreSQL use bytea instead of blob + `expires` int(10) unsigned DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; diff --git a/app/index.php b/app/index.php new file mode 100644 index 000000000..459176916 --- /dev/null +++ b/app/index.php @@ -0,0 +1,16 @@ + + + + + + diff --git a/app/phpstan.neon b/app/phpstan.neon new file mode 100644 index 000000000..28cf5ba94 --- /dev/null +++ b/app/phpstan.neon @@ -0,0 +1,8 @@ +parameters: + level: 7 + checkMissingIterableValueType: false + treatPhpDocTypesAsCertain: false + paths: + - src + excludes_analyse: + - src/Console/Installer.php diff --git a/app/phpunit.xml.dist b/app/phpunit.xml.dist new file mode 100644 index 000000000..710712222 --- /dev/null +++ b/app/phpunit.xml.dist @@ -0,0 +1,40 @@ + + + + + + + + + + + tests/TestCase/ + + + + + + + + + + + + + + + + + src/ + plugins/*/src/ + + src/Console/Installer.php + + + + diff --git a/app/plugins/CoreAssigner/README.md b/app/plugins/CoreAssigner/README.md new file mode 100644 index 000000000..23bd1ab90 --- /dev/null +++ b/app/plugins/CoreAssigner/README.md @@ -0,0 +1,11 @@ +# CoreAssigner plugin for CakePHP + +## Installation + +You can install this plugin into your CakePHP application using [composer](https://getcomposer.org). + +The recommended way to install composer packages is: + +``` +composer require your-name-here/core-assigner +``` diff --git a/app/plugins/CoreAssigner/composer.json b/app/plugins/CoreAssigner/composer.json new file mode 100644 index 000000000..627cf1916 --- /dev/null +++ b/app/plugins/CoreAssigner/composer.json @@ -0,0 +1,24 @@ +{ + "name": "your-name-here/core-assigner", + "description": "CoreAssigner plugin for CakePHP", + "type": "cakephp-plugin", + "license": "MIT", + "require": { + "php": ">=7.2", + "cakephp/cakephp": "4.4.*" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.3" + }, + "autoload": { + "psr-4": { + "CoreAssigner\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "CoreAssigner\\Test\\": "tests/", + "Cake\\Test\\": "vendor/cakephp/cakephp/tests/" + } + } +} diff --git a/app/plugins/CoreAssigner/phpunit.xml.dist b/app/plugins/CoreAssigner/phpunit.xml.dist new file mode 100644 index 000000000..96d774cf4 --- /dev/null +++ b/app/plugins/CoreAssigner/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + + + + + tests/TestCase/ + + + + + + + + + + + src/ + + + diff --git a/app/plugins/CoreAssigner/resources/locales/en_US/core_assigner.po b/app/plugins/CoreAssigner/resources/locales/en_US/core_assigner.po new file mode 100644 index 000000000..0e3112e84 --- /dev/null +++ b/app/plugins/CoreAssigner/resources/locales/en_US/core_assigner.po @@ -0,0 +1,74 @@ +# COmanage Registry Localizations (core_assigner domain) +# +# Portions licensed to the University Corporation for Advanced Internet +# Development, Inc. ("UCAID") under one or more contributor license agreements. +# See the NOTICE file distributed with this work for additional information +# regarding copyright ownership. +# +# UCAID licenses this file to you under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with the +# License. You may obtain a copy of the License at: +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# @link https://www.internet2.edu/comanage COmanage Project +# @package registry-plugins +# @since COmanage Registry v5.0.0 +# @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + +msgid "controller.FormatAssigners" +msgstr "{0,plural,=1{Format Assigner} other{Format Assigners}}" + +msgid "enumeration.CollisionModeEnum.R" +msgstr "Random" + +msgid "enumeration.CollisionModeEnum.S" +msgstr "Sequential" + +msgid "enumeration.PermittedCharactersEnum.AN" +msgstr "AlphaNumeric Only" + +msgid "enumeration.PermittedCharactersEnum.AD" +msgstr "AlphaNumeric and Dot, Dash, Underscore" + +msgid "enumeration.PermittedCharactersEnum.AQ" +msgstr "AlphaNumeric and Dot, Dash, Underscore, Apostrophe" + +msgid "enumeration.PermittedCharactersEnum.AL" +msgstr "Any" + +msgid "field.FormatAssigners.collision_mode" +msgstr "Collision Mode" + +msgid "field.FormatAssigners.collision_mode.desc" +msgstr "How to assign collision numbers when required" + +msgid "field.FormatAssigners.format" +msgstr "Format" + +msgid "field.FormatAssigners.format.desc" +msgstr "See the documentation for details" + +msgid "field.FormatAssigners.maximum" +msgstr "Maximum Collision Value" + +msgid "field.FormatAssigners.maximum.desc" +msgstr "The maximum value for randomly generated collision numbers" + +msgid "field.FormatAssigners.minimum" +msgstr "Minimum Collision Value" + +msgid "field.FormatAssigners.minimum.desc" +msgstr "The minimum value for randomly generated collision numbers, or the starting value for sequences" + +msgid "field.FormatAssigners.permitted_characters" +msgstr "Permitted Characters" + +msgid "field.FormatAssigners.permitted_characters.desc" +msgstr "When substituting parameters in a format, only permit these characters to be used" diff --git a/app/plugins/CoreAssigner/src/Controller/AppController.php b/app/plugins/CoreAssigner/src/Controller/AppController.php new file mode 100644 index 000000000..5f6437e52 --- /dev/null +++ b/app/plugins/CoreAssigner/src/Controller/AppController.php @@ -0,0 +1,10 @@ + [ + 'FormatAssigners.format' => 'asc' + ] + ]; +} diff --git a/app/plugins/CoreAssigner/src/CoreAssignerPlugin.php b/app/plugins/CoreAssigner/src/CoreAssignerPlugin.php new file mode 100644 index 000000000..fcf1f19dc --- /dev/null +++ b/app/plugins/CoreAssigner/src/CoreAssignerPlugin.php @@ -0,0 +1,93 @@ +plugin( + 'CoreAssigner', + ['path' => '/core-assigner'], + function (RouteBuilder $builder) { + // Add custom routes here + + $builder->fallbacks(); + } + ); + parent::routes($routes); + } + + /** + * Add middleware for the plugin. + * + * @param \Cake\Http\MiddlewareQueue $middlewareQueue The middleware queue to update. + * @return \Cake\Http\MiddlewareQueue + */ + public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue + { + // Add your middlewares here + + return $middlewareQueue; + } + + /** + * Add commands for the plugin. + * + * @param \Cake\Console\CommandCollection $commands The command collection to update. + * @return \Cake\Console\CommandCollection + */ + public function console(CommandCollection $commands): CommandCollection + { + // Add your commands here + + $commands = parent::console($commands); + + return $commands; + } + + /** + * Register application container services. + * + * @param \Cake\Core\ContainerInterface $container The Container to update. + * @return void + * @link https://book.cakephp.org/4/en/development/dependency-injection.html#dependency-injection + */ + public function services(ContainerInterface $container): void + { + // Add your services here + } +} diff --git a/app/plugins/CoreAssigner/src/Lib/Enum/CollisionModeEnum.php b/app/plugins/CoreAssigner/src/Lib/Enum/CollisionModeEnum.php new file mode 100644 index 000000000..1c107f8fe --- /dev/null +++ b/app/plugins/CoreAssigner/src/Lib/Enum/CollisionModeEnum.php @@ -0,0 +1,37 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/CoreAssigner/src/Model/Entity/FormatAssignerSequence.php b/app/plugins/CoreAssigner/src/Model/Entity/FormatAssignerSequence.php new file mode 100644 index 000000000..de4e99e81 --- /dev/null +++ b/app/plugins/CoreAssigner/src/Model/Entity/FormatAssignerSequence.php @@ -0,0 +1,49 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/CoreAssigner/src/Model/Table/FormatAssignerSequencesTable.php b/app/plugins/CoreAssigner/src/Model/Table/FormatAssignerSequencesTable.php new file mode 100644 index 000000000..af501fab1 --- /dev/null +++ b/app/plugins/CoreAssigner/src/Model/Table/FormatAssignerSequencesTable.php @@ -0,0 +1,154 @@ +addBehavior('Timestamp'); + + // This is sort of a hybrid of configuration and artifact... + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('CoreAssigner.FormatAssigners'); + + $this->setDisplayField('affix'); + + $this->setPrimaryLink('CoreAssigner.format_assigner_id'); + $this->setRequiresCO(true); + } + + /** + * Obtain the next sequence number for the specified identifier assignment. + * NOTE: This method should be called from within a transaction + * + * @since COmanage Registry v5.0.0 + * @param int $formatAssignerId Format Assigner ID + * @param string $affix Affix to obtain a sequence number for + * @param int $start Initial value to return if sequence not yet started + * @return int Next sequence + */ + + public function next( + int $formatAssignerId, + string $affix, + int $start + ): int { + // We're basically implementing sequences. We don't actually use sequences + // because dynamically creating sequences is a recipe for platform dependent + // coding. + + $newCount = 1; + + if($start && $start > -1) { + $newCount = $start; + } + + // Get the current value for this affix. We need to use FOR UPDATE in case + // another process is trying to assign the same sequence number at the same time. + + $cur = $this->find() + ->where([ + 'FormatAssignerSequences.format_assigner_id' => $formatAssignerId, + 'FormatAssignerSequences.affix' => $affix + ]) + ->epilog('FOR UPDATE') + ->first(); + + if(!empty($cur)) { + // Increment an existing counter + + $newCount = $cur->last + 1; + + $cur->last = $newCount; + + $this->save($cur); + } else { + // Start a new counter + + $seq = $this->newEntity([ + 'format_assigner_id' => $formatAssignerId, + 'affix' => $affix, + 'start' => $newCount + ]); + + $this->save($seq); + } + + return $newCount; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('format_assigner_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('format_assigner_id'); + + $this->registerStringValidation($validator, $schema, 'affix', true); + + $validator->add('last', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('last'); + + return $validator; + } +} diff --git a/app/plugins/CoreAssigner/src/Model/Table/FormatAssignersTable.php b/app/plugins/CoreAssigner/src/Model/Table/FormatAssignersTable.php new file mode 100644 index 000000000..759b4caea --- /dev/null +++ b/app/plugins/CoreAssigner/src/Model/Table/FormatAssignersTable.php @@ -0,0 +1,548 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + // Timestamp behavior handles created/modified updates + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('IdentifierAssignments'); + + $this->hasMany('CoreAssigner.FormatAssignerSequences') + ->setDependent(true) + ->setCascadeCallbacks(true); + + $this->setDisplayField('format'); + + $this->setPrimaryLink('identifier_assignment_id'); + $this->setRequiresCO(true); + + $this->setAutoViewVars([ + 'collisionModes' => [ + 'type' => 'enum', + 'class' => 'CoreAssigner.CollisionModeEnum' + ], + 'permittedCharacters' => [ + 'type' => 'enum', + 'class' => 'CoreAssigner.PermittedCharactersEnum' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, // Delete the pluggable object instead + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ +// XXX do we need to fix this for other plugin entry point models? + 'add' => false, // This is added by the parent model + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Assign an identifier. + * + * @since COmanage Registry v5.0.0 + * @param IdentifierAssignment $ia Identifier Assignment describing the requested configuration + * @param object $entity The entity (Person, Group, Department) to assign an Identifier for + * @return string The newly proposed Identifier + * @throws InvalidArgumentException + * @throws RuntimeException + */ + + public function assign($ia, $entity): string { + // Generate the new identifier. This requires several steps. First, substitute + // non-collision number parameters to generate our base. If substituteParameters() + // fails, it'll throw an Exception that we let bubble up. + $base = $this->substituteParameters( + $entity, + // If no format is specified, default to "(#)". + $ia->format_assigner->format ?? "(#)", + $ia->format_assigner->permitted_characters + ); + + // Now that we've got our base, loop until we get a unique identifier. + // We try a maximum of 10 (0 through 9) times, and track identifiers we've + // seen already. + + $tested = []; + $ret = null; + + // PAR-FormatAssigner-1 A maximum of 10 attempts will be made to assign an Identifier. + for($i = 0;$i < 10;$i++) { + $sequenced = $this->selectSequences( + $base, + $i, + $ia->format_assigner->permitted_characters + ); + + // There may or may not be a collision number format. If so, we should end + // up with a unique candidate (though for random it's possible we won't). + $candidate = $this->assignCollisionNumber( + $ia->format_assigner->id, + $sequenced, + $ia->format_assigner->collision_mode, + $ia->format_assigner->minimum ?? 0, + $ia->format_assigner->maximum + ); + + if(!in_array($candidate, $tested) + // Also check that we didn't get an empty string + && trim($candidate) != false) { + // We have a new candidate (ie: one that wasn't generated on a previous loop), + // so let's see if it is already in use. + + try { + $this->IdentifierAssignments->checkAvailability( + (!empty($ia->identifier_type_id) ? 'Identifiers' : 'EmailAddresses'), + (!empty($ia->identifier_type_id) ? $ia->identifier_type_id : $ia->email_address_type_id), + $candidate, + $entity + ); + + $ret = $candidate; + } + catch(\OverflowException $e) { + // The Identifier we generated is in use, try again + $this->llog('trace', "Generated candidate identifier $candidate, but it is already in use"); + } + + if($ret) { + break; + } + + // else try the next one + $tested[] = $candidate; + } + } + + if(!$ret) { + throw new \RuntimeException(__d('error', 'IdentifierAssignments.failed')); + } + + return $ret; + } + + /** + * Assign a collision number if the current identifier segment accepts one. + * + * @since COmanage Registry v5.0.0 + * @param int $formatAssignerId Format Assigner ID + * @param string $sequenced Sequenced string as returned by selectSequences() + * @param CollisionModeEnum $collisionMode Collision number assignment mode + * @param int $min Minimum number to assign + * @param int $max Maximum number to assign (for Random mode only) + * @return string Candidate string, possibly with a collision number assigned + * @throws InvalidArgumentException + */ + + protected function assignCollisionNumber( + int $formatAssignerId, + string $sequenced, + string $collisionMode, + int $min, + ?int $max=null + ): string { + // We expect $sequenced to be %s and not %d in order to be able to ensure + // a specific width (ie: padded and/or truncated). This also makes sense in that + // identifiers are really strings, not numbers. + + $matches = []; + + if(preg_match('/\%[0-9.]*s/', $sequenced, $matches)) { + switch($collisionMode) { + case CollisionModeEnum::Random: + // Simply pick a number between $min and $max. + + $lmax = $max; + + if(!$max) { + // We have to be a bit careful with min and max vs mt_rand(). substituteParameters() + // will generate something like (%05.5s). If no explicit $max is configured by the + // admin, we used mt_getrandmax. However, that could generate a string like 172500398. + // We take the first (eg) 5 digits, which are "17250". If $min is 20000, we'll + // incorrectly assign a collision number outside the permitted range (CO-1933). + + // Pull the width out of the string + $width = (int)rtrim(ltrim(strstr($matches[0], '.'), "."), "s"); + + // And calculate a new max + $lmax = (10 ** $width) - 1; + } + + $n = random_int($min, $lmax); + return sprintf($sequenced, $n); + break; + case CollisionModeEnum::Sequential: + return sprintf($sequenced, $this->FormatAssignerSequences->next( + formatAssignerId: $formatAssignerId, + affix: $sequenced, + start: $min)); + break; + default: + throw new InvalidArgumentException(__d('error', 'unknown', $algorithm)); + break; + } + } else { + // Nothing to do, just return the same string + + return $sequenced; + } + } + + /** + * Select the sequenced segments to be processed for the given iteration. + * + * @since COmanage Registry v5.0.0 + * @param string $base Base string as returned by substituteParameters + * @param int $iteration Iteration number (between 0 and 9) + * @param PermittedCharactersEnum $permitted Acceptable characters for substituted parameters + * @return string Format with sequenced segments selected + */ + + protected function selectSequences( + string $base, + int $iteration, + string $permitted + ): string { + $sequenced = ""; + + // Loop through the string + for($j = 0;$j < strlen($base);$j++) { + switch($base[$j]) { + case '\\': + // Copy the next character directly + if($j+1 < strlen($base)) { + $j++; + $sequenced .= $base[$j]; + } + break; + case '[': + // Sequenced segment + + // Single Use segments are only incorporated into the specified iteration, + // vs Additive segments that are incorporated into all subsequent ones as well. + $singleuse = false; + + if($j+1 < strlen($base) && $base[$j+1] == '=') { + $singleuse = true; + $j++; + } + + if($j+3 < strlen($base)) { + $j++; + + if(($singleuse && ($base[$j] == $iteration)) + || + (!$singleuse && ($base[$j] <= $iteration))) { + // This segment is now in effect, copy until we see a close bracket + // (and jump past the ':') + $j += 2; + + // Assemble the text for this segment. If after parameter substitution + // we end up with no permitted characters, skip this segment + + $segtext = ""; + + while($base[$j] != ']') { + $segtext .= $base[$j]; + $j++; + } + + if(strlen($segtext) > 0 + && preg_match('/'. PermittedCharactersEnum::getPermittedCharacters($permitted) . '/', $segtext)) { + $sequenced .= $segtext; + } + } else { + // Move to end of segment, we're not using this one yet + + while($base[$j] != ']') { + $j++; + } + } + } + break; + default: + // Just copy this character + $sequenced .= $base[$j]; + break; + } + } + + return $sequenced; + } + + /** + * Perform parameter substitution on an identifier format to generate the base + * string used in identifier assignment. + * + * @since COmanage Registry v5.0.0 + * @param EntityInterface $entity Entity to assign Identifier for + * @param string $format Identifier assignment format + * @param PermittedCharactersEnum $permitted Acceptable characters for substituted parameters + * @return string Identifier with paramaters substituted + * @throws RuntimeException + */ + + protected function substituteParameters( + $entity, + string $format, + string $permitted + ): string { + $base = ""; + + // For random letter generation ('h', 'r', 'R') + $randomCharSet = array( + 'h' => "0123456789abcdef", + 'l' => "abcdefghijkmnopqrstuvwxyz", // Note no "l" + 'L' => "ACDEFGHIJKLMNPQPTUVWXYZ" // Note no "B", "O", or "S" (similar to 8,0,5) + ); + + // Loop through the format string + for($i = 0;$i < strlen($format);$i++) { + switch($format[$i]) { + case '\\': + // Copy the next character directly + if($i+1 < strlen($format)) { + $i++; + $base .= $format[$i]; + } + break; + case '(': + // Parameter to substitute + if($i+2 < strlen($format)) { + // Move past '(' + $i++; + + $width = ""; + + // Check if the next character is a width specifier + if($format[$i+1] == ':') { + // Don't advance $i yet since we still need it, so use $j instead + for($j = $i+2;$j < strlen($format);$j++) { + if($format[$j] != ')') { + $width .= $format[$j]; + } else { + break; + } + } + } + + // Do the actual parameter replacement, blocking out characters that aren't permitted + + $charregex = '/'. PermittedCharactersEnum::getPermittedCharacters(enum: $permitted, invert: true) . '/'; + + switch($format[$i]) { + case 'f': + $base .= sprintf("%.".$width."s", + preg_replace($charregex, '', strtolower($entity->primary_name->family))); + break; + case 'F': + $base .= sprintf("%.".$width."s", + preg_replace($charregex, '', $entity->primary_name->family)); + break; + case 'g': + $base .= sprintf("%.".$width."s", + preg_replace($charregex, '', strtolower($entity->primary_name->given))); + break; + case 'G': + $base .= sprintf("%.".$width."s", + preg_replace($charregex, '', $entity->primary_name->given)); + break; + // Note 'h' is defined with 'l', below + // case 'h': + case 'I': + // We skip the next character (a slash) and then continue reading + // until we get to a close parenthesis + $identifierType = ""; + + $i+=2; + + while($format[$i] != ')' && $i < strlen($format)) { + $identifierType .= $format[$i]; + $i++; + } + + // Rewind one character because we're going to advance past it + // again below. + $i--; + + if($identifierType == "") { + throw new \RuntimeException(__d('error', 'IdentifierAssignments.type.none')); + } + + // If we find more than one identifier of the same type, we + // arbitrarily pick the first. We should be able to use Hash + // to do this, but our type label appears to be one level too + // deep. (The alternative would be to use Types->getTypeId but + // then that adds another database call.) + + $id = null; + + foreach($entity->identifiers as $idx) { + if($idx->type->value == $identifierType) { + $id = $idx->identifier; + break; + } + } + + if(empty($id)) { + throw new \RuntimeException(__d('error', 'IdentifierAssignments.type.notfound', $identifierType)); + } + + $base .= sprintf("%.".$width."s", + preg_replace($charregex, '', $id)); + break; + case 'h': + case 'l': + case 'L': + for($j = 0;$j < ($width != "" ? $width : 1);$j++) { + $base .= $randomCharSet[ $format[$i] ][ mt_rand(0, strlen($randomCharSet[ $format[$i] ])-1) ]; + } + break; + case 'm': + $base .= sprintf("%.".$width."s", + preg_replace($charregex, '', strtolower( $entity->primary_name->middle))); + break; + case 'M': + $base .= sprintf("%.".$width."s", + preg_replace($charregex, '', $entity->primary_name->middle)); + break; + case 'n': + $base .= sprintf("%.".$width."s", + preg_replace($charregex, '', strtolower($entity->name))); + break; + case 'N': + $base .= sprintf("%.".$width."s", + preg_replace($charregex, '', $entity->name)); + break; + case '#': + // Convert the collision number parameter to a sprintf style specification, + // left padded with 0s. Note that assignCollisionNumber expects %s, not %d. + $base .= "%" . ($width != "" ? ("0" . $width . "." . $width) : "") . "s"; + break; + } + + // Move past the width specifier + if($width != "") { + $i += strlen($width) + 1; + } + + // Move past the ')' + $i++; + } + break; + default: + // Just copy this character + $base .= $format[$i]; + break; + } + } + + return $base; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('identifier_assignment_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('identifier_assignment_id'); + + $this->registerStringValidation($validator, $schema, 'format', true); + + $validator->add('minimum', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('minimum'); + + $validator->add('maximum', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('maximum'); + + $validator->add('collision_mode', [ + 'content' => ['rule' => ['inList', CollisionModeEnum::getConstValues()]] + ]); + $validator->notEmptyString('collision_mode'); + + $validator->add('permitted_characters', [ + 'content' => ['rule' => ['inList', PermittedCharactersEnum::getConstValues()]] + ]); + $validator->notEmptyString('permitted_characters'); + + return $validator; + } +} diff --git a/app/plugins/CoreAssigner/src/config/plugin.json b/app/plugins/CoreAssigner/src/config/plugin.json new file mode 100644 index 000000000..66155266a --- /dev/null +++ b/app/plugins/CoreAssigner/src/config/plugin.json @@ -0,0 +1,38 @@ +{ + "types": { + "assigner": [ + "FormatAssigners" + ] + }, + "schema": { + "tables": { + "format_assigners": { + "columns": { + "id": {}, + "identifier_assignment_id": {}, + "format": { "type": "string", "size": 256 }, + "minimum": { "type": "integer" }, + "maximum": { "type": "integer" }, + "collision_mode": { "type": "string", "size": 2 }, + "permitted_characters": { "type": "string", "size": 2 } + }, + "indexes": { + "format_assigners_i1": { "columns": [ "identifier_assignment_id" ]} + } + }, + "format_assigner_sequences": { + "columns": { + "id": {}, + "format_assigner_id": { "type": "integer", "foreignkey": { "table": "format_assigners", "column": "id" } }, + "affix": { "type": "string", "size": 256 }, + "last": { "type": "integer" } + }, + "indexes": { + "format_assigner_sequences_i1": { "columns": [ "format_assigner_id", "affix" ], "unique": true }, + "format_assigner_sequences_i2": { "needed": false, "columns": [ "format_assigner_id" ] } + }, + "changelog": false + } + } + } +} \ No newline at end of file diff --git a/app/plugins/CoreAssigner/templates/FormatAssigners/fields.inc b/app/plugins/CoreAssigner/templates/FormatAssigners/fields.inc new file mode 100644 index 000000000..f382bc570 --- /dev/null +++ b/app/plugins/CoreAssigner/templates/FormatAssigners/fields.inc @@ -0,0 +1,64 @@ + + + +Field->control('format'); + + print $this->Field->control( + fieldName: 'collision_mode', + options: [ + 'onChange' => 'updateGadgets()' + ] + ); + + print $this->Field->control('permitted_characters'); + + print $this->Field->control('minimum'); + + print $this->Field->control('maximum'); +} diff --git a/app/plugins/CoreAssigner/tests/bootstrap.php b/app/plugins/CoreAssigner/tests/bootstrap.php new file mode 100644 index 000000000..2985f3a43 --- /dev/null +++ b/app/plugins/CoreAssigner/tests/bootstrap.php @@ -0,0 +1,55 @@ +loadSqlFiles('tests/schema.sql', 'test'); diff --git a/app/plugins/CoreAssigner/tests/schema.sql b/app/plugins/CoreAssigner/tests/schema.sql new file mode 100644 index 000000000..20f50320d --- /dev/null +++ b/app/plugins/CoreAssigner/tests/schema.sql @@ -0,0 +1 @@ +-- Test database schema for CoreAssigner diff --git a/app/plugins/CoreAssigner/webroot/.gitkeep b/app/plugins/CoreAssigner/webroot/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/app/plugins/CoreServer/README.md b/app/plugins/CoreServer/README.md new file mode 100644 index 000000000..984a690c6 --- /dev/null +++ b/app/plugins/CoreServer/README.md @@ -0,0 +1,11 @@ +# CoreServer plugin for CakePHP + +## Installation + +You can install this plugin into your CakePHP application using [composer](https://getcomposer.org). + +The recommended way to install composer packages is: + +``` +composer require your-name-here/core-server +``` diff --git a/app/plugins/CoreServer/composer.json b/app/plugins/CoreServer/composer.json new file mode 100644 index 000000000..471af66a7 --- /dev/null +++ b/app/plugins/CoreServer/composer.json @@ -0,0 +1,24 @@ +{ + "name": "comanage-registry/core-server", + "description": "CoreServer plugin for COmanage Registry", + "type": "cakephp-plugin", + "license": "MIT", + "require": { + "php": ">=7.2", + "cakephp/cakephp": "4.4.*" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.3" + }, + "autoload": { + "psr-4": { + "CoreServer\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "CoreServer\\Test\\": "tests/", + "Cake\\Test\\": "vendor/cakephp/cakephp/tests/" + } + } +} diff --git a/app/plugins/CoreServer/phpunit.xml.dist b/app/plugins/CoreServer/phpunit.xml.dist new file mode 100644 index 000000000..dc201acf9 --- /dev/null +++ b/app/plugins/CoreServer/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + + + + + tests/TestCase/ + + + + + + + + + + + src/ + + + diff --git a/app/plugins/CoreServer/resources/locales/en_US/core_server.po b/app/plugins/CoreServer/resources/locales/en_US/core_server.po new file mode 100644 index 000000000..5a8660f67 --- /dev/null +++ b/app/plugins/CoreServer/resources/locales/en_US/core_server.po @@ -0,0 +1,58 @@ +# COmanage Registry Localizations (core_server domain) +# +# Portions licensed to the University Corporation for Advanced Internet +# Development, Inc. ("UCAID") under one or more contributor license agreements. +# See the NOTICE file distributed with this work for additional information +# regarding copyright ownership. +# +# UCAID licenses this file to you under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with the +# License. You may obtain a copy of the License at: +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# @link https://www.internet2.edu/comanage COmanage Project +# @package registry-plugins +# @since COmanage Registry v5.0.0 +# @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + +msgid "controller.SqlServers" +msgstr "{0,plural,=1{SQL Server} other{SQL Servers}}" + +msgid "enumeration.RdbmsTypeEnum.LT" +msgstr "SQLite" + +msgid "enumeration.RdbmsTypeEnum.MA" +msgstr "MariaDB" + +msgid "enumeration.RdbmsTypeEnum.MS" +msgstr "MS SQL Server" + +msgid "enumeration.RdbmsTypeEnum.MY" +msgstr "MySQL" + +# XXX Not yet supported +#msgid "enumeration.RdbmsTypeEnum.OR" +#msgstr "Oracle" + +msgid "enumeration.RdbmsTypeEnum.PG" +msgstr "Postgres" + +msgid "field.SqlServers.databas" +msgstr "Database Name" + +msgid "field.SqlServers.hostname" +msgstr "Hostname" + +# XXX Temporary? +msgid "field.SqlServers.password" +msgstr "Password" + +msgid "field.SqlServers.type" +msgstr "RDBMS Type" diff --git a/app/plugins/CoreServer/src/Controller/AppController.php b/app/plugins/CoreServer/src/Controller/AppController.php new file mode 100644 index 000000000..554055701 --- /dev/null +++ b/app/plugins/CoreServer/src/Controller/AppController.php @@ -0,0 +1,10 @@ + [ + 'SqlServers.hostname' => 'asc' + ] + ]; +} diff --git a/app/plugins/CoreServer/src/CoreServerPlugin.php b/app/plugins/CoreServer/src/CoreServerPlugin.php new file mode 100644 index 000000000..6711a5cf7 --- /dev/null +++ b/app/plugins/CoreServer/src/CoreServerPlugin.php @@ -0,0 +1,93 @@ +plugin( + 'CoreServer', + ['path' => '/core-server'], + function (RouteBuilder $builder) { + // Add custom routes here + + $builder->fallbacks(); + } + ); + parent::routes($routes); + } + + /** + * Add middleware for the plugin. + * + * @param \Cake\Http\MiddlewareQueue $middlewareQueue The middleware queue to update. + * @return \Cake\Http\MiddlewareQueue + */ + public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue + { + // Add your middlewares here + + return $middlewareQueue; + } + + /** + * Add commands for the plugin. + * + * @param \Cake\Console\CommandCollection $commands The command collection to update. + * @return \Cake\Console\CommandCollection + */ + public function console(CommandCollection $commands): CommandCollection + { + // Add your commands here + + $commands = parent::console($commands); + + return $commands; + } + + /** + * Register application container services. + * + * @param \Cake\Core\ContainerInterface $container The Container to update. + * @return void + * @link https://book.cakephp.org/4/en/development/dependency-injection.html#dependency-injection + */ + public function services(ContainerInterface $container): void + { + // Add your services here + } +} diff --git a/app/plugins/CoreServer/src/Lib/Enum/RdbmsTypeEnum.php b/app/plugins/CoreServer/src/Lib/Enum/RdbmsTypeEnum.php new file mode 100644 index 000000000..a6be23625 --- /dev/null +++ b/app/plugins/CoreServer/src/Lib/Enum/RdbmsTypeEnum.php @@ -0,0 +1,46 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/CoreServer/src/Model/Table/SqlServersTable.php b/app/plugins/CoreServer/src/Model/Table/SqlServersTable.php new file mode 100644 index 000000000..f4d9851fc --- /dev/null +++ b/app/plugins/CoreServer/src/Model/Table/SqlServersTable.php @@ -0,0 +1,175 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + // Timestamp behavior handles created/modified updates + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('Servers'); + + $this->setDisplayField('hostname'); + + $this->setPrimaryLink('server_id'); + $this->setRequiresCO(true); + + $this->setAutoViewVars([ + 'types' => [ + 'type' => 'enum', + 'class' => 'CoreServer.RdbmsTypeEnum' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, // Delete the pluggable object instead + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Establish a connection (via Cake's ConnectionManager) to the specified SQL server. + * + * @since COmanage Registry v5.0.0 + * @param int $serverId Server ID (NOT SqlServer ID) + * @param string $name Connection name, used for subsequent access via Models + * @return bool true on success + * @throws Exception + */ + + public function connect(int $serverId, string $name): bool { + // Note if you're looking to add support for tablePrefix here (eg: "cm_") + // Cake basically dropped support for that in v3. As an alternate, + // individual models can be configured to use alternate table names, + // which is basically what the SQL Provisioner does. + + // Pull our configuration via the parent Server object. + $server = $this->Servers->get($serverId, ['contain' => ['SqlServers']]); + + $dbmap = [ + RdbmsTypeEnum::MariaDB => 'Mysql', + RdbmsTypeEnum::MySQL => 'Mysql', + RdbmsTypeEnum::Postgres => 'Postgres', + RdbmsTypeEnum::SQLite => 'Sqlite', + RdbmsTypeEnum::SqlServer => 'Sqlserver' + ]; + + $dbconfig = [ + 'className' => 'Cake\Database\Connection', + 'driver' => "Cake\Database\Driver\\" . $dbmap[$server->sql_server->type], + 'persistent' => false, + 'host' => $server->sql_server->hostname, + 'username' => $server->sql_server->username, + 'password' => $server->sql_server->password, + 'database' => $server->sql_server->databas, + 'quoteIdentifiers' => false, + 'encoding' => 'utf8', + 'timezone' => 'UTC' + ]; + + // We need to drop the existing configuration before we can reconfigure it + ConnectionManager::drop('targetdb'); + + ConnectionManager::setConfig('targetdb', $dbconfig); + + return true; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('server_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('server_id'); + + $validator->add('type', [ + 'content' => ['rule' => ['inList', RdbmsTypeEnum::getConstValues()]] + ]); + $validator->notEmptyString('type'); + + $this->registerStringValidation($validator, $schema, 'hostname', true); + + $this->registerStringValidation($validator, $schema, 'databas', true); + + $this->registerStringValidation($validator, $schema, 'username', false); + + $this->registerStringValidation($validator, $schema, 'password', false); + + return $validator; + } +} diff --git a/app/plugins/CoreServer/src/config/plugin.json b/app/plugins/CoreServer/src/config/plugin.json new file mode 100644 index 000000000..876ade57f --- /dev/null +++ b/app/plugins/CoreServer/src/config/plugin.json @@ -0,0 +1,25 @@ +{ + "types": { + "server": [ + "SqlServers" + ] + }, + "schema": { + "tables": { + "sql_servers": { + "columns": { + "id": {}, + "server_id": {}, + "type": { "type": "string", "size": 2 }, + "hostname": { "type": "string", "size": 128 }, + "databas": { "type": "string", "size": 128 }, + "username": { "type": "string", "size": 128 }, + "password": { "type": "string", "size": 80 } + }, + "indexes": { + "sql_servers_i1": { "columns": [ "server_id" ]} + } + } + } + } +} \ No newline at end of file diff --git a/app/plugins/CoreServer/templates/SqlServers/fields.inc b/app/plugins/CoreServer/templates/SqlServers/fields.inc new file mode 100644 index 000000000..aaa97647e --- /dev/null +++ b/app/plugins/CoreServer/templates/SqlServers/fields.inc @@ -0,0 +1,39 @@ +Field->control('type'); + + print $this->Field->control('hostname'); + + print $this->Field->control('databas'); + + print $this->Field->control('username'); + + print $this->Field->control('password'); +} diff --git a/app/plugins/CoreServer/tests/bootstrap.php b/app/plugins/CoreServer/tests/bootstrap.php new file mode 100644 index 000000000..d27d0ae8c --- /dev/null +++ b/app/plugins/CoreServer/tests/bootstrap.php @@ -0,0 +1,55 @@ +loadSqlFiles('tests/schema.sql', 'test'); diff --git a/app/plugins/CoreServer/tests/schema.sql b/app/plugins/CoreServer/tests/schema.sql new file mode 100644 index 000000000..f5df4568c --- /dev/null +++ b/app/plugins/CoreServer/tests/schema.sql @@ -0,0 +1 @@ +-- Test database schema for CoreServer diff --git a/app/plugins/CoreServer/webroot/.gitkeep b/app/plugins/CoreServer/webroot/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/app/resources/.gitkeep b/app/resources/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/app/resources/locales/en_US/command.po b/app/resources/locales/en_US/command.po new file mode 100644 index 000000000..d67e5fbc4 --- /dev/null +++ b/app/resources/locales/en_US/command.po @@ -0,0 +1,151 @@ +# COmanage Registry Localizations (command domain) +# +# Portions licensed to the University Corporation for Advanced Internet +# Development, Inc. ("UCAID") under one or more contributor license agreements. +# See the NOTICE file distributed with this work for additional information +# regarding copyright ownership. +# +# UCAID licenses this file to you under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with the +# License. You may obtain a copy of the License at: +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# @link https://www.internet2.edu/comanage COmanage Project +# @package registry +# @since COmanage Registry v5.0.0 +# @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + +# Command Line text + +msgid "db.noop" +msgstr "== SQL NOT EXECUTED ==" + +msgid "db.ok" +msgstr "Database schema update successful" + +msgid "db.schema" +msgstr "Loading database schema from {0}" + +msgid "db.schema.plugin" +msgstr "Loading database schema from active plugin {0}" + +msgid "db.schema.plugin.none" +msgstr "No database schema found for active plugin {0}" + +msgid "job.process" +msgstr "Processing job {0}" + +msgid "job.registered" +msgstr "Registered job {0}" + +msgid "job.run.child.done.empty" +msgstr "No jobs left in the queue, queue runner {0} exiting" + +msgid "job.run.child.done.max" +msgstr "Queue runner {0} reached maximum number of jobs ({1}), exiting" + +msgid "job.run.child.request" +msgstr "Queue runner {0} requesting assignment {1} for CO {2}" + +msgid "job.run.child.running" +msgstr "Queue runner {0} processing job ID {1}" + +msgid "job.run.max" +msgstr "Reached max queue runner count ({0}), waiting for one to exit" + +msgid "job.run.piddone" +msgstr "Queue runner PID {0} completed" + +msgid "job.run.start" +msgstr "Launching queue runner {0} (of {1}) for CO {2}" + +msgid "job.run.waiting" +msgstr "{0,plural,=1{Waiting for the last queue runner to complete} other{Waiting for # queue runners to complete}}" + +msgid "opt.admin-family-name" +msgstr "Family Name of initial platform administrator" + +msgid "opt.admin-given-name" +msgstr "Given Name of initial platform administrator" + +msgid "opt.admin-username" +msgstr "Username of initial platform administrator" + +msgid "opt.force" +msgstr "Force a rerun of setup (only if you know what you are doing)" + +msgid "opt.job.co_id" +msgstr "CO ID" + +msgid "opt.job.max" +msgstr "Maximum number of concurrent queue runners (use with -r)" + +msgid "opt.job.parallel" +msgstr "Number of parallel queue runners per CO (use with -r)" + +msgid "opt.job.parameters" +msgstr "Job (plugin) specific parameters" + +msgid "opt.job.plugin" +msgstr "Job (plugin) to run" + +msgid "opt.job.run" +msgstr "Run the Job queue for the specified CO" + +msgid "opt.job.synchronous" +msgstr "Run the requested job synchronously (use with -j)" + +msgid "opt.not" +msgstr "Calculate changes but do not apply" + +# msgid "se.admin" +# msgstr "Creating initial administrator permission" + +# msgid "se.admin.user" +# msgstr "Enter administrator's login username" + +msgid "se.already" +msgstr "Setup appears to have already run" + +msgid "se.db.cmpadmin" +msgstr "Creating the Platform Administrator" + +msgid "se.db.co" +msgstr "Creating COmanage CO" + +msgid "se.db.co.done" +msgstr "COmanage CO created - CO Id: {0}" + +msgid "se.done" +msgstr "Done" + +msgid "se.person_role.title" +msgstr "COmanage Platform Administrator" + +msgid "se.salt" +msgstr "Generating salt file {0}" + +msgid "opt.test.database.ok" +msgstr "Database connection established" + +msgid "opt.test.database.source" +msgstr "For database test, the datasource to use" + +msgid "opt.test.test" +msgstr "Test to perform" + +msgid "tm.epilog" +msgstr "An optional, space separated list of tables to transmogrify may be specified" + +msgid "tm.login-identifier-copy" +msgstr "Copy any login Identifiers from External Identities to the associated Persons" + +msgid "tm.login-identifier-type" +msgstr "Flag all Person Identifiers of this type as login identifiers" diff --git a/app/resources/locales/en_US/controller.po b/app/resources/locales/en_US/controller.po new file mode 100644 index 000000000..5539ba39b --- /dev/null +++ b/app/resources/locales/en_US/controller.po @@ -0,0 +1,127 @@ +# COmanage Registry Localizations (controller domain) +# +# Portions licensed to the University Corporation for Advanced Internet +# Development, Inc. ("UCAID") under one or more contributor license agreements. +# See the NOTICE file distributed with this work for additional information +# regarding copyright ownership. +# +# UCAID licenses this file to you under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with the +# License. You may obtain a copy of the License at: +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# @link https://www.internet2.edu/comanage COmanage Project +# @package registry +# @since COmanage Registry v5.0.0 +# @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + +# Controllers (Models) + +msgid "Addresses" +msgstr "{0,plural,=1{Address} other{Addresses}}" + +msgid "AdHocAttributes" +msgstr "{0,plural,=1{Ad Hoc Attribute} other{Ad Hoc Attributes}}" + +msgid "ApiUsers" +msgstr "{0,plural,=1{API User} other{API Users}}" + +msgid "AuthenticationEvents" +msgstr "{0,plural,=1{Authentication Event} other{Authentication Events}}" + +msgid "CoSettings" +msgstr "{0,plural,=1{CO Setting} other{CO Settings}}" + +msgid "Cos" +msgstr "{0,plural,=1{CO} other{COs}}" + +msgid "Cous" +msgstr "{0,plural,=1{COU} other{COUs}}" + +msgid "Dashboards" +msgstr "{0,plural,=1{Dashboard} other{Dashboards}}" + +msgid "EmailAddresses" +msgstr "{0,plural,=1{Email Address} other{Email Addresses}}" + +msgid "ExternalIdentities" +msgstr "{0,plural,=1{External Identity} other{External Identities}}" + +msgid "ExternalIdentityRoles" +msgstr "{0,plural,=1{External Identity Role} other{External Identity Roles}}" + +msgid "ExternalIdentitySources" +msgstr "{0,plural,=1{External Identity Source} other{External Identity Sources}}" + +msgid "ExtIdentitySourceRecords" +msgstr "{0,plural,=1{External Identity Source Record} other{External Identity Source Records}}" + +msgid "GroupMembers" +msgstr "{0,plural,=1{Group Member} other{Group Members}}" + +msgid "GroupNestings" +msgstr "{0,plural,=1{Group Nesting} other{Group Nestings}}" + +msgid "GroupOwners" +msgstr "{0,plural,=1{Group Owner} other{Group Owners}}" + +msgid "Groups" +msgstr "{0,plural,=1{Group} other{Groups}}" + +msgid "HistoryRecords" +msgstr "{0,plural,=1{History Record} other{History Records}}" + +msgid "IdentifierAssignments" +msgstr "{0,plural,=1{Identifier Assignment} other{Identifier Assignments}}" + +msgid "Identifiers" +msgstr "{0,plural,=1{Identifier} other{Identifiers}}" + +msgid "JobHistoryRecords" +msgstr "{0,plural,=1{Job History Record} other{Job History Records}}" + +msgid "Jobs" +msgstr "{0,plural,=1{Job} other{Jobs}}" + +msgid "Names" +msgstr "{0,plural,=1{Name} other{Names}}" + +msgid "People" +msgstr "{0,plural,=1{Person} other{People}}" + +msgid "PersonRoles" +msgstr "{0,plural,=1{Person Role} other{Person Roles}}" + +msgid "Pipelines" +msgstr "{0,plural,=1{Pipeline} other{Pipelines}}" + +msgid "Pronouns" +msgstr "{0,plural,=1{Pronoun Preference} other{Pronouns}}" + +msgid "Plugins" +msgstr "{0,plural,=1{Plugin} other{Plugins}}" + +msgid "ProvisioningHistoryRecords" +msgstr "{0,plural,=1{Provisioning History Record} other{Provisioning History Records}}" + +msgid "ProvisioningTargets" +msgstr "{0,plural,=1{Provisioning Target} other{Provisioning Targets}}" + +msgid "Servers" +msgstr "{0,plural,=1{Server} other{Servers}}" + +msgid "TelephoneNumbers" +msgstr "{0,plural,=1{Telephone Number} other{Telephone Numbers}}" + +msgid "Types" +msgstr "{0,plural,=1{Type} other{Types}}" + +msgid "Urls" +msgstr "{0,plural,=1{URL} other{URLs}}" \ No newline at end of file diff --git a/app/resources/locales/en_US/default.po b/app/resources/locales/en_US/default.po new file mode 100644 index 000000000..5e71b6412 --- /dev/null +++ b/app/resources/locales/en_US/default.po @@ -0,0 +1,55 @@ +# COmanage Registry Localizations +# +# Portions licensed to the University Corporation for Advanced Internet +# Development, Inc. ("UCAID") under one or more contributor license agreements. +# See the NOTICE file distributed with this work for additional information +# regarding copyright ownership. +# +# UCAID licenses this file to you under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with the +# License. You may obtain a copy of the License at: +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# @link https://www.internet2.edu/comanage COmanage Project +# @package registry +# @since COmanage Registry v5.0.0 +# @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + +# For common code to know which component it is +msgid "product.code" +msgstr "registry" + +msgid "product.comanage" +msgstr "COmanage" + +msgid "registry.co.desc" +msgstr "COmanage Registry Internal CO" + +# This should match the ISO 639-1 two letter language code for the translation +msgid "registry.meta.lang" +msgstr "en" + +msgid "registry.meta.logo" +msgstr "COmanage Logo" + +msgid "registry.meta.powered" +msgstr "Powered by" + +msgid "registry.meta.registry" +msgstr "COmanage Registry" + +msgid "registry.home.collab" +msgstr "Available Collaborations" + +msgid "registry.home.welcome" +msgstr "Welcome to {0}." + +msgid "registry.version" +msgstr "COmanage Registry v{0}" \ No newline at end of file diff --git a/app/resources/locales/en_US/defaultType.po b/app/resources/locales/en_US/defaultType.po new file mode 100644 index 000000000..ee6498f0f --- /dev/null +++ b/app/resources/locales/en_US/defaultType.po @@ -0,0 +1,176 @@ +# COmanage Registry Localizations (defaultType domain) +# +# Portions licensed to the University Corporation for Advanced Internet +# Development, Inc. ("UCAID") under one or more contributor license agreements. +# See the NOTICE file distributed with this work for additional information +# regarding copyright ownership. +# +# UCAID licenses this file to you under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with the +# License. You may obtain a copy of the License at: +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# @link https://www.internet2.edu/comanage COmanage Project +# @package registry +# @since COmanage Registry v5.0.0 +# @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + +# Labels for default types when a new CO is created + +msgid "Addresses.type.campus" +msgstr "Campus" + +msgid "Addresses.type.home" +msgstr "Home" + +msgid "Addresses.type.office" +msgstr "Office" + +msgid "Addresses.type.postal" +msgstr "Postal" + +msgid "EmailAddresses.type.delivery" +msgstr "Delivery" + +msgid "EmailAddresses.type.forwarding" +msgstr "Forwarding" + +msgid "EmailAddresses.type.list" +msgstr "Mailing List" + +msgid "EmailAddresses.type.official" +msgstr "Official" + +msgid "EmailAddresses.type.personal" +msgstr "Personal" + +msgid "EmailAddresses.type.preferred" +msgstr "Preferred" + +msgid "EmailAddresses.type.recovery" +msgstr "Recovery" + +msgid "Identifiers.type.badge" +msgstr "Badge" + +msgid "Identifiers.type.enterprise" +msgstr "Enterprise" + +msgid "Identifiers.type.eppn" +msgstr "ePPN" + +msgid "Identifiers.type.eptid" +msgstr "ePTID" + +msgid "Identifiers.type.epuid" +msgstr "ePUID" + +msgid "Identifiers.type.gid" +msgstr "GID" + +msgid "Identifiers.type.mail" +msgstr "Mail" + +msgid "Identifiers.type.national" +msgstr "National" + +msgid "Identifiers.type.network" +msgstr "Network" + +msgid "Identifiers.type.oidcsub" +msgstr "OIDC Sub" + +msgid "Identifiers.type.openid" +msgstr "OpenID" + +msgid "Identifiers.type.orcid" +msgstr "ORCiD" + +# This is coded as "provisioningtarget" for compatibility with v4 +msgid "Identifiers.type.provisioningtarget" +msgstr "Provisioning Key" + +msgid "Identifiers.type.reference" +msgstr "Match Reference" + +msgid "Identifiers.type.pairwiseid" +msgstr "SAML Pairwise" + +msgid "Identifiers.type.subjectid" +msgstr "SAML Subject" + +msgid "Identifiers.type.sorid" +msgstr "System of Record" + +msgid "Identifiers.type.uid" +msgstr "UID" + +msgid "Names.type.alternate" +msgstr "Alternate" + +msgid "Names.type.author" +msgstr "Author" + +msgid "Names.type.fka" +msgstr "Formerly Known As" + +msgid "Names.type.official" +msgstr "Official" + +msgid "Names.type.preferred" +msgstr "Preferred" + +msgid "PersonRoles.affiliation_type.affiliate" +msgstr "Affiliate" + +msgid "PersonRoles.affiliation_type.alum" +msgstr "Alum" + +msgid "PersonRoles.affiliation_type.employee" +msgstr "Employee" + +msgid "PersonRoles.affiliation_type.faculty" +msgstr "Faculty" + +msgid "PersonRoles.affiliation_type.librarywalkin" +msgstr "Library Walk-In" + +msgid "PersonRoles.affiliation_type.member" +msgstr "Member" + +msgid "PersonRoles.affiliation_type.staff" +msgstr "Staff" + +msgid "PersonRoles.affiliation_type.student" +msgstr "Student" + +msgid "Pronouns.type.default" +msgstr "Default" + +msgid "TelephoneNumbers.type.campus" +msgstr "Campus" + +msgid "TelephoneNumbers.type.fax" +msgstr "Fax" + +msgid "TelephoneNumbers.type.home" +msgstr "Home" + +msgid "TelephoneNumbers.type.mobile" +msgstr "Mobile" + +msgid "TelephoneNumbers.type.office" +msgstr "Office" + +msgid "Urls.type.official" +msgstr "Official" + +msgid "Urls.type.personal" +msgstr "Personal" diff --git a/app/resources/locales/en_US/enumeration.po b/app/resources/locales/en_US/enumeration.po new file mode 100644 index 000000000..207110da6 --- /dev/null +++ b/app/resources/locales/en_US/enumeration.po @@ -0,0 +1,451 @@ +# COmanage Registry Localizations (enumeration domain) +# +# Portions licensed to the University Corporation for Advanced Internet +# Development, Inc. ("UCAID") under one or more contributor license agreements. +# See the NOTICE file distributed with this work for additional information +# regarding copyright ownership. +# +# UCAID licenses this file to you under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with the +# License. You may obtain a copy of the License at: +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# @link https://www.internet2.edu/comanage COmanage Project +# @package registry +# @since COmanage Registry v5.0.0 +# @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + +# Enumerations + +msgid "AuthenticationEventEnum.AI" +msgstr "API Login" + +msgid "AuthenticationEventEnum.IN" +msgstr "Registry Login" + +msgid "BooleanEnum.0" +msgstr "False" + +msgid "BooleanEnum.1" +msgstr "True" + +msgid "DeletedRoleStatusEnum.D" +msgstr "Archived" + +msgid "DeletedRoleStatusEnum.GP" +msgstr "Grace Period" + +msgid "DeletedRoleStatusEnum.S" +msgstr "Suspended" + +msgid "DeletedRoleStatusEnum.XP" +msgstr "Expired" + +msgid "EduPersonAffiliationEnum.affiliate" +msgstr "Affiliate" + +msgid "EduPersonAffiliationEnum.alum" +msgstr "Alum" + +msgid "EduPersonAffiliationEnum.employee" +msgstr "Employee" + +msgid "EduPersonAffiliationEnum.faculty" +msgstr "Faculty" + +msgid "EduPersonAffiliationEnum.librarywalkin" +msgstr "Library Walk-In" + +msgid "EduPersonAffiliationEnum.member" +msgstr "Member" + +msgid "EduPersonAffiliationEnum.staff" +msgstr "Staff" + +msgid "EduPersonAffiliationEnum.student" +msgstr "Student" + +msgid "ExternalIdentityStatusEnum.A" +msgstr "Active" + +msgid "ExternalIdentityStatusEnum.D" +msgstr "Archived" + +msgid "ExternalIdentityStatusEnum.D2" +msgstr "Duplicate" + +msgid "ExternalIdentityStatusEnum.GP" +msgstr "Grace Period" + +msgid "ExternalIdentityStatusEnum.PS" +msgstr "Pending Activation" + +msgid "ExternalIdentityStatusEnum.S" +msgstr "Suspended" + +msgid "ExternalIdentityStatusEnum.DX" +msgstr "Deleted" + +msgid "GroupTypeEnum.MA" +msgstr "Active Members" + +msgid "GroupTypeEnum.A" +msgstr "Admins" + +msgid "GroupTypeEnum.M" +msgstr "All Members" + +msgid "GroupTypeEnum.O" +msgstr "Owners" + +msgid "GroupTypeEnum.S" +msgstr "Standard" + +msgid "IdentifierAssignmentContextEnum.CD" +msgstr "Department" + +msgid "IdentifierAssignmentContextEnum.CG" +msgstr "Group" + +msgid "IdentifierAssignmentContextEnum.CP" +msgstr "Person" + +msgid "JobStatusEnum.A" +msgstr "Assigned" + +msgid "JobStatusEnum.CX" +msgstr "Canceled" + +msgid "JobStatusEnum.GO" +msgstr "In Progress" + +msgid "JobStatusEnum.NT" +msgstr "Notice" + +msgid "JobStatusEnum.OK" +msgstr "Complete" + +msgid "JobStatusEnum.Q" +msgstr "Queued" + +msgid "JobStatusEnum.X" +msgstr "Failed" + +msgid "LanguageEnum.af" +msgstr "Afrikaans" + +msgid "LanguageEnum.ar" +msgstr "Arabic (العربية)" + +msgid "LanguageEnum.bn" +msgstr "Bengali" + +msgid "LanguageEnum.zh-Hans" +msgstr "Chinese - Simplified (简体中文)" + +msgid "LanguageEnum.zh-Hant" +msgstr "Chinese - Traditional (繁體中文)" + +msgid "LanguageEnum.hr" +msgstr "Croatian (Hrvatski)" + +msgid "LanguageEnum.cs" +msgstr "Czech (čeština)" + +msgid "LanguageEnum.da" +msgstr "Danish (Dansk)" + +msgid "LanguageEnum.nl" +msgstr "Dutch (Nederlands) / Flemish" + +msgid "LanguageEnum.en" +msgstr "English" + +msgid "LanguageEnum.et" +msgstr "Estonian (Eesti Keel)" + +msgid "LanguageEnum.fi" +msgstr "Finnish (Suomi)" + +msgid "LanguageEnum.fr" +msgstr "French (Français)" + +msgid "LanguageEnum.de" +msgstr "German (Deutsch)" + +msgid "LanguageEnum.el" +msgstr "Greek (ελληνικά)" + +msgid "LanguageEnum.he" +msgstr "Hebrew (עִבְרִית)" + +msgid "LanguageEnum.hi" +msgstr "Hindi (हिंदी)" + +msgid "LanguageEnum.hu" +msgstr "Hungarian (Magyar)" + +msgid "LanguageEnum.id" +msgstr "Indonesian (Bahasa Indonesia)" + +msgid "LanguageEnum.it" +msgstr "Italian (Italiano)" + +msgid "LanguageEnum.ja" +msgstr "Japanese (日本語)" + +msgid "LanguageEnum.ko" +msgstr "Korean (한국어)" + +msgid "LanguageEnum.lv" +msgstr "Latvian (Latviešu Valoda)" + +msgid "LanguageEnum.lt" +msgstr "Lithuanian (Lietuvių Kalba)" + +msgid "LanguageEnum.ms" +msgstr "Malaysian (Bahasa Malaysia)" + +msgid "LanguageEnum.no" +msgstr "Norwegian (Norsk)" + +msgid "LanguageEnum.pl" +msgstr "Polish (Język Polski)" + +msgid "LanguageEnum.pt" +msgstr "Portuguese (Português)" + +msgid "LanguageEnum.ro" +msgstr "Romanian (Limba Română)" + +msgid "LanguageEnum.ru" +msgstr "Russian (Pyccĸий)" + +msgid "LanguageEnum.sr" +msgstr "Serbian (српски / Srpski)" + +msgid "LanguageEnum.sl" +msgstr "Slovene (Slovenski Jezik)" + +msgid "LanguageEnum.es" +msgstr "Spanish (Español)" + +msgid "LanguageEnum.sv" +msgstr "Swedish (Svenska)" + +msgid "LanguageEnum.tr" +msgstr "Turkish (Türkçe)" + +msgid "LanguageEnum.ur" +msgstr "Urdu (اُردُو)" + +msgid "MatchStrategyEnum.EA" +msgstr "Email Address" + +msgid "MatchStrategyEnum.EX" +msgstr "External" + +msgid "MatchStrategyEnum.ID" +msgstr "Identifier" + +msgid "MatchStrategyEnum.NO" +msgstr "No Matching" + +msgid "PermittedNameFieldsEnum.given,family" +msgstr "Given, Family" + +msgid "PermittedNameFieldsEnum.given,middle,family" +msgstr "Given, Middle, Family" + +msgid "PermittedNameFieldsEnum.given,family,suffix" +msgstr "Given, Family, Suffix" + +msgid "PermittedNameFieldsEnum.given,middle,family,suffix" +msgstr "Given, Middle, Family, Suffix" + +msgid "PermittedNameFieldsEnum.honorific,given,family" +msgstr "Honorific, Given, Family" + +msgid "PermittedNameFieldsEnum.honorific,given,middle,family" +msgstr "Honorific, Given, Middle, Family" + +msgid "PermittedNameFieldsEnum.honorific,given,family,suffix" +msgstr "Honorific, Given, Family, Suffix" + +msgid "PermittedNameFieldsEnum.honorific,given,middle,family,suffix" +msgstr "Honorific, Given, Middle, Family, Suffix" + +msgid "PermittedTelephoneNumberFieldsEnum.number" +msgstr "Number" + +msgid "PermittedTelephoneNumberFieldsEnum.number,extension" +msgstr "Number, Extension" + +msgid "PermittedTelephoneNumberFieldsEnum.area_code,number" +msgstr "Area Code, Number" + +msgid "PermittedTelephoneNumberFieldsEnum.area_code,number,extension" +msgstr "Area Code, Number, Extension" + +msgid "PermittedTelephoneNumberFieldsEnum.country_code,number" +msgstr "Country Code, Number" + +msgid "PermittedTelephoneNumberFieldsEnum.country_code,number,extension" +msgstr "Country Code, Number, Extension" + +msgid "PermittedTelephoneNumberFieldsEnum.country_code,area_code,number" +msgstr "Country Code, Area Code, Number" + +msgid "PermittedTelephoneNumberFieldsEnum.country_code,area_code,number,extension" +msgstr "Country Code, Area Code, Number, Extension" + +msgid "ProvisionerModeEnum.A" +msgstr "Immediate" + +msgid "ProvisionerModeEnum.E" +msgstr "Enrollment Only" + +msgid "ProvisionerModeEnum.M" +msgstr "Manual" + +msgid "ProvisionerModeEnum.Q" +msgstr "Queue" + +msgid "ProvisionerModeEnum.QE" +msgstr "Queue on Error" + +msgid "ProvisionerModeEnum.X" +msgstr "Disabled" + +msgid "ProvisioningStatusEnum.N" +msgstr "Not Provisioned" + +msgid "ProvisioningStatusEnum.P" +msgstr "Provisioned" + +msgid "ProvisioningStatusEnum.Q" +msgstr "Queued" + +msgid "ProvisioningStatusEnum.X" +msgstr "Unknown" + +msgid "RequiredAddressFieldsEnum.country" +msgstr "Country" + +msgid "RequiredAddressFieldsEnum.locality,state" +msgstr "City, State" + +msgid "RequiredAddressFieldsEnum.postal_code" +msgstr "Postal Code" + +msgid "RequiredAddressFieldsEnum.street" +msgstr "Street" + +msgid "RequiredAddressFieldsEnum.street,locality,state,postal_code" +msgstr "Street, City, State, Postal Code" + +msgid "RequiredAddressFieldsEnum.street,locality,state,postal_code,country" +msgstr "Street, City, State, Postal Code, Country" + +msgid "RequiredNameFieldsEnum.given" +msgstr "Given" + +msgid "RequiredNameFieldsEnum.given,family" +msgstr "Given, Family" + +msgid "SetBooleanEnum.0" +msgstr "Not Set" + +msgid "SetBooleanEnum.1" +msgstr "Set" + +msgid "StatusEnum.A" +msgstr "Active" + +msgid "StatusEnum.C" +msgstr "Confirmed" + +msgid "StatusEnum.D" +msgstr "Archived" + +msgid "StatusEnum.D2" +msgstr "Duplicate" + +msgid "StatusEnum.GP" +msgstr "Grace Period" + +msgid "StatusEnum.I" +msgstr "Invited" + +msgid "StatusEnum.LK" +msgstr "Locked" + +msgid "StatusEnum.N" +msgstr "Denied" + +msgid "StatusEnum.P" +msgstr "Pending" + +msgid "StatusEnum.PA" +msgstr "Pending Approval" + +msgid "StatusEnum.PC" +msgstr "Pending Confirmation" + +msgid "StatusEnum.PS" +msgstr "Pending Activation" + +msgid "StatusEnum.S" +msgstr "Suspended" + +msgid "StatusEnum.X" +msgstr "Declined" + +msgid "StatusEnum.XP" +msgstr "Expired" + +msgid "StatusEnum.Y" +msgstr "Approved" + +msgid "SuspendableStatusEnum.A" +msgstr "Active" + +msgid "SuspendableStatusEnum.S" +msgstr "Suspended" + +msgid "SyncModeEnum.F" +msgstr "Full" + +msgid "SyncModeEnum.M" +msgstr "Manual" + +msgid "SyncModeEnum.Q" +msgstr "Query" + +msgid "SyncModeEnum.U" +msgstr "Update" + +msgid "SyncModeEnum.X" +msgstr "Disabled" + +msgid "TemplateableStatusEnum.A" +msgstr "Active" + +msgid "TemplateableStatusEnum.S" +msgstr "Suspended" + +msgid "TemplateableStatusEnum.T" +msgstr "Template" + +msgid "YesBooleanEnum.0" +msgstr "No" + +msgid "YesBooleanEnum.1" +msgstr "Yes" diff --git a/app/resources/locales/en_US/error.po b/app/resources/locales/en_US/error.po new file mode 100644 index 000000000..530055559 --- /dev/null +++ b/app/resources/locales/en_US/error.po @@ -0,0 +1,279 @@ +# COmanage Registry Localizations (error domain) +# +# Portions licensed to the University Corporation for Advanced Internet +# Development, Inc. ("UCAID") under one or more contributor license agreements. +# See the NOTICE file distributed with this work for additional information +# regarding copyright ownership. +# +# UCAID licenses this file to you under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with the +# License. You may obtain a copy of the License at: +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# @link https://www.internet2.edu/comanage COmanage Project +# @package registry +# @since COmanage Registry v5.0.0 +# @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + +# Error Messages + +msgid "api.object" +msgstr "Did not find \"{0}\" object in request" + +msgid "auth.api.expired" +msgstr "API User \"{0}\" has expired" + +msgid "auth.api.failed" +msgstr "Authentication Failed" + +msgid "auth.api.invalid" +msgstr "Authentication request did not include Username and/or API Key" + +msgid "auth.api.ip" +msgstr "Invalid IP Addres \"{0}\" for API User \"{1}\"" + +msgid "auth.api.key" +msgstr "Invalid API Key provided for \"{0}\"" + +msgid "auth.api.status" +msgstr "API User \"{0}\" is not Active" + +msgid "auth.api.toosoon" +msgstr "API User \"{0}\" is not yet valid" + +msgid "auth.api.unknown" +msgstr "Username \"{0}\" not found in api_users table" + +msgid "coid" +msgstr "CO ID not found" + +msgid "coid.frozen" +msgstr "Cannot change co_id of an existing object" + +msgid "coid.mismatch" +msgstr "Requested CO does not match CO of {0} {1}" + +msgid "cou.parent" +msgstr "COU Parent ID not valid" + +msgid "db.config" +msgstr "Invalid database configuration \"{0}\"" + +msgid "default.conflict" +msgstr "Default values already imported" + +msgid "delete" +msgstr "Failed to delete {0} {1}" + +msgid "delete.active" +msgstr "This record is in Active status and cannot be deleted" + +msgid "edit.comanage" +msgstr "Cannot edit or delete the COmanage CO" + +msgid "edit.readonly" +msgstr "This record is read only and cannot be edited" + +msgid "exists" +msgstr "{0} already exists with this name" + +msgid "exists.GroupMember" +msgstr "{0} is already a member of Group {1}" + +msgid "fields" +msgstr "Please recheck these fields: {0}" + +msgid "fields.primary_link" +msgstr "The Primary Link {0} is frozen and cannot be changed" + +msgid "fields.read_only" +msgstr "The field {0} is not modifiable" + +msgid "file" +msgstr "Cannot read file {0}" + +# Used to construct flash message for field errors +msgid "flash" +msgstr "{0}: {1}" + +msgid "Cos.active" +msgstr "Requested CO {0} is not active" + +msgid "GroupNestings.active" +msgstr "Group {0} is not active and so cannot be nested" + +msgid "GroupNestings.automatic" +msgstr "Group {0} is an automatic Group and so cannot be a nesting target" + +msgid "GroupNestings.exists" +msgstr "Group is already nested into this target" + +msgid "GroupNestings.loop" +msgstr "Target group is already nested into this Group" + +msgid "GroupNestings.same" +msgstr "Group cannot be nested into itself" + +msgid "Groups.name.prefix" +msgstr "Standard Groups may not have names starting with \"CO:\"" + +msgid "Groups.nested" +msgstr "Group is nested or has nestings, and cannot be suspended or deleted" + +msgid "Groups.owners.update" +msgstr "Update called on Owners Group" + +msgid "IdentifierAssignments.exists" +msgstr "The identifier \"{0}\" is already in use" + +msgid "IdentifierAssignments.failed" +msgstr "Failed to find a unique identifier to assign" + +msgid "IdentifierAssignments.type" +msgstr "Exactly one Email Address Type or one Identifier Type must be specified" + +msgid "IdentifierAssignments.type.notfound" +msgstr "No identifier of type \"{0}\" found" + +msgid "IdentifierAssignments.type.none" +msgstr "No identifier type specified" + +msgid "Identifiers.login" +msgstr "Only Identifiers attached to a Person may be flagged for login" + +msgid "inactive" +msgstr "{0} {1} is not Active" + +msgid "input.blank" +msgstr "Value cannot consist of only blank characters" + +msgid "input.condreq" +msgstr "When this value is selected, {0} cannot be empty" + +msgid "input.invalid" +msgstr "Invalid character found" + +msgid "input.invalid.email" +msgstr "The provided value is not a valid email address" + +msgid "input.invalid.prefix" +msgstr "The provided value is not valid. \"{0}\" prefix is required" + +msgid "input.invalid.url" +msgstr "The provided value is not a valid URL" + +msgid "input.length" +msgstr "The provided value cannot be longer than {0} characters" + +msgid "input.notprov" +msgstr "{0} must be provided" + +msgid "invalid" +msgstr "Invalid value \"{0}\"" + +msgid "Jobs.failed.abnormal" +msgstr "The Job terminated unexpectedly" + +msgid "Jobs.plugin.parameter.int" +msgstr "Provided value is not an integer" + +msgid "Jobs.plugin.parameter.invalid" +msgstr "Invalid parameter" + +msgid "Jobs.plugin.parameter.required" +msgstr "Required parameter not provided" + +msgid "Jobs.plugin.parameter.type" +msgstr "Unknown parameter type {0}" + +msgid "Jobs.registered.already" +msgstr "A Job is already registered for this plugin ({0}) with these parameters" + +msgid "Jobs.status.invalid" +msgstr "Job {0} is not in {1} status and cannot be set to {2} (Job is {3})" + +msgid "Jobs.status.invalid.cancel" +msgstr "Job {0} is not in a cancelable status (Job is {1})" + +msgid "Names.minimum" +msgstr "At least one name is required" + +msgid "Names.primary_name" +msgstr "Primary Name not found" + +msgid "Names.primary_name.del" +msgstr "Primary Name cannot be deleted" + +msgid "notfound" +msgstr "{0} not found" + +msgid "notfound.person" +msgstr "No Person or External Identity found" + +msgid "notprov" +msgstr "{0} not provided" + +msgid "pagenum.exceeded" +msgstr "Page number may not be larger than {0}" + +msgid "pagenum.nan" +msgstr "Page number must be an integer" + +msgid "perm" +msgstr "Permission Denied" + +msgid "PersonRoles.valid_from.after" +msgstr "Valid From date must be earlier than Valid Through date" + + +msgid "Plugins.inactive" +msgstr "The plugin {0} is not active" + +msgid "Plugins.inuse" +msgstr "{0,plural,=1{Plugin in use for {1} \"{2}\" in CO {3}} other{Plugin in use for {1} \"{2}\" in CO {3} and others}}" + +msgid "primary_link" +msgstr "Could not find value for Primary Link" + +msgid "primary_link.frozen" +msgstr "The Primary Link key cannot be changed once set" + +msgid "primary_link.mismatch" +msgstr "All records must have the same Primary Link" + +msgid "rule.ValidateCo.errorField" +msgstr "errorField not set in ruleValidateCO" + +msgid "rule.ValidateCo.mismatch" +msgstr "Foreign key {0} CO ID {1} does not match primary object CO ID {2}" + +msgid "save" +msgstr "Save Failed ({0})" + +msgid "save.plugin" +msgstr "Failed to instantiate Plugin {0}" + +msgid "schema.column" +msgstr "No type defined for table \"{0}\" column \"{1}\"" + +msgid "schema.parse" +msgstr "Failed to parse file {0}" + +msgid "setup.co.comanage" +msgstr "Failed to setup COmanage CO" + +msgid "Types.inuse" +msgstr "Type {0} is in use and cannot be deleted" + +msgid "Types.isdefault" +msgstr "Type {0} is in use as a default (via CO Settings)" + +msgid "unknown" +msgstr "Unknown value \"{0}\"" diff --git a/app/resources/locales/en_US/field.po b/app/resources/locales/en_US/field.po new file mode 100644 index 000000000..ac02352bc --- /dev/null +++ b/app/resources/locales/en_US/field.po @@ -0,0 +1,566 @@ +# COmanage Registry Localizations (field domain) +# +# Portions licensed to the University Corporation for Advanced Internet +# Development, Inc. ("UCAID") under one or more contributor license agreements. +# See the NOTICE file distributed with this work for additional information +# regarding copyright ownership. +# +# UCAID licenses this file to you under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with the +# License. You may obtain a copy of the License at: +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# @link https://www.internet2.edu/comanage COmanage Project +# @package registry +# @since COmanage Registry v5.0.0 +# @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + +# Fields +# Keys of the form MyModels.field_name[.desc] will apply only to MyModels.field_name +# Keys of the form field_name[.desc] will apply if no model specific key is found +# +# When adding entries to this file, group non-model specific translations at the top, +# then model specific translations alphabetically by model. + +msgid "action" +msgstr "Action" + +msgid "actions" +msgstr "{0,plural,=1{Action} other{Actions}}" + +msgid "actor" +msgstr "Actor" + +msgid "api_key" +msgstr "API Key" + +msgid "affiliation" +msgstr "Affiliation" + +msgid "area_code" +msgstr "Area Code" + +msgid "attribute" +msgstr "Attribute" + +msgid "comment" +msgstr "Comment" + +msgid "context" +msgstr "Context" + +msgid "country" +msgstr "Country" + +msgid "country_code" +msgstr "Country Code" + +msgid "created" +msgstr "Created" + +msgid "datepicker.am" +msgstr "AM" + +msgid "datepicker.chooseTime" +msgstr "Choose time" + +msgid "datepicker.enterDate" +msgstr "Enter a date as YYYY-MM-DD" + +msgid "datepicker.enterDateTime" +msgstr "Enter a date as YYYY-MM-DD HH:MM:SS" + +msgid "datepicker.hour" +msgstr "Hour" + +msgid "datepicker.pm" +msgstr "PM" + +msgid "datepicker.minute" +msgstr "Minute" + +msgid "date_of_birth" +msgstr "Date of Birth" + +msgid "department" +msgstr "Department" + +msgid "description" +msgstr "Description" + +msgid "display_name" +msgstr "Display Name" + +msgid "edupersonaffiliation" +msgstr "eduPersonAffiliation" + +msgid "ends_at" +msgstr "Ends at:" + +msgid "extension" +msgstr "Extension" + +msgid "frozen" +msgstr "Frozen" + +msgid "id" +msgstr "ID" + +msgid "item" +msgstr "Item" + +msgid "family" +msgstr "Family Name" + +msgid "given" +msgstr "Given Name" + +msgid "group_membership" +msgstr "{0} Membership in {1}" + +msgid "honorific" +msgstr "Honorific" + +msgid "honorific.desc" +msgstr "(Dr, Hon, etc)" + +msgid "identifier" +msgstr "Identifier" + +msgid "format" +msgstr "format" + +msgid "full_name" +msgstr "Full Name" + +msgid "language" +msgstr "Language" + +msgid "last_update" +msgstr "Last Update" + +msgid "locality" +msgstr "Locality" + +msgid "login" +msgstr "Login" + +msgid "mail" +msgstr "Email Address" + +msgid "manager" +msgstr "Manager" + +msgid "middle" +msgstr "Middle" + +msgid "name" +msgstr "Name" + +# This field is called "ordr" to avoid conflicts with MySQL +msgid "ordr" +msgstr "Order" + +msgid "organization" +msgstr "Organization" + +msgid "parameters" +msgstr "Parameters" + +msgid "parent_id" +msgstr "Parent" + +msgid "plugin" +msgstr "Plugin" + +msgid "postal_code" +msgstr "Postal Code" + +msgid "primary" +msgstr "Primary" + +msgid "primary_name" +msgstr "Primary Name" + +msgid "privileged" +msgstr "Privileged" + +msgid "pronouns" +msgstr "Preferred Pronouns" + +msgid "reference_identifier" +msgstr "Reference Identifier" + +msgid "remote_ip" +msgstr "IP Address" + +msgid "required" +msgstr "Required" + +msgid "role_key" +msgstr "Role Key" + +msgid "room" +msgstr "Room" + +msgid "search.global" +msgstr "Global search" + +msgid "search.global.submit" +msgstr "Submit global search" + +msgid "search.global.clear" +msgstr "Clear global search" + +msgid "search.placeholder" +msgstr "Search..." + +msgid "source" +msgstr "Source" + +msgid "source_key" +msgstr "Source Key" + +msgid "source_record" +msgstr "Source Record" + +msgid "sponsor" +msgstr "Sponsor" + +msgid "starts_at" +msgstr "Starts at:" + +msgid "state" +msgstr "State" + +msgid "status" +msgstr "Status" + +msgid "street" +msgstr "Street" + +msgid "suffix" +msgstr "Suffix" + +msgid "suffix.desc" +msgstr "(Jr, III, etc)" + +msgid "tag" +msgstr "Tag" + +msgid "timestamp" +msgstr "Timestamp" + +msgid "title" +msgstr "Title" + +msgid "type" +msgstr "Type" + +msgid "url" +msgstr "URL" + +msgid "username" +msgstr "Username" + +msgid "valid_from" +msgstr "Valid From" + +msgid "valid_from.desc" +msgstr "Leave blank for immediate validity" + +msgid "valid_from.tz" +msgstr "Valid From ({0})" + +msgid "valid_through" +msgstr "Valid Through" + +msgid "valid_through.desc" +msgstr "Leave blank for indefinite validity" + +msgid "valid_through.tz" +msgstr "Valid Through ({0})" + +msgid "value" +msgstr "Value" + +msgid "verified" +msgstr "Verified" + +msgid "unverified" +msgstr "Unverified" + +msgid "ApiUsers.privileged.desc" +msgstr "A privileged API user has full access to the CO. Unprivileged API users may be granted specific permissions where supported." + +msgid "ApiUsers.remote_ip.desc" +msgstr "If specified, a regular expression describing the IP address(es) from which this API User may connect. Be sure to escape dots (eg: "/10\\.0\\.1\\.150/")." + +msgid "ApiUsers.username.desc" +msgstr "The API User Name must be prefixed with the string \"co_#.\"" + +msgid "AuthenticationEvents.authenticated_identifier" +msgstr "Authenticated Identifier" + +msgid "AuthenticationEvents.authentication_event" +msgstr "Authentication Event" + +msgid "Cos.member.not" +msgstr "{0} (Not a Member)" + +msgid "CoSettings.default_address_type_id" +msgstr "Default Address Type" + +msgid "CoSettings.default_email_address_type_id" +msgstr "Default Email Address Type" + +msgid "CoSettings.default_identifier_type_id" +msgstr "Default Identifier Type" + +msgid "CoSettings.default_name_type_id" +msgstr "Default Name Type" + +msgid "CoSettings.default_pronoun_type_id" +msgstr "Default Pronoun Type" + +msgid "CoSettings.default_telephone_number_type_id" +msgstr "Default Telephone Number Type" + +msgid "CoSettings.default_url_type_id" +msgstr "Default URL Type" + +msgid "CoSettings.permitted_fields_name" +msgstr "Name Permitted Fields" + +msgid "CoSettings.permitted_fields_telephone_number" +msgstr "Telephone Number Permitted Fields" + +msgid "CoSettings.required_fields_address" +msgstr "Address Required Fields" + +msgid "CoSettings.required_fields_name" +msgstr "Name Required Fields" + +msgid "CoSettings.search_global_limit" +msgstr "Global Search Limit" + +msgid "CoSettings.search_global_limited_models" +msgstr "Limit Global Search Scope" + +msgid "CoSettings.search_global_limited_models.desc" +msgstr "If true, Global Search will only search Names, Email Addresses, and Identifiers. This may result in faster searches for larger deployments." + +msgid "ExternalIdentitySources.source_record.empty" +msgstr "The source record is empty. This suggests the record is no longer available from the datasource." + +msgid "GroupMembers.source" +msgstr "Membership Source" + +msgid "GroupMembers.source.direct" +msgstr "Direct" + +msgid "GroupNestings.group_id" +msgstr "Nested Group" + +msgid "GroupNestings.negate" +msgstr "Negate Nesting" + +msgid "GroupNestings.negate.desc" +msgstr "If true, members of the Nested Group will not be eligible to be a member of the Target Group" + +msgid "GroupNestings.target_group_id" +msgstr "Target Group" + +msgid "Groups.desc.admins" +msgstr "{0} Administrators" + +msgid "Groups.desc.members" +msgstr "{0} Members" + +msgid "Groups.desc.members.active" +msgstr "{0} Active Members" + +msgid "Groups.group_type" +msgstr "Group Type" + +msgid "Groups.nesting_mode_all" +msgstr "Require All for Nested Memberships" + +msgid "Groups.nesting_mode_all.desc" +msgstr "When enabled, a Person must be found in ALL nested groups to be included as a member. When disabled, a Person found in ANY nested group will be included. (Note that People in negated nested groups are always excluded.)" + +msgid "Groups.open" +msgstr "Open" + +msgid "Groups.open.desc" +msgstr "Open groups may be self-joined by any Person in the CO" + +msgid "Groups.owners.desc.affix" +msgstr "{0} Owners" + +msgid "Groups.owners_group_id" +msgstr "Owners Group" + +msgid "IdentifierAssignments.email_address_type_id" +msgstr "Email Address Type" + +msgid "IdentifierAssignments.email_address_type_id.desc" +msgstr "Exactly one of Email Address or Identifier Type must be set" + +msgid "IdentifierAssignments.identifier_type_id" +msgstr "Identifier Type" + +msgid "IdentifierAssignments.identifier_type_id.desc" +msgstr "Exactly one of Email Address or Identifier Type must be set" + +msgid "JobHistoryRecords.record_key" +msgstr "Record Key" + +msgid "Jobs.assigned_host" +msgstr "Assigned Host" + +msgid "Jobs.assigned_pid" +msgstr "Assigned Process ID" + +msgid "Jobs.finish_summary" +msgstr "Finish Summary" + +msgid "Jobs.finish_time" +msgstr "Finished" + +msgid "Jobs.percent_complete" +msgstr "Percent Complete" + +msgid "Jobs.register_summary" +msgstr "Register Summary" + +msgid "Jobs.register_time" +msgstr "Registered" + +msgid "Jobs.requeue_interval" +msgstr "Requeue Interval" + +msgid "Jobs.requeue_interval.desc" +msgstr "After the job successfully completes, it will automatically be requeued to execute after this interval (in seconds). (To stop requeuing, cancel this job.)" + +msgid "Jobs.requeued_from_job_id" +msgstr "Requeued From Job" + +msgid "Jobs.retry_interval" +msgstr "Retry Interval" + +msgid "Jobs.retry_interval.desc" +msgstr "If the job fails, it will automatically be retried after this interval (in seconds). (To stop retrying, cancel this job.)" + +msgid "Jobs.start_after_time" +msgstr "Start After" + +msgid "Jobs.start_after_time.desc" +msgstr "The queued job will not be started until after this time" + +msgid "Jobs.start_summary" +msgstr "Start Summary" + +msgid "Jobs.start_time" +msgstr "Started" +msgid "Pipelines.match_email_address_type_id" +msgstr "Email Address Type" + +msgid "Pipelines.match_identifier_type_id" +msgstr "Identifier Type" + +msgid "Pipelines.match_strategy" +msgstr "Match Strategy" + +msgid "Pipelines.sync_affiliation_type_id" +msgstr "Person Role Affiliation" + +msgid "Pipelines.sync_affiliation_type_id.desc" +msgstr "If set, created Person Roles will be given this affiliation (not the affiliation of the External Identity)" + +msgid "Pipelines.sync_cou_id" +msgstr "Sync to COU" + +msgid "Pipelines.sync_identifier_type_id" +msgstr "Sync Identifier Type" + +msgid "Pipelines.sync_identifier_type_id.desc" +msgstr "For fields such as manager or sponsor, the inbound identifier type" + +msgid "Pipelines.sync_on_delete" +msgstr "Sync On Delete" + +msgid "Pipelines.sync_on_update" +msgstr "Sync On Update" + +msgid "Pipelines.sync_replace_cou_id" +msgstr "Replace Record in COU" + +msgid "Pipelines.sync_replace_cou_id.desc" +msgstr "If the Person has an existing Person Role in the specified Person Role will be deleted/expired" + +msgid "Pipelines.sync_status_on_delete" +msgstr "Role Status On Delete" + +msgid "Pipelines.sync_status_on_delete.desc" +msgstr "When the source record is no longer valid, the corresponding Person Role will be set to this status" + +msgid "Plugins.plugin" +msgstr "Plugin" + +msgid "Plugins.location" +msgstr "Location" + +msgid "ProvisioningHistoryRecords.subject_model" +msgstr "Subject Object Type" + +msgid "ProvisioningHistoryRecords.subjectid" +msgstr "Subject Object ID" + +msgid "ProvisioningTargets.ordr.desc" +msgstr "The order in which this provisioner will be run when provisioning occurs (leave blank to run after all current provisioners)" + +msgid "ProvisioningTargets.provisioning_group_id" +msgstr "Provisioning Group" + +msgid "ProvisioningTargets.provisioning_group_id.desc" +msgstr "If set, only members of the specified Group will be provisioned to this target" + +msgid "ProvisioningTargets.retry_interval" +msgstr "Retry Interval" + +msgid "ProvisioningTargets.retry_interval.desc" +msgstr "If the provisioning action fails, it will be automatically retried after this interval (in seconds), default is 900 seconds. Set to 0 to not try again. (To stop retrying, cancel the job in the Job Queue.)" + +msgid "ProvisioningTargets.status" +msgstr "Provisioning Mode" + +msgid "TelephoneNumbers.formatted_number" +msgstr "Telephone Number" + +msgid "TelephoneNumbers.number" +msgstr "Telephone Number" + +# This field is for rendering the telephone number into a string (eg: 555 1212 x279) +msgid "TelephoneNumbers.number.ext" +msgstr "x" + +msgid "Types.edupersonaffiliation.desc" +# XXX update link to PE wiki? +msgstr "Map the extended affiliation to this eduPersonAffiliation, see eduPersonAffiliation and Extended Affiliations" + +msgid "Types.status.desc" +msgstr "Suspending a Type will prevent it from being assigned to new attributes, but will not remove it from existing attributes" + +msgid "Types.value" +msgstr "Database Value" + +msgid "Types.value.desc" +msgstr "Database value for this type, characters must be alphanumeric, dot, or dash" diff --git a/app/resources/locales/en_US/information.po b/app/resources/locales/en_US/information.po new file mode 100644 index 000000000..6a05a1458 --- /dev/null +++ b/app/resources/locales/en_US/information.po @@ -0,0 +1,109 @@ +# COmanage Registry Localizations (information domain) +# +# Portions licensed to the University Corporation for Advanced Internet +# Development, Inc. ("UCAID") under one or more contributor license agreements. +# See the NOTICE file distributed with this work for additional information +# regarding copyright ownership. +# +# UCAID licenses this file to you under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with the +# License. You may obtain a copy of the License at: +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# @link https://www.internet2.edu/comanage COmanage Project +# @package registry +# @since COmanage Registry v5.0.0 +# @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + +# Informational (Banner) Messages + +msgid "api.cmp" +msgstr "API Users created in the COmanage CO are given full privileges to all Registry data." + +msgid "api.key" +msgstr "This newly generated API Key cannot be recovered. If it is lost a new key must be generated." + +msgid "cos.none" +msgstr "You are not an active member in any collaboration. If your request for enrollment is still being processed, you will not be able to login until it is approved. Please contact an administrator for assistance." + +msgid "cos.select" +msgstr "Please select the collaboration (CO) you wish to manage." + +msgid "cmp.config.notice" +msgstr "Platform-wide configurations are available in the COmanage CO." + +msgid "cmp.co.notice" +msgstr "This is the COmanage CO, which is used for platform wide configurations." + +msgid "entity.id" +msgstr "ID: {0}" + +msgid "noattrs" +msgstr "No attributes" + +msgid "notset" +msgstr "not set" + +msgid "pagination.format" +msgstr "Page {{page}} of {{pages}}, Viewing {{start}}-{{end}} of {{count}}" + +msgid "ExternalIdentities.source" +msgstr "This External Identity was created from {0}." + +msgid "ExternalIdentitySources.records" +msgstr "Source Records" + +msgid "ExternalIdentitySources.retrieve" +msgstr "This is the current record retrieved directly from the source. View the latest record cached by Registry." + +msgid "ExternalIdentitySources.retrieve.notSynced" +msgstr "This is the current record available directly from the source." + +msgid "ExternalIdentitySourceRecords.metadata" +msgstr "Metadata" + +msgid "ExternalIdentitySourceRecords.view" +msgstr "This is the latest record from the source as cached by Registry. Retrieve the current record directly from the source." + +msgid "ExternalIdentitySources.search.attrs.none" +msgstr "The External Identity Source cannot be searched." + +msgid "ExternalIdentitySources.search.single.placeholder" +msgstr "Search directly against the source for any value" + +msgid "global.attribute.modal" +msgstr "Attribute Modal" + +msgid "global.attributes" +msgstr "Attributes" + +msgid "global.records.none" +msgstr "There are no records to display." + +msgid "global.title.none" +msgstr "No title" + +msgid "global.value.none" +msgstr "No value" + +msgid "global.visit.link" +msgstr "Visit link" + +msgid "pagination.format" +msgstr "Page {{page}} of {{pages}}, Viewing {{start}}-{{end}} of {{count}}" + +msgid "plugin.active" +msgstr "Active" + +msgid "plugin.active.only" +msgstr "Active, Cannot Be Disabled" + +msgid "plugin.inactive" +msgstr "Inactive" \ No newline at end of file diff --git a/app/resources/locales/en_US/menu.po b/app/resources/locales/en_US/menu.po new file mode 100644 index 000000000..72e4011aa --- /dev/null +++ b/app/resources/locales/en_US/menu.po @@ -0,0 +1,220 @@ +# COmanage Registry Localizations (menu domain) +# +# Portions licensed to the University Corporation for Advanced Internet +# Development, Inc. ("UCAID") under one or more contributor license agreements. +# See the NOTICE file distributed with this work for additional information +# regarding copyright ownership. +# +# UCAID licenses this file to you under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with the +# License. You may obtain a copy of the License at: +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# @link https://www.internet2.edu/comanage COmanage Project +# @package registry +# @since COmanage Registry v5.0.0 +# @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + +# Menu Messages + +msgid "artifacts" +msgstr "Available {0} Artifacts" + +msgid "available.filters" +msgstr "Available Filters" + +msgid "co.all" +msgstr "All" + +msgid "co.artifacts" +msgstr "Artifacts" + +msgid "co.attributes" +msgstr "Attributes" + +msgid "co.configuration" +msgstr "Configuration" + +msgid "co.configuration.desc" +msgstr "Configuration for the current CO" + +msgid "co.configuration.short" +msgstr "Config" + +msgid "co.configuration.panel.title" +msgstr "Configuration" + +msgid "co.configuration.panel.personalization" +msgstr "Personalization" + +msgid "co.configuration.panel.personalization.desc" +msgstr "Dashboards, custom text, and theming" + +msgid "co.configuration.panel.platform" +msgstr "Platform-Wide Configuration" + +msgid "co.configuration.panel.platform.desc" +msgstr "These configurations are found only in the COmanage CO" + +msgid "co.configuration.title" +msgstr "CO Configuration" + +msgid "co.connections" +msgstr "Connections" + +msgid "co.connections.panel.title" +msgstr "Connections" + +msgid "co.connections.panel.desc" +msgstr "Connections to external services: upstream inputs and downstream outputs" + +msgid "co.connections.external_identity_sources.desc" +msgstr "Define external \"systems of record\" from which to receive Person data through a Pipeline" + +msgid "co.connections.pipelines.desc" +msgstr "Connect external data to Person records" + +msgid "co.connections.provisioning_targets.desc" +msgstr "Create access to applications and services" + +msgid "co.features.all" +msgstr "All Features" + +msgid "co.groups" +msgstr "Groups" + +msgid "co.operations" +msgstr "Operations" + +msgid "co.operations.panel.title" +msgstr "Operations" + +msgid "co.operations.panel.desc" +msgstr "Operational activities" + +msgid "co.operations.jobs.desc" +msgstr "Manage this CO's asynchronous process queue" + +msgid "co.operations.reports.desc" +msgstr "Create, view, and export reports" + +msgid "co.people" +msgstr "People" + +msgid "co.people.enrollments.pending" +msgstr "Pending Enrollments" + +msgid "co.people.enrollments.pending.desc" +msgstr "See and manage in-progress enrollments (CO Petitions)" + +msgid "co.people.external.source.records" +msgstr "External Source Records" + +msgid "co.people.external.source.records.desc" +msgstr "See and manage information from your external sources" + +msgid "co.people.panel.title" +msgstr "People" + +msgid "co.people.panel.desc" +msgstr "Model people and manage person lifecycles" + +msgid "co.people.population" +msgstr "My Population" + +msgid "co.people.population.desc" +msgstr "Review and manage people in your collaboration (CO Person Records)" + +msgid "co.registries" +msgstr "Registries" + +msgid "co.structure" +msgstr "Structure" + +msgid "co.structure.cous.desc" +msgstr "Collaborative Organizational Units (COUs) are the primary structural objects used to allow delegation of person management within a CO. COUs attach to a Person Role and imply specific Group memberships." + +msgid "co.structure.depts.desc" +msgstr "Departments represent entities within a CO or COU and can store telephone numbers, email addresses, URLs, identifiers, and sets of people." + +msgid "co.structure.groups.desc" +msgstr "Groups attach to a Person. By default, any CO Person can create a new CO Group." + +msgid "co.structure.orgs.desc" +msgstr "Organizations are like Departments but represent entities external to the CO." + +msgid "co.structure.panel.title" +msgstr "Structure" + +msgid "co.structure.panel.desc" +msgstr "Groupings for your population" + +msgid "co.structure.groups" +msgstr "Groups" + +msgid "co.structure.groups.all" +msgstr "All Groups" + +msgid "co.switch" +msgstr "Switch CO" + +msgid "menu.advanced" +msgstr "Advanced Menu" + +msgid "menu.darkmode" +msgstr "Dark mode" + +msgid "menu.darkmode.auto" +msgstr "auto" + +msgid "menu.darkmode.dark" +msgstr "dark" + +msgid "menu.darkmode.light" +msgstr "light" + +msgid "menu.density" +msgstr "Density" + +msgid "menu.density.small" +msgstr "small" + +msgid "menu.density.medium" +msgstr "medium" + +msgid "menu.density.large" +msgstr "large" + +msgid "menu.introduction" +msgstr "Please select an action from the menu." + +msgid "menu.main" +msgstr "Main Menu" + +msgid "menu.settings" +msgstr "Settings" + +msgid "menu.user" +msgstr "User Menu" + +msgid "menu.toggle" +msgstr "Toggle menu collapse button" + +msgid "options" +msgstr "Options" + +msgid "registries" +msgstr "Available {0} Registries" + +msgid "related.configurations" +msgstr "Related Configurations" + +msgid "related.links" +msgstr "Related Links" \ No newline at end of file diff --git a/app/resources/locales/en_US/operation.po b/app/resources/locales/en_US/operation.po new file mode 100644 index 000000000..092608bc4 --- /dev/null +++ b/app/resources/locales/en_US/operation.po @@ -0,0 +1,185 @@ +# COmanage Registry Localizations (operation domain) +# +# Portions licensed to the University Corporation for Advanced Internet +# Development, Inc. ("UCAID") under one or more contributor license agreements. +# See the NOTICE file distributed with this work for additional information +# regarding copyright ownership. +# +# UCAID licenses this file to you under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with the +# License. You may obtain a copy of the License at: +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# @link https://www.internet2.edu/comanage COmanage Project +# @package registry +# @since COmanage Registry v5.0.0 +# @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + +# Operations (Commands) + +msgid "activate" +msgstr "Activate" + +msgid "add" +msgstr "Add" + +msgid "add.a" +msgstr "Add a New {0}" + +msgid "api.key.generate" +msgstr "Generate API Key" + +msgid "api.key.generate.confirm" +msgstr "Are you sure you wish to generate a new API Key?" + +msgid "applySchema" +msgstr "Apply Database Schema" + +msgid "assign" +msgstr "Assign" + +msgid "cancel" +msgstr "Cancel" + +msgid "clear" +msgstr "Clear" + +msgid "clear.filters" +msgstr "{0,plural,=1{Clear Filter} other{Clear Filters}}" + +msgid "close" +msgstr "Close" + +msgid "confirm" +msgstr "Confirm" + +msgid "confirm.generic" +msgstr "Are you sure you want to confirm this action?" + +msgid "configure.a" +msgstr "Configure {0}" + +msgid "configure.plugin" +msgstr "Configure Plugin" + +msgid "dashboard.configuration" +msgstr "{0} Configuration" + +msgid "deactivate" +msgstr "Deactivate" + +msgid "delete" +msgstr "Delete" + +msgid "delete.confirm" +msgstr "Are you sure you wish to delete this record ({0})?" + +msgid "duplicate" +msgstr "Duplicate" + +msgid "edit" +msgstr "Edit" + +msgid "edit.a" +msgstr "Edit {0}" + +msgid "ExternalIdentitySourceRecords.retrieve" +msgstr "Retrieve from External Identity Source" + +msgid "ExternalIdentitySources.search" +msgstr "Search Source" + +msgid "ExternalIdentitySources.sync" +msgstr "Sync Record to CO" + +msgid "filter" +msgstr "Filter" + +msgid "first" +msgstr "First" + +msgid "go" +msgstr "Go" + +msgid "identifiers.assign" +msgstr "Assign Identifiers" + +msgid "identifiers.assign.confirm" +msgstr "Are you sure you want to assign identifiers to this record ({0})?" + +msgid "last" +msgstr "Last" + +msgid "login" +msgstr "Login" + +msgid "logout" +msgstr "Logout" + +msgid "next" +msgstr "Next" + +msgid "page.display" +msgstr "Display records" + +msgid "page.goto" +msgstr "Go to page" + +msgid "previous" +msgstr "Previous" + +msgid "primary" +msgstr "Make Primary" + +msgid "provision" +msgstr "Provision Now" + +msgid "provision.confirm" +msgstr "Are you sure you want to run provisioning?" + +msgid "provisioning.status" +msgstr "Provisioning Status" + +msgid "Types.restore" +msgstr "Add/Restore Default Types" + +msgid "reconcile" +msgstr "Reconcile" + +msgid "reconcile.confirm" +msgstr "Are you sure you want to reconcile this group ({0})?" + +msgid "remove" +msgstr "Remove" + +msgid "save" +msgstr "Save" + +msgid "search" +msgstr "Search" + +msgid "search.global" +msgstr "Global Search" + +msgid "skip_to_content" +msgstr "Skip to main content" + +msgid "Cos.switch" +msgstr "Switch To This CO" + +msgid "unfreeze" +msgstr "Unfreeze" + +msgid "view" +msgstr "View" + +msgid "view.a" +msgstr "View {0}" + diff --git a/app/resources/locales/en_US/result.po b/app/resources/locales/en_US/result.po new file mode 100644 index 000000000..a28d8ca79 --- /dev/null +++ b/app/resources/locales/en_US/result.po @@ -0,0 +1,160 @@ +# COmanage Registry Localizations (result domain) +# +# Portions licensed to the University Corporation for Advanced Internet +# Development, Inc. ("UCAID") under one or more contributor license agreements. +# See the NOTICE file distributed with this work for additional information +# regarding copyright ownership. +# +# UCAID licenses this file to you under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with the +# License. You may obtain a copy of the License at: +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# @link https://www.internet2.edu/comanage COmanage Project +# @package registry +# @since COmanage Registry v5.0.0 +# @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + +# Results + +msgid "activated" +msgstr "{0} Activated" + +msgid "added.mvea" +msgstr "{0} {1} Added: {2}" + +msgid "applied.schema" +msgstr "Successfully applied database schema" + +msgid "deactivated" +msgstr "{0} Deactivated" + +msgid "deleted" +msgstr "Deleted" + +msgid "deleted.a" +msgstr "{0} Deleted" + +msgid "deleted.mvea" +msgstr "{0} {1} Deleted: {2}" + +msgid "edited.mvea" +msgstr "{0} {1} Edited: {2}" + +msgid "ExternalIdentities.status.recalculated" +msgstr "External Identity status recalculated from {0} to {1}" + +msgid "ExternalIdentitySources.synced" +msgstr "External Identity Source sync complete" + +msgid "Groups.added" +msgstr "Group {0} created" + +msgid "Groups.deleted" +msgstr "Group {0} deleted" + +msgid "Groups.edited" +msgstr "Group {0} edited: {1}" + +msgid "Groups.reconciled" +msgstr "Membership for group reconciled" + +msgid "GroupMembers.added" +msgstr "Added {0} to group {1}" + +msgid "GroupMembers.added.nesting" +msgstr "Added {0} to group {1} via nesting of group {2} ({3})" + +msgid "GroupMembers.deleted" +msgstr "Removed {0} from group {1}" + +msgid "GroupMembers.deleted.nesting" +msgstr "Removed {0} from group {1} via nesting of group {2} ({3})" + +msgid "GroupMembers.edited" +msgstr "Membership for {0} in group {1} edited: {2}" + +msgid "GroupOwners.added" +msgstr "Added {0} as an owner of group {1}" + +msgid "GroupOwners.deleted" +msgstr "Removed {0} as an owner of group {1}" + +msgid "IdentifierAssignments.assigned.already" +msgstr "Identifiers Already Assigned ({0})" + +msgid "IdentifierAssignments.history" +msgstr "Identifier Auto Assigned: {0} ({1}, {2})" + +msgid "IdentifierAssignments.assigned.ok" +msgstr "Identifiers Assigned ({0})" + +msgid "Names.primary_name" +msgstr "Primary Name Updated" + +msgid "Jobs.canceled" +msgstr "Job {0} canceled" + +msgid "Jobs.canceled.by" +msgstr "Job canceled by {0}" + +msgid "Jobs.registered" +msgstr "Started via JobCommand by {0} (uid {1})" + +msgid "People.added.pipeline" +msgstr "Created new Person via Pipeline {0} ({1}) using Source {2} ({3}) Key {4}" + +msgid "People.status.recalculated" +msgstr "Person status recalculated from {0} to {1}" + +msgid "PersonRoles.status.recalculated" +msgstr "Person Role status recalculated from {0} to {1}" + +msgid "Pipelines.complete" +msgstr "Pipeline {0} complete for EIS {1} source key {2}" + +msgid "Pipelines.matched" +msgstr "Pipeline {0} ({1}) matched EIS {2} ({3}) source key {4} to Person using Match Strategy {5}" + +msgid "Pipelines.ei.added" +msgstr "Created new External Identity via Pipeline {0} ({1}) using Source {2} ({3}) Key {4}" + +msgid "Pipelines.started" +msgstr "Pipeline {0} ({1}) started for EIS {2} ({3}) source key {4}" + +msgid "saved" +msgstr "Saved" + +msgid "search.exact" +msgstr "Exact match for \"{0}\" found ({1})" + +msgid "search.limit" +msgstr "Search limit reached" + +msgid "search.none" +msgstr "No results found" + +msgid "search.result.found" +msgstr "Found {0} results" + +msgid "search.result.found.modelCount" +msgstr "{0} {1}" + +msgid "search.result.id" +msgstr "ID {0}" + +msgid "search.result.related" +msgstr "{0}: {1}, ID {2}" + +msgid "search.results" +msgstr "Search Results" + +msgid "search.retry" +msgstr "Please select an option from a menu, or try your search again." \ No newline at end of file diff --git a/app/src/Application.php b/app/src/Application.php new file mode 100644 index 000000000..fbed5ede5 --- /dev/null +++ b/app/src/Application.php @@ -0,0 +1,127 @@ +bootstrapCli(); + } + + /* + * Only try to load DebugKit in development mode + * Debug Kit should not be installed on a production system + */ + if (Configure::read('debug')) { + $this->addPlugin('DebugKit'); + } + + // Load more plugins here + + try { + $Plugins = TableRegistry::getTableLocator()->get('Plugins'); + + $activePlugins = $Plugins->find('active')->all(); + + foreach($activePlugins as $p) { + $this->addPlugin($p->plugin); + } + } + catch(\Cake\Database\Exception\DatabaseException $e) { + // Most likely we are performing the initial database setup and + // the plugins table is missing. + } + // Let any other exception bubble up + } + + /** + * Setup the middleware queue your application will use. + * + * @param \Cake\Http\MiddlewareQueue $middlewareQueue The middleware queue to setup. + * @return \Cake\Http\MiddlewareQueue The updated middleware queue. + */ + public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue + { + // Note route specific middleware is loaded in routes.php + + $middlewareQueue + // Catch any exceptions in the lower layers, + // and make an error page/response + ->add(new ErrorHandlerMiddleware(Configure::read('Error'))) + + // Handle plugin/theme assets like CakePHP normally does. + ->add(new AssetMiddleware([ + 'cacheTime' => Configure::read('Asset.cacheTime'), + ])) + + // Add routing middleware. + // If you have a large number of routes connected, turning on routes + // caching in production could improve performance. For that when + // creating the middleware instance specify the cache config name by + // using it's second constructor argument: + // `new RoutingMiddleware($this, '_cake_routes_')` + ->add(new RoutingMiddleware($this)); + + return $middlewareQueue; + } + + /** + * Bootrapping for CLI application. + * + * That is when running commands. + * + * @return void + */ + protected function bootstrapCli(): void + { + try { + $this->addPlugin('Bake'); + } catch (MissingPluginException $e) { + // Do not halt if the plugin is missing + } + + $this->addPlugin('Migrations'); + + // Load more plugins here + } +} diff --git a/app/src/Command/ConsoleCommand.php b/app/src/Command/ConsoleCommand.php new file mode 100644 index 000000000..2ec000ef0 --- /dev/null +++ b/app/src/Command/ConsoleCommand.php @@ -0,0 +1,86 @@ +err('Unable to load Psy\Shell.'); + $io->err(''); + $io->err('Make sure you have installed psysh as a dependency,'); + $io->err('and that Psy\Shell is registered in your autoloader.'); + $io->err(''); + $io->err('If you are using composer run'); + $io->err(''); + $io->err('$ php composer.phar require --dev psy/psysh'); + $io->err(''); + + return static::CODE_ERROR; + } + + $io->out("You can exit with `CTRL-C` or `exit`"); + $io->out(''); + + Log::drop('debug'); + Log::drop('error'); + $io->setLoggers(false); + restore_error_handler(); + restore_exception_handler(); + + $psy = new PsyShell(); + $psy->run(); + } + + /** + * Display help for this console. + * + * @param \Cake\Console\ConsoleOptionParser $parser The parser to update + * @return \Cake\Console\ConsoleOptionParser + */ + public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser + { + $parser->setDescription( + 'This shell provides a REPL that you can use to interact with ' . + 'your application in a command line designed to run PHP code. ' . + 'You can use it to run adhoc queries with your models, or ' . + 'explore the features of CakePHP and your application.' . + "\n\n" . + 'You will need to have psysh installed for this Shell to work.' + ); + + return $parser; + } +} diff --git a/app/src/Command/DatabaseCommand.php b/app/src/Command/DatabaseCommand.php new file mode 100644 index 000000000..8f8578f17 --- /dev/null +++ b/app/src/Command/DatabaseCommand.php @@ -0,0 +1,104 @@ +addOption('not', [ + 'short' => 'n', + 'boolean' => true, + 'help' => __d('command', 'opt.not') + ]); + + return $parser; + } + + /** + * Execute the Database Command. + * + * @since COmanage Registry v5.0.0 + * @param Arguments $args Command Arguments + * @param ConsoleIo $io Console IO + * @throws RuntimeException + */ + + public function execute(Arguments $args, ConsoleIo $io) { + $SchemaManager = new SchemaManager(io: $io); + + // First apply the core schema + $schemaFile = ROOT . DS . 'config' . DS . 'schema' . DS . 'schema.json'; + + $io->out(__d('command', 'db.schema', [$schemaFile])); + + $SchemaManager->applySchemaFile(schemaFile: $schemaFile, + diffOnly: $args->getOption('not')); + + // Next see which plugins are active and have database configurations + $Plugins = TableRegistry::getTableLocator()->get('Plugins'); + + // AR-Plugin-6 Only apply schemas from active plugins + $activePlugins = $Plugins->find('active')->all(); + + if(!empty($activePlugins)) { + foreach($activePlugins as $p) { + $pSchemaConfig = $Plugins->getPluginSchema($p); + + if($pSchemaConfig) { + $io->out(__d('command', 'db.schema.plugin', [$p->plugin])); + $SchemaManager->applySchemaObject($pSchemaConfig); + } else { + $io->out(__d('command', 'db.schema.plugin.none', [$p->plugin])); + } + } + } + + if($args->getOption('not')) { + $io->out(__d('command', 'db.noop')); + } else { + $io->out(__d('command', 'db.ok')); + } + } +} diff --git a/app/src/Command/JobCommand.php b/app/src/Command/JobCommand.php new file mode 100644 index 000000000..7be128bb5 --- /dev/null +++ b/app/src/Command/JobCommand.php @@ -0,0 +1,275 @@ +addOption( + 'co_id', + [ + 'required' => true, + 'short' => 'c', + 'help' => __d('command', 'opt.job.co_id') + ] + )->addOption( + 'job', + [ + 'required' => false, + 'short' => 'j', + 'help' => __d('command', 'opt.job.plugin') + ] + )->addOption( + 'parallel', + [ + 'required' => false, + 'short' => 'p', + 'default' => 1, + 'help' => __d('command', 'opt.job.parallel') + ] + )->addOption( + 'max', + [ + 'required' => false, + 'short' => 'm', + 'default' => 10, + 'help' => __d('command', 'opt.job.max') + ] + )->addOption( + 'run', + [ + 'short' => 'r', + 'boolean' => true, + 'help' => __d('command', 'opt.job.run') + ] + )->addOption( + 'synchronous', + [ + 'short' => 's', + 'boolean' => true, + 'help' => __d('command', 'opt.job.synchronous') + ] + ); + + return $parser; + } + + /** + * Execute the Job Command. + * + * @since COmanage Registry v5.0.0 + * @param Arguments $args Command Arguments + * @param ConsoleIo $io Console IO + */ + + public function execute(Arguments $args, ConsoleIo $io) + { + $CosTable = $this->getTableLocator()->get('Cos'); + $JobTable = $this->getTableLocator()->get('Jobs'); + + if($args->getOption('run')) { + // Run the Job queue + + $coIds = []; + + if($args->getOption('co_id') == 'all') { + $cos = $CosTable->find() + ->where(['status' => \App\Lib\Enum\SuspendableStatusEnum::Active]) + ->toArray(); + + $coIds = \Cake\Utility\Hash::extract($cos, '{n}.id'); + } else { + $coIds[] = (int)$args->getOption('co_id'); + + // Verify that the requested CO exists and is active + $co = $CosTable->get($coIds[0]); + + if(!$co->isActive()) { + throw new \InvalidArgumentException(__d('error', 'Cos.active', [$coIds[0]])); + } + } + + // The set of PIDs launched + $pids = []; + // The total number of actively running children + $pidcount = 0; + + // The maximum number of runners to run at any one time + $max = (int)$args->getOption('max'); + // The number of parallel runners for each CO + $parallel = (int)$args->getOption('parallel'); + + // The maximum number of jobs a queue runner will process before exiting + $maxjobs = 100; + + foreach($coIds as $coId) { + // We start counting from 1 rather than 0 to simplify console output + for($i = 1;$i <= $parallel;$i++) { + $io->out(__d('command', 'job.run.start', [$i, $parallel, $coId])); + + $newPid = pcntl_fork(); + + switch($newPid) { + case -1: + throw new \RuntimeException('fork failed'); + break; + case 0: + // We are the child, process jobs from the requested CO's queue. + // We'll run up to 100 jobs, the same behavior as v4, then exit. + // This could become configurable at some point. + + // We need to open a new database connection for the child after the fork + // so we don't run into problems with other processes (the parent or more + // likely the other children) closing the connection. We'll use the default + // configuration and create a new configuration on the fly so we don't have + // to pollute the database config file. + + ConnectionManager::setConfig('plugin', ConnectionManager::getConfig('default')); +// XXX this doesn't seem to work, so plugins must always access the 'plugin' database, at least for now (CFM-253) +// ConnectionManager::alias('plugin', 'default'); + $cxn = ConnectionManager::get('plugin'); + + $JobTable->setConnection($cxn); + + for($j = 1;$j <= $maxjobs;$j++) { + $io->verbose(__d('command', 'job.run.child.request', [$i, $j, $coId])); + + // Request a job to run + $job = $JobTable->assignNext($coId); + + if(!$job) { + // Nothing to do, exit + $io->verbose(__d('command', 'job.run.child.done.empty', [$newPid])); + exit; + } + + $io->verbose(__d('command', 'job.run.child.running', [$newPid, $job->id])); + + try { + $JobTable->process($job); + } + catch(\Exception $e) { + // The only Exception would be if the Job is in an invalid state, + // which shouldn't happen because we just ran assignNext() + $io->error($e->getMessage()); + } + } + + $io->verbose(__d('command', 'job.run.child.done.max', [$newPid, $maxjobs])); + exit; + break; + default: + // We are the parent, keep launching + $pids[$newPid] = $coId; + $pidcount++; + break; + } + + if($pidcount == $max) { + $io->out(__d('command', 'job.run.max', [$max])); + + $status = -1; + $pid = pcntl_waitpid(-1, $status); + + // Confirm the Job was properly finished. + $JobTable->confirmFinished($pid); + + $io->verbose(__d('command', 'job.run.piddone', [$pid])); + $pidcount--; + } + } + } + + // We are the parent, and we're done launching queue runners. wait() for them. + while($pidcount > 0) { + $io->out(__d('command', 'job.run.waiting', $pidcount)); + + $status = -1; + $pid = pcntl_waitpid(-1, $status); + + // Confirm the Job was properly finished. + $JobTable->confirmFinished($pid); + + $io->verbose(__d('command', 'job.run.piddone', [$pid])); + + $pidcount--; + } + } else { + // Run the requested job synchronously? + $synchronous = $args->getOption('synchronous'); + + // Pull current user info + $pwent = posix_getpwuid(posix_getuid()); + + // Parse any provided parameters + $params = []; + + foreach($args->getArguments() as $a) { + $p = explode('=', $a, 2); + + $params[ $p[0] ] = $p[1]; + } + + $job = $JobTable->register( + coId: (int)$args->getOption('co_id'), + plugin: $args->getOption('job'), + parameters: $params, + registerSummary: __d('result', 'Jobs.registered', [$pwent['name'], $pwent['uid']]), + synchronous: $synchronous + ); + + $io->out(__d('command', 'job.registered', [$job->id])); + + if($synchronous) { + $io->out(__d('command', 'job.process', [$job->id])); + + $JobTable->process($job); + } + } + } +} \ No newline at end of file diff --git a/app/src/Command/SetupCommand.php b/app/src/Command/SetupCommand.php new file mode 100644 index 000000000..456c9f0bc --- /dev/null +++ b/app/src/Command/SetupCommand.php @@ -0,0 +1,200 @@ +addOption('admin-username', [ + 'help' => __d('command', 'opt.admin-username'), + ])->addOption('admin-given-name', [ + 'help' => __d('command', 'opt.admin-given-name'), + ])->addOption('admin-family-name', [ + 'help' => __d('command', 'opt.admin-family-name'), + ])->addOption('force', [ + 'help' => __d('command', 'opt.force'), + 'boolean' => true, + ]); + + return $parser; + } + + /** + * Execute the Setup Command. + * + * @since COmanage Registry v5.0.0 + * @param Arguments $args Command Arguments + * @param ConsoleIo $io Console IO + */ + + public function execute(Arguments $args, ConsoleIo $io) + { + global $argv; + + $force = $args->getOption('force'); + + // Check if the COmanage CO already exists, and if so abort. + + $coTable = $this->getTableLocator()->get('Cos'); + $query = $coTable->find(); + $comanageCO = $coTable->findCOmanageCO($query)->first(); + + if(!is_null($comanageCO)) { + $io->out(__d('command', 'se.already')); + + if(!$force) { + exit; + } + } + + // Collect the admin info before we try to do anything. + + $givenName = $args->getOption('admin-given-name'); + $sn = $args->getOption('admin-family-name'); + $username = $args->getOption('admin-username'); + + if(empty($givenName)) { + $givenName = $io->ask(__d('command', 'opt.admin-given-name')); + } + + if(empty($sn)) { + $sn = $io->ask(__d('command', 'opt.admin-family-name')); + } + + if(empty($username)) { + $username = $io->ask(__d('command', 'opt.admin-username')); + } + + // Setup the COmanage CO. + + if(is_null($comanageCO)) { + $io->out(__d('command', 'se.db.co')); + $co_id = $coTable->setupCOmanageCO(); + + if(is_null($co_id)) { + throw new \RuntimeException('setup.co.comanage'); + } + + $io->out(__d('command', 'se.db.co.done', [$co_id])); + } else { + $co_id = $comanageCO->id; + } + + // Add the first CMP Administrator. + + $io->out(__d('command', 'se.db.cmpadmin')); + + // We disable validation here because there may be dependencies on + // validation aspects that aren't set up yet or aren't available here + + $person = $coTable->People->newEntity([ + 'co_id' => $co_id, + 'status' => SuspendableStatusEnum::Active + ], + ['validate' => false]); + + $person->names = [$coTable->People->Names->newEntity([ + 'type_id' => $coTable->Types->getTypeId(coId: $co_id, + attribute: 'Names.type', + value: 'official'), + 'given' => $givenName, + 'family' => $sn, + 'primary_name' => true + ], + ['validate' => false])]; + + $person->identifiers = [$coTable->People->Identifiers->newEntity([ + 'type_id' => $coTable->Types->getTypeId(coId: $co_id, + attribute: 'Identifiers.type', + value: 'network'), + 'identifier' => $username, + 'login' => true, + 'status' => SuspendableStatusEnum::Active + ], + ['validate' => false])]; + + $person->person_roles = [$coTable->People->PersonRoles->newEntity([ + 'affiliation_type_id' => $coTable->Types->getTypeId(coId: $co_id, + attribute: 'PersonRoles.affiliation_type', + value: 'staff'), + 'title' => __d('command', 'se.person_role.title'), + 'status' => SuspendableStatusEnum::Active + ], + ['validate' => false])]; + + $g = $coTable->Groups->find('adminGroup', ['co_id' => $co_id])->firstOrFail(); + + $person->group_members = [ + $coTable->People->GroupMembers->newEntity( + ['group_id' => $g->id], + ['validate' => false] + ), + $coTable->People->GroupMembers->newEntity( + ['group_id' => $g->owners_group_id], + ['validate' => false] + ) + ]; + + $coTable->People->save($person); + + // Write the salt file if not set in environment and file does not exist. + if(!env('SECURITY_SALT', null)) { + $securitySaltFile = LOCAL . "config" . DS . "security.salt"; + + if(file_exists($securitySaltFile)) { + $io->out(__d('command', 'se.already')); + } else { + $salt = substr(bin2hex(random_bytes(1024)), 0, 40); + file_put_contents($securitySaltFile, $salt); + $io->out(__d('command', 'se.salt', [$securitySaltFile])); + } + } + + $io->out(__d('command', 'se.done')); + } +} \ No newline at end of file diff --git a/app/src/Command/TestCommand.php b/app/src/Command/TestCommand.php new file mode 100644 index 000000000..6109ae41d --- /dev/null +++ b/app/src/Command/TestCommand.php @@ -0,0 +1,104 @@ +addOption('test', [ + 'help' => __d('command', 'opt.test.test'), + 'short' => 't', + 'choices' => ['database'] + ])->addOption('datasource', [ + 'help' => __d('command', 'opt.test.database.source'), + 'default' => 'default' + ]); + + return $parser; + } + + /** + * Execute the Setup Command. + * + * @since COmanage Registry v5.0.0 + * @param Arguments $args Command Arguments + * @param ConsoleIo $io Console IO + */ + + public function execute(Arguments $args, ConsoleIo $io) + { + global $argv; + + $this->io = $io; + + // The test we want to run + $test = $args->getOption('test'); + + switch($test) { + case 'database': + $this->testDatabase($args->getOption('datasource')); + break; + } + } + + /** + * Test database connectivity for the default + */ + + protected function testDatabase(string $source): int { + try { + $cxn = ConnectionManager::get($source); + $this->io->out(__d('command', 'opt.test.database.ok')); + } + catch(\Exception $e) { + $this->io->error($e->getMessage()); + $this->abort(static::CODE_ERROR); + } + + return static::CODE_SUCCESS; + } +} \ No newline at end of file diff --git a/app/src/Command/TransmogrifyCommand.php b/app/src/Command/TransmogrifyCommand.php new file mode 100644 index 000000000..79d869cbb --- /dev/null +++ b/app/src/Command/TransmogrifyCommand.php @@ -0,0 +1,1351 @@ + [ + 'source' => 'cm_cos', + 'displayField' => 'name', + 'addChangelog' => true, + // We don't really need status, but we need something cached for co_settings + 'cache' => [ 'status' ] + ], + 'types' => [ + 'source' => 'cm_co_extended_types', + 'displayField' => 'display_name', + 'postTable' => 'insertPronounTypes', + 'fieldMap' => [ + 'attribute' => '&map_extended_type', + 'name' => 'value', + // For some reason, cm_co_extended_types never had created/modified metadata + 'created' => '&map_now', + 'modified' => '&map_now' + ], + 'cache' => [ [ 'co_id', 'attribute', 'value' ] ] + ], + 'co_settings' => [ + 'source' => 'cm_co_settings', + 'displayField' => 'co_id', + 'addChangelog' => true, + 'booleans' => [], + 'postTable' => 'insertDefaultSettings', + 'cache' => [ 'co_id' ], + 'fieldMap' => [ + 'global_search_limit' => 'search_global_limit', + 'required_fields_addr' => 'required_fields_address', + 'permitted_fields_telephone_number' => '&populate_co_settings_phone', + // XXX CFM-80 these fields are not yet migrated + // be sure to add appropriate fields to 'booleans' + 'enable_nsf_demo' => null, // CFM-123 + 'disable_expiration' => null, + 'disable_ois_sync' => null, + 'group_validity_sync_window' => null, + 'garbage_collection_interval' => null, + 'enable_normalization' => null, + 'enable_empty_cou' => null, + 'invitation_validity' => null, + 't_and_c_login_mode' => null, + 'sponsor_eligibility' => null, + 'sponsor_co_group_id' => null, + 'theme_stacking' => null, + 'default_co_pipeline_id' => null, // XXX was this ever used? + 'elect_strategy_primary_name' => null, + 'co_dashboard_id' => null, + 'co_theme_id' => null, + 'person_picker_email_type' => null, + 'person_picker_identifier_type' => null, + 'person_picker_display_types' => null, + // No longer supported in PE, see CFM-316 + 'group_create_admin_only' => null + ] + ], + 'authentication_events' => [ + 'source' => 'cm_authentication_events', + 'displayField' => 'authenticated_identifier' + ], + 'api_users' => [ + 'source' => 'cm_api_users', + 'displayField' => 'username', + 'booleans' => [ 'privileged' ], + 'fieldMap' => [ + 'password' => 'api_key' + ] + ], + 'cous' => [ + 'source' => 'cm_cous', + 'displayField' => 'name' + ], + //'dashboards' => [ 'source' => 'cm_co_dashboards' ] + 'people' => [ + 'source' => 'cm_co_people', + 'displayField' => 'id', + 'cache' => [ 'co_id' ], + 'fieldMap' => [ + // Rename the changelog key + 'co_person_id' => 'person_id' + ] + ], + 'person_roles' => [ + 'source' => 'cm_co_person_roles', + 'displayField' => 'id', + // We don't currently need status specifically, just that the role exists + 'cache' => [ 'status' ], + 'fieldMap' => [ + 'co_person_id' => 'person_id', + // Rename the changelog key + 'co_person_role_id' => 'person_role_id', + // We need to map affiliation_type_id before we null out affiliation + 'affiliation_type_id' => '&map_affiliation_type', + 'affiliation' => null, + 'manager_co_person_id' => 'manager_person_id', + 'sponsor_co_person_id' => 'sponsor_person_id', + 'o' => 'organization', + 'ou' => 'department', +// XXX temporary until tables are migrated + 'source_org_identity_id' => null + ] + ], + 'external_identities' => [ + 'source' => 'cm_org_identities', + 'displayField' => 'id', + 'fieldMap' => [ + 'co_id' => null, + 'person_id' => '&map_org_identity_co_person_id', + // Rename the changelog key + 'org_identity_id' => 'external_identity_id', + // These fields are migrated to external_identity_roles by split_external_identity() + 'title' => null, + 'o' => null, + 'ou' => null, + 'affiliation' => null, + 'manager_identifier' => null, + 'sponsor_identifier' => null, + 'valid_from' => null, + 'valid_through' => null + ], + 'postRow' => 'split_external_identity', + 'cache' => [ 'person_id' ] + ], + 'groups' => [ + 'source' => 'cm_co_groups', + 'displayField' => 'name', + 'cache' => [ 'co_id', 'owners_group_id' ], + 'booleans' => [ 'nesting_mode_all', 'open' ], + 'fieldMap' => [ + // auto is implied by group_type + 'auto' => null, + // Rename the changelog key + 'co_group_id' => 'group_id', + // Make sure group_type is populated if not already set + 'group_type' => '?S' + ], + 'postTable' => 'createOwnersGroups' + ], + 'group_nestings' => [ + 'source' => 'cm_co_group_nestings', + 'displayField' => 'id', + 'booleans' => [ 'negate' ], + 'fieldMap' => [ + 'co_group_id' => 'group_id', + 'target_co_group_id' => 'target_group_id', + // Rename the changelog key + 'co_group_nesting_id' => 'group_nesting_id' + ] + ], + 'group_members' => [ + 'source' => 'cm_co_group_members', + 'displayField' => 'id', + 'booleans' => [ 'member', 'owner' ], + 'fieldMap' => [ + 'co_group_id' => 'group_id', + 'co_person_id' => 'person_id', + 'member' => null, + 'owner' => null, + 'co_group_nesting_id' => 'group_nesting_id', + // Rename the changelog key + 'co_group_member_id' => 'group_member_id', + // Temporary until implemented + 'source_org_identity_id' => null + ], + 'preRow' => 'check_group_membership' + ], + 'names' => [ + 'source' => 'cm_names', + 'displayField' => 'id', + 'booleans' => [ 'primary_name' ], + 'fieldMap' => [ + 'co_person_id' => 'person_id', + 'org_identity_id' => 'external_identity_id', + // We need to map type_id before we null out type + 'type_id' => '&map_name_type', + 'type' => null + ] + ], + 'ad_hoc_attributes' => [ + 'source' => 'cm_ad_hoc_attributes', + 'displayField' => 'id', + 'fieldMap' => [ + 'co_person_role_id' => 'person_role_id', + 'org_identity_id' => 'external_identity_id', +// XXX temporary until tables are migrated + 'co_department_id' => null, + 'organization_id' => null + ], + 'postTable' => 'processExtendedAttributes' + ], + 'addresses' => [ + 'source' => 'cm_addresses', + 'displayField' => 'id', + 'fieldMap' => [ + 'co_person_role_id' => 'person_role_id', + 'org_identity_id' => 'external_identity_id', + 'type_id' => '&map_address_type', + 'type' => null, +// XXX temporary until tables are migrated + 'co_department_id' => null, + 'organization_id' => null + ] + ], + 'email_addresses' => [ + 'source' => 'cm_email_addresses', + 'displayField' => 'id', + 'booleans' => [ 'verified' ], + 'fieldMap' => [ + 'co_person_id' => 'person_id', + 'org_identity_id' => 'external_identity_id', + 'type_id' => '&map_email_type', + 'type' => null, +// XXX temporary until tables are migrated + 'co_department_id' => null, + 'organization_id' => null + ] + ], + 'identifiers' => [ + 'source' => 'cm_identifiers', + 'displayField' => 'id', + 'booleans' => [ 'login' ], + 'fieldMap' => [ + 'co_group_id' => 'group_id', + 'co_person_id' => 'person_id', + 'org_identity_id' => 'external_identity_id', + 'type_id' => '&map_identifier_type', + 'type' => null, +// XXX temporary until tables are migrated + 'co_department_id' => null, + 'co_provisioning_target_id' => null, + 'organization_id' => null + ], + 'preRow' => 'map_login_identifiers' + ], + 'telephone_numbers' => [ + 'source' => 'cm_telephone_numbers', + 'displayField' => 'id', + 'fieldMap' => [ + 'co_person_role_id' => 'person_role_id', + 'org_identity_id' => 'external_identity_id', + 'type_id' => '&map_telephone_type', + 'type' => null, +// XXX temporary until tables are migrated + 'co_department_id' => null, + 'organization_id' => null + ] + ], + 'urls' => [ + 'source' => 'cm_urls', + 'displayField' => 'id', + 'fieldMap' => [ + 'co_person_id' => 'person_id', + 'org_identity_id' => 'external_identity_id', + 'type_id' => '&map_url_type', + 'type' => null, +// XXX temporary until tables are migrated + 'co_department_id' => null, + 'organization_id' => null + ] + ], + 'history_records' => [ + 'source' => 'cm_history_records', + 'displayField' => 'id', + 'fieldMap' => [ + 'actor_co_person_id' => 'actor_person_id', + 'co_person_id' => 'person_id', + 'co_person_role_id' => 'person_role_id', + 'co_group_id' => 'group_id', + 'org_identity_id' => 'external_identity_id', +// XXX temporary until tables are migrated + 'co_email_list_id' => null, + 'co_service_id' => null + ] + ], + 'jobs' => [ + 'source' => 'cm_co_jobs', + 'displayField' => 'id', + 'fieldMap' => [ + 'job_type' => 'plugin', + 'job_mode' => null, + 'queue_time' => 'register_time', + 'complete_time' => 'finish_time', + 'job_type_fk' => null, + 'job_params' => 'parameters', + 'requeued_from_co_job_id' => 'requeued_from_job_id', + // XXX CFM-246 not yet supported + 'max_retry' => null, + 'max_retry_count' => null + ], + 'preRow' => 'filterJobs' + ], + 'job_history_records' => [ + 'source' => 'cm_co_job_history_records', + 'displayField' => 'id', + 'fieldMap' => [ + 'co_job_id' => 'job_id', + 'co_person_id' => 'person_id', + 'org_identity_id' => 'external_identity_id' + ] + ] + ]; + + // Table specific field mapping cache + protected $cache = []; + + // Make some objects more easily accessible + protected $inconn = null; + protected $outconn = null; + // Cache the driver for ease of workarounds + protected $outdriver = null; + + // Shell arguments, for easier access + protected $args = null; + protected $io = null; + + /** + * Build an Option Parser. + * + * @since COmanage Registry v5.0.0 + * @param ConsoleOptionParser $parser ConsoleOptionParser + * @return ConsoleOptionParser ConsoleOptionParser + */ + + protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser { + $parser->addOption('login-identifier-copy', [ + 'help' => __d('command', 'tm.login-identifier-copy'), + 'boolean' => true + ]); + + $parser->addOption('login-identifier-type', [ + 'help' => __d('command', 'tm.login-identifier-type') + ]); + + $parser->setEpilog(__d('command', 'tm.epilog')); + + return $parser; + } + + /** + * Cache results as configured for the specified table. + * + * @since COmanage Registry v5.0.0 + * @param string $table Table to cache + * @param array $row Row of table data + */ + + protected function cacheResults(string $table, array $row) { + if(!empty($this->tables[$table]['cache'])) { + // Cache the requested fields. For now, at least, we key on row ID only. + foreach($this->tables[$table]['cache'] as $field) { + if(is_array($field)) { + // This is a list of fields, create a composite key that point to the row ID + + $label = ""; + $key = ""; + + foreach($field as $subfield) { + // eg: co_id+attribute+value+ + $label .= $subfield . "+"; + + // eg: 2+Identifier.type+eppn+ + $key .= $row[$subfield] . "+"; + } + + $this->cache[$table][$label][$key] = $row['id']; + } else { + // Map id to the requested field + $this->cache[$table]['id'][ $row['id'] ][$field] = $row[$field]; + } + } + } + } + + /** + * Check if a group membership is actually asserted, and reassign ownerships. + * + * @since COmanage Registry v5.0.0 + * @param array $origRow Row of table data (original data) + * @param array $row Row of table data (post fixes) + * @throws InvalidArgumentException + */ + + protected function check_group_membership(array $origRow, array $row) { + // We need to handle the various member+owner scenarios, but basically + // (1) If 'owner' is set, manually create a Group Membership in the appropriate + // Owners Group (we need to be called via preRow to do this) + // (2) If 'member' is NOT set, throw an exception so we don't create + // in invalid membership + // (3) Otherwise just return so the Membership gets created + + if($origRow['owner'] && !$origRow['deleted'] && !$origRow['co_group_member_id']) { + // Create a membership in the appropriate owners group, but not + // on changelog entries + + if(!empty($this->cache['groups']['id'][ $origRow['co_group_id'] ]['owners_group_id'])) { + $ownerRow = [ + 'group_id' => $this->cache['groups']['id'][ $origRow['co_group_id'] ]['owners_group_id'], + 'person_id' => $origRow['co_person_id'], + 'created' => $origRow['created'], + 'modified' => $origRow['modified'], + 'group_member_id' => null, + 'revision' => 0, + 'deleted' => 'f', + 'actor_identifier' => $origRow['actor_identifier'] + ]; + + $this->outconn->insert('group_members', $ownerRow); + } else { + $this->io->error("Could not find owners group for CoGroupMember " . $origRow['id']); + } + } + + if(!$row['member'] && !$row['owner']) { + throw new \InvalidArgumentException('member not set on GroupMember'); + } + } + + /** + * Create an Owners Group for an existing Group. + * + * @since COmanage Registry v5.0.0 + * @param array $origRow Row of table data (original data) + * @param array $row Row of table data (post fixes) + */ + + protected function createOwnersGroups() { + // Pull all Groups and create Owners Group for them. Deployments generally + // don't have so many Groups that we need PaginatedSqlIterator, but we'll + // use it here anyway just in case. + + // By doing this once for the table we avoid having to sort through + // changelog metadata to figure out which rows to actually create owners + // groups for. + + $Groups = TableRegistry::getTableLocator()->get('Groups'); + + $iterator = new PaginatedSqlIterator($Groups, []); + + foreach($iterator as $k => $group) { + try { + // Because PaginatedSqlIterator will pick up new Groups as we create them, + // we need to check for any Owners groups (that we just created) and skip them. + if(!$group->isOwners()) { + $ownersGid = $Groups->createOwnersGroup($group); + + // We need to manually populate the cache + $this->cache['groups']['id'][$group->id]['owners_group_id'] = $ownersGid; + } + } + catch(\Exception $e) { + $this->io->error("Failed to create owners group for " + . $group->name . " (" . $group->id . "): " + . $e->getMessage()); + } + } + } + + /** + * Execute the Transmogrify Command. + * + * @since COmanage Registry v5.0.0 + * @param Arguments $args Command Arguments + * @param ConsoleIo $io Console IO + */ + + public function execute(Arguments $args, ConsoleIo $io) { + $this->args = $args; + $this->io = $io; + + // Load data from the inbound "transmogrify" database to a newly created + // (and empty) v5 database. The schema should already be applied to the + // new database. + + // First, open connections to both old and new databases. + // Use the Cake ConnectionManager to get the database configs to pass to DBAL. + $indb = ConnectionManager::get('transmogrify'); + $incfg = $indb->config(); + + if(empty($incfg)) { + throw new \InvalidArgumentException(__d('error', 'db.config', ["transmogrify"])); + } + + $outdb = ConnectionManager::get('default'); + $outcfg = $outdb->config(); + + if(empty($outcfg)) { + throw new \InvalidArgumentException(__d('error', 'db.config', ["default"])); + } + + $inconfig = new \Doctrine\DBAL\Configuration(); + + $cargs = [ + 'dbname' => $incfg['database'], + 'user' => $incfg['username'], + 'password' => $incfg['password'], + 'host' => $incfg['host'], + 'driver' => ($incfg['driver'] == 'Cake\Database\Driver\Postgres' ? "pdo_pgsql" : "mysqli") + ]; + + // For MySQL SSL + if(!empty($incfg['ssl_ca'])) { + // mysqli supports SSL configuration + $cargs['ssl_ca'] = $incfg['ssl_ca']; + } + + $this->inconn = DriverManager::getConnection($cargs, $inconfig); + + $outconfig = new \Doctrine\DBAL\Configuration(); + + $cargs = [ + 'dbname' => $outcfg['database'], + 'user' => $outcfg['username'], + 'password' => $outcfg['password'], + 'host' => $outcfg['host'], + 'driver' => ($outcfg['driver'] == 'Cake\Database\Driver\Postgres' ? "pdo_pgsql" : "mysqli") + ]; + + // For MySQL SSL + if(!empty($outcfg['ssl_ca'])) { + // mysqli supports SSL configuration + $cargs['ssl_ca'] = $outcfg['ssl_ca']; + } + + $this->outconn = DriverManager::getConnection($cargs, $outconfig); + $this->outdriver = $cargs['driver']; + + // We accept a list of table names, mostly for testing purposes + $atables = $args->getArguments(); + + $schemaPrefix = ''; + + if($this->outdriver == 'mysqli') { + // We prefix the database to the table to avoid having to quote table names + // that match (MySQL) reserved keywords (in particular "groups"). While + // theoretically Postgres supports the same notation, it seems to cause + // more problems than it solves. + + $schemaPrefix = $outcfg['database'] . '.'; + } + + // Register the current version for future upgrade purposes + + $targetVersion = rtrim(file_get_contents(CONFIG . DS . "VERSION")); + + $metaTable = $this->getTableLocator()->get('Meta'); + $metaTable->setUpgradeVersion($targetVersion, true); + + foreach(array_keys($this->tables) as $t) { + // If we were given a list of tables see if this table is in the list + if(!empty($atables) && !in_array($t, $atables)) + continue; + + $io->info(Inflector::classify($t) . "(" . $t . ")"); + + // Run any pre processing functions for the table. + + if(!empty($this->tables[$t]['preTable'])) { + $p = $this->tables[$t]['preTable']; + + $this->$p(); + } + + $count = $this->inconn->fetchOne("SELECT COUNT(*) FROM " . $this->tables[$t]['source']); + + $insql = "SELECT * FROM " . $this->tables[$t]['source'] . " ORDER BY id ASC"; + $stmt = $this->inconn->executeQuery($insql); + + // Check if the table contains data + $Model = $this->getTableLocator()->get($t); + if($Model->find()->count() > 0) { + $io->warning("Skipping Transmogrification. Table (" . $t . ") is not empty. Drop the database (or truncate) and start over."); + continue; + } + + $tally = 0; + $warns = 0; + $err = 0; + + while($row = $stmt->fetch()) { + if(!empty($row[ $this->tables[$t]['displayField'] ])) { + $io->verbose("$t " . $row[ $this->tables[$t]['displayField'] ]); + } + + try { + // Make a copy of the original data for any post processing followups + $origRow = $row; + + // Run any pre processing functions for the row. + + if(!empty($this->tables[$t]['preRow'])) { + $p = $this->tables[$t]['preRow']; + + $this->$p($origRow, $row); + } + + // Do this before fixBooleans since we'll insert some + $this->fixChangelog($t, $row, isset($this->tables[$t]['addChangelog']) && $this->tables[$t]['addChangelog']); + + $this->fixBooleans($t, $row); + + $this->mapFields($t, $row); + + $this->outconn->insert($schemaPrefix.$t, $row); + + $this->cacheResults($t, $row); + + // Run any post processing functions for the row. + + if(!empty($this->tables[$t]['postRow'])) { + $p = $this->tables[$t]['postRow']; + + $this->$p($origRow, $row); + } + } + catch(ForeignKeyConstraintViolationException $e) { + // A foreign key associated with this record did not load, so we can't + // load this record. This can happen, eg, because the source_field_id + // did not load, perhaps because it was associated with an Org Identity + // not linked to a CO Person that was not migrated. + $warns++; + $io->warning("Skipping $t record " . $row['id'] . " due to invalid foreign key: " . $e->getMessage()); + } + catch(\InvalidArgumentException $e) { + // If we can't find a value for mapping we skip the record + // (ie: mapFields basically requires a successful mapping) + $warns++; + $io->warning("Skipping $t record " . $row['id'] . ": " . $e->getMessage()); + } + catch(\Exception $e) { + $err++; + $io->error("$t record " . $row['id'] . ": " . $e->getMessage()); + } + + $tally++; + + if(!$this->args->getOption('quiet') && !$this->args->getOption('verbose')) { + // We don't output the progress bar for quiet for obvious reasons, + // or for verbose so we don't interfere with the extra output + $this->cliLogPercentage($tally, $count); + } + } + + // Log warning and error count. + $io->out("(Warnings: " . $warns . ")"); + $io->out("(Errors: " . $err . ")"); + + // Reset sequence to next value after current max. + $max = $this->outconn->fetchOne('SELECT MAX(id) FROM ' . $t); + $max++; + $this->io->info("Resetting sequence for $t to $max"); + + // Strictly speaking we should use prepared statements, but we control the + // data here, and also we're executing a maintenance operation (so query + // optimization is less important) + if($this->outdriver == 'mysqli') { + $outsql = "ALTER TABLE `" . $t . "` AUTO_INCREMENT = " . $max; + } else { + $outsql = "ALTER SEQUENCE " . $t . "_id_seq RESTART WITH " . $max; + } + $this->outconn->executeQuery($outsql); + + // Run any post processing functions for the table. + if(!empty($this->tables[$t]['postTable'])) { + $p = $this->tables[$t]['postTable']; + + $this->$p(); + } + } + } + + /** + * Filter Jobs. + * + * @since COmanage Registry v5.0.0 + * @param array $origRow Row of table data (original data) + * @param array $row Row of table data (post fixes) + * @throws InvalidArgumentException + */ + + protected function filterJobs(array $origRow, array $row) { + // We don't update any of the attributes, but for rows with unsupported data + // we throw an exception so they don't transmogrify. + + if($row['status'] == 'GO' || $row['status'] == 'Q') { + throw new \InvalidArgumentException("Job is Queued or In Progress"); + } + + if($row['job_type'] == 'EX' || $row['job_type'] == 'OS') { + throw new \InvalidArgumentException("Legacy Job types cannot be transmogrified"); + } + } + + /** + * Find the CO for a row of table data, based on a foreign key. + * + * @since COmanage Registry v5.0.0 + * @param array $row Row of table data + * @return int CO ID + * @throws InvalidArgumentException + */ + + protected function findCoId(array $row) { + // By the time we're called, we should have transmogrified the Org Identity + // and CO Person data, so we can just walk the caches + + if(!empty($row['person_id'])) { + if(isset($this->cache['people']['id'][ $row['person_id'] ]['co_id'])) { + return $this->cache['people']['id'][ $row['person_id'] ]['co_id']; + } + } elseif(!empty($row['external_identity_id'])) { + // Map the OrgIdentity to a CO Person, then to the CO + if(!empty($this->cache['external_identities']['id'][ $row['external_identity_id'] ]['person_id'])) { + $personId = $this->cache['external_identities']['id'][ $row['external_identity_id'] ]['person_id']; + + if(isset($this->cache['people']['id'][ $personId ]['co_id'])) { + return $this->cache['people']['id'][ $personId ]['co_id']; + } + } + } elseif(!empty($row['group_id'])) { + if(isset($this->cache['groups']['id'][ $row['group_id'] ]['co_id'])) { + return $this->cache['groups']['id'][ $row['group_id'] ]['co_id']; + } + } + // We also support being called using the old keys for use in the preRow context + elseif(!empty($row['org_identity_id'])) { + // Map the OrgIdentity to a CO Person, then to the CO + if(!empty($this->cache['external_identities']['id'][ $row['org_identity_id'] ]['person_id'])) { + $personId = $this->cache['external_identities']['id'][ $row['org_identity_id'] ]['person_id']; + + if(isset($this->cache['people']['id'][ $personId ]['co_id'])) { + return $this->cache['people']['id'][ $personId ]['co_id']; + } + } + } elseif(!empty($row['co_person_id'])) { + if(isset($this->cache['people']['id'][ $row['co_person_id'] ]['co_id'])) { + return $this->cache['people']['id'][ $row['co_person_id'] ]['co_id']; + } + } + + throw new \InvalidArgumentException('CO not found for record'); + } + + /** + * Translate booleans to string literals to work around DBAL Postgres boolean handling. + * + * @since COmanage Registry v5.0.0 + * @param string $table Table Name + * @param array $row Row of attributes, fixed in place + */ + + protected function fixBooleans(string $table, array &$row) { + $attrs = ['deleted']; + + // We could introspect this from the schema file... + if(!empty($this->tables[$table]['booleans'])) { + $attrs = array_merge($attrs, $this->tables[$table]['booleans']); + } + + foreach($attrs as $a) { + if(isset($row[$a]) && gettype($row[$a]) == 'boolean') { + // DBAL Postgres boolean handling seems to be somewhat buggy, see history in + // this issue: https://github.com/doctrine/dbal/issues/1847 + // We need to (more generically than this hack) convert from boolean to char + // to avoid errors on insert + if($this->outdriver == 'mysqli') { + $row[$a] = ($row[$a] ? '1' : '0'); + } else { + $row[$a] = ($row[$a] ? 't' : 'f'); + } + } + } + } + + /** + * Populate empty Changelog data from legacy records + * + * @since COmanage Registry v5.0.0 + * @param string $table Table Name + * @param array $row Row of attributes, fixed in place + * @param bool $force If true, always create keys + */ + + protected function fixChangelog(string $table, array &$row, bool $force=false) { + if($force || (array_key_exists('deleted', $row) && is_null($row['deleted']))) { + $row['deleted'] = false; + } + + if($force || (array_key_exists('revision', $row) && is_null($row['revision']))) { + $row['revision'] = 0; + } + + if($force || (array_key_exists('actor_identifier', $row) && is_null($row['actor_identifier']))) { + $row['actor_identifier'] = 'Transmogrification'; + } + + // The parent FK should remain NULL since this is the original record. + /* + // If the table was renamed, we need to rename the changelog key as well. + // NOTE: We don't actually do this here because it creates issues with the + // order of field processing. Instead, each key must be renamed + // manually in the fieldMap. + // eg: cm_org_identities -> org_identity_id + $oldfk = Inflector::singularize(substr($this->tables[$table]['source'], 3)) . "_id"; + // eg: external_identities -> external_identity_id + $newfk = Inflector::singularize($table) . "_id"; + + if($oldfk != $newfk && array_key_exists($oldfk, $row)) { + $row[$newfk] = $row[$oldfk]; + unset($row[$oldfk]); + }*/ + } + + /** + * Insert default CO Settings. + * + * @since COmanage Registry v5.0.0 + */ + + protected function insertDefaultSettings() { + // Create a CoSetting for any CO that didn't previously have one. + + $createdSettings = []; + $createdCos = array_keys($this->cache['cos']['id']); + + foreach($this->cache['co_settings']['id'] as $co_setting_id => $cached) { + $createdSettings[] = $cached['co_id']; + } + + $emptySettings = array_values(array_diff($createdCos, $createdSettings)); + + if(!empty($emptySettings)) { + $CoSettings = TableRegistry::getTableLocator()->get('CoSettings'); + + foreach($emptySettings as $coId) { + // Insert a default row into CoSettings for this CO ID + try { + $CoSettings->addDefaults($coId); + } catch (\ConflictException $e) { + // skip + } + } + } + } + + /** + * Insert default Pronoun types. + * + * @since COmanage Registry v5.0.0 + */ + + protected function insertPronounTypes() { + // Since the Pronoun MVEA didn't exist in v4, we'll need to create the + // default types for all COs. + + $Types = TableRegistry::getTableLocator()->get('Types'); + + foreach(array_keys($this->cache['cos']['id']) as $coId) { + $Types->addDefault($coId, 'Pronouns.type'); + } + } + + /** + * Map fields that have been renamed from Registry Classic to Registry PE. + * + * @since COmanage Registry v5.0.0 + * @param string $table Table Name + * @param array $row Row of attributes, fixed in place + * @throws InvalidArgumentException + */ + + protected function mapFields(string $table, array &$row) { + // oldname => newname, or &newname, which is a function to call. + // Note functions can returns more than one mapping + $fields = []; + + if(!empty($this->tables[$table]['fieldMap'])) { + $fields = $this->tables[$table]['fieldMap']; + } + + foreach($fields as $oldname => $newname) { + if(!$newname) { + // This attribute doesn't map, so simply unset it + unset($row[$oldname]); + } elseif($newname[0] == '&') { + // This is a function to map the field, in which case we reuse the old name + $f = substr($newname, 1); + + // We always pass the entire row so the mapping function can implement + // whatever logic it needs + $row[$oldname] = $this->$f($row); + + if(!$row[$oldname]) { + throw new \InvalidArgumentException("Could not find value for $table $oldname"); + } + } elseif($newname[0] == '?') { + // This is a default value to populate if the current value is null + $v = substr($newname, 1); + + if($row[$oldname] === null) { + $row[$oldname] = $v; + } + } else { + // Copy the value to the new name, then unset the old name + $row[$newname] = $row[$oldname]; + unset($row[$oldname]); + } + } + } + + /** + * Map an address type string to a foreign key. + * + * @since COmanage Registry v5.0.0 + * @param array $row Row of table data + * @return int type_id + */ + + protected function map_address_type(array $row) { + return $this->map_type($row, 'Addresses.type', $this->findCoId($row)); + } + + /** + * Map an affiliation type string to a foreign key. + * + * @since COmanage Registry v5.0.0 + * @param array $row Row of table data + * @return int type_id + */ + + protected function map_affiliation_type(array $row) { + return $this->map_type($row, 'PersonRoles.affiliation_type', $this->findCoId($row), 'affiliation'); + } + + /** + * Map an email type string to a foreign key. + * + * @since COmanage Registry v5.0.0 + * @param array $row Row of table data + * @return int type_id + */ + + protected function map_email_type(array $row) { + return $this->map_type($row, 'EmailAddresses.type', $this->findCoId($row)); + } + + /** + * Map an Extended Type attribute name for model name changes. + * + * @since COmanage Registry v5.0.0 + * @param array $row Row of table data + * @return string Updated attribute name + */ + + protected function map_extended_type(array $row) { + switch($row['attribute']) { + case 'CoDepartment.type': + return 'Departments.type'; + case 'CoPersonRole.affiliation': + return 'PersonRoles.affiliation_type'; + } + + // For everything else, we need to pluralize the model name + $bits = explode('.', $row['attribute'], 2); + + return Inflector::pluralize($bits[0]) . "." . $bits[1]; + } + + /** + * Map login identifiers, in accordance with the configuration. + * + * @since COmanage Registry v5.0.0 + * @param array $origRow Row of table data (original data) + * @param array $row Row of table data (post fixes) + * @throws InvalidArgumentException + */ + + protected function map_login_identifiers(array $origRow, array $row) { + // There might be multiple reasons to copy the row, but we only want to + // copy it once. + $copyRow = false; + + if(!empty($origRow['org_identity_id'])) { + if($this->args->getOption('login-identifier-copy') + && $origRow['login']) { + $copyRow = true; + } + + // Note the argument here is the old v4 string (eg "eppn") and not the + // PE foreign key + if($this->args->getOption('login-identifier-type') + && $origRow['type'] == $this->args->getOption('login-identifier-type')) { + $copyRow = true; + } + + // Identifiers attached to External Identities do not have login flags in PE + $row['login'] = false; + } + + if($copyRow) { + // Find the Person ID associated with this External Identity ID + + if(!empty($this->cache['external_identities']['id'][ $origRow['org_identity_id'] ]['person_id'])) { + // Insert a new row attached to the Person, leave the original record + // (ie: $row) untouched + + $copiedRow = [ + 'person_id' => $this->map_org_identity_co_person_id(['id' => $origRow['org_identity_id']]), + 'identifier' => $origRow['identifier'], + 'type_id' => $this->map_identifier_type($origRow), + 'status' => $origRow['status'], + 'login' => true, + 'created' => $origRow['created'], + 'modified' => $origRow['modified'] + ]; + + // Set up changelog and fix booleans + $this->fixChangelog('identifiers', $copiedRow, true); + $this->fixBooleans('identifiers', $copiedRow); + + try { + $this->outconn->insert('identifiers', $copiedRow); + } catch (\Doctrine\DBAL\Exception\UniqueConstraintViolationException $e) { + $this->io->warning("record already exists: " . print_r($copiedRow, true)); + } + } + } + } + + /** + * Map an identifier type string to a foreign key. + * + * @since COmanage Registry v5.0.0 + * @param array $row Row of table data (ignored) + * @return int type_id + */ + + protected function map_identifier_type(array $row) { + return $this->map_type($row, 'Identifiers.type', $this->findCoId($row)); + } + + /** + * Map a name type string to a foreign key. + * + * @since COmanage Registry v5.0.0 + * @param array $row Row of table data (ignored) + * @return int type_id + */ + + protected function map_name_type(array $row) { + return $this->map_type($row, 'Names.type', $this->findCoId($row)); + } + + /** + * Return a timestamp equivalent to now. + * + * @since COmanage Registry v5.0.0 + * @param array $row Row of table data (ignored) + * @return string Timestamp + */ + + protected function map_now(array $row) { + if(empty($this->cache['now'])) { + $created = new \Datetime('now'); + $this->cache['now'] = $created->format('Y-m-d H:i:s'); + } + + return $this->cache['now']; + } + + /** + * Map an Org Identity ID to a CO Person ID + * + * @since COmanage Registry v5.0.0 + * @param array $row Row of Org Identity table data + * @return int CO Person ID + */ + + protected function map_org_identity_co_person_id(array $row) { + // PE eliminates OrgIdentityLink, so we need to map each Org Identity to + // a Person ID. This is a bit trickier than it sounds, since an Org Identity + // could have been relinked. + + // Before Transmogrification, we require that Org Identities are unpooled. + // (This is probably how most deployments are set up, but there may be some + // legacy deployments out there.) This ensures whatever CO Person the Org + // Identity currently maps to through CoOrgIdentityLink is in the same CO. + + // There may be multiple mappings if the Org Identity was relinked. Basically + // we're going to lose the multiple mappings, since we can only return one + // value here. (Ideally, we would inject multiple OrgIdentities into the new + // table, but this ends up being rather tricky, since we have to figure out + // what row id to assign, and for the moment we don't have a mechanism to + // do that.) Historical information remains available in history_records, + // and if the deployer keeps an archive of the old database. + + // To figure out which person_id to use, we pull the record with the + // highest revision number. Note we might be transmogrifying a deleted row, + // so we can't ignore deleted rows here. + + if(empty($this->cache['org_identities']['co_people'])) { + $this->io->info('Populating org identity map...'); + + // We pull deleted rows because we might be migrating deleted rows + $mapsql = "SELECT * FROM cm_co_org_identity_links"; + $stmt = $this->inconn->query($mapsql); + + while($r = $stmt->fetch()) { + if(!empty($r['org_identity_id'])) { + if(isset($this->cache['org_identities']['co_people'][ $r['org_identity_id'] ][ $r['revision'] ])) { + // If for some reason we already have a record, it's probably due to + // improper unpooling from a legacy deployment. We'll accept only the + // first record and throw warnings on the others. + + $this->io->warning("Found existing CO Person for Org Identity " . $r['org_identity_id'] . ", skipping"); + } else { + $this->cache['org_identities']['co_people'][ $r['org_identity_id'] ][ $r['revision'] ] = $r['co_person_id']; + } + } + } + } + + if(!empty($this->cache['org_identities']['co_people'][ $row['id'] ])) { + // Return the record with the highest revision number + $rev = max(array_keys($this->cache['org_identities']['co_people'][ $row['id'] ])); + + return $this->cache['org_identities']['co_people'][ $row['id'] ][$rev]; + } + + return null; + } + + /** + * Map a telephone type string to a foreign key. + * + * @since COmanage Registry v5.0.0 + * @param array $row Row of table data + * @return int type_id + */ + + protected function map_telephone_type(array $row) { + return $this->map_type($row, 'TelephoneNumbers.type', $this->findCoId($row)); + } + + /** + * Map a type string to a foreign key. + * + * @since COmanage Registry v5.0.0 + * @param array $row Row of table data + * @param string $type Type to map (types:attribute) + * @param int $coId CO ID + * @param string $attr Row column to use for type value + * @return int type_id + * @throws InvalidArgumentException + */ + + protected function map_type(array $row, string $type, $coId, string $attr="type") { + if(!$coId) { + throw new \InvalidArgumentException("CO ID not provided for $type " . $row['id']); + } + + $key = $coId . "+" . $type . "+" . $row[$attr] . "+"; + + if(empty($this->cache['types']['co_id+attribute+value+'][$key])) { + throw new \InvalidArgumentException("Type not found for " . $key); + } + + return $this->cache['types']['co_id+attribute+value+'][$key]; + } + + /** + * Map a URL type string to a foreign key. + * + * @since COmanage Registry v5.0.0 + * @param array $row Row of table data + * @return int type_id + */ + + protected function map_url_type(array $row) { + return $this->map_type($row, 'Urls.type', $this->findCoId($row)); + } + + /** + * Set a default value for CO Settings Permitted Telephone Number Fields. + * + * @since COmanage Registry v5.0.0 + * @param array $row Row of table data + * @return string Default value + */ + + protected function populate_co_settings_phone(array $row) { + return \App\Lib\Enum\PermittedTelephoneNumberFieldsEnum::CANE; + } + + /** + * Process Extended Attributes by converting them to Ad Hoc Attributes. + * + * @since COmanage Registry v5.0.0 + */ + + protected function processExtendedAttributes() { + // This is intended to run AFTER AdHocAttributes so that we don't stomp on + // the row identifiers. + + // First, pull the old Extended Attribute configuration. + $extendedAttrs = []; + + $insql = "SELECT * FROM cm_co_extended_attributes ORDER BY id ASC"; + $stmt = $this->inconn->query($insql); + + while($row = $stmt->fetch()) { + $extendedAttrs[ $row['co_id'] ][] = $row['name']; + } + + if(empty($extendedAttrs)) { + // No need to do anything further if no attributes are configured + return; + } + + foreach(array_keys($extendedAttrs) as $coId) { + $insql = "SELECT * FROM cm_co" . $coId . "_person_extended_attributes"; + $stmt = $this->inconn->query($insql); + + while($eaRow = $stmt->fetch()) { + // If we didn't transmogrify the parent row for some reason then trying + // to insert the ad_hoc_attributes will throw an error. + if(!empty($this->cache['person_roles']['id'][ $eaRow['co_person_role_id'] ])) { + foreach($extendedAttrs[$coId] as $ea) { + $adhocRow = [ + 'person_role_id' => $eaRow['co_person_role_id'], + 'tag' => $ea, + 'value' => $eaRow[$ea], + 'created' => $eaRow['created'], + 'modified' => $eaRow['modified'] + ]; + + // Extended Attributes were not changelog enabled + $this->fixChangelog('ad_hoc_attributes', $adhocRow, true); + $this->fixBooleans('ad_hoc_attributes', $adhocRow); + + try { + $this->outconn->insert('ad_hoc_attributes', $adhocRow); + } catch (\Doctrine\DBAL\Exception\UniqueConstraintViolationException $e) { + $this->io->warning("record already exists: " . print_r($adhocRow, true)); + } + } + } + } + } + } + + /** + * Split an External Identity into an External Identity Role. + * + * @since COmanage Registry v5.0.0 + * @param array $origRow Row of table data (original data) + * @param array $row Row of table data (post fixes) + */ + + protected function split_external_identity(array $origRow, array $row) { + $roleRow = []; + + // We could set the row ID to be the same as the original parent, but then + // we'd have to reset the sequence after the table is finished migrating. + + foreach([ + // Parent Key + 'id' => 'external_identity_id', + 'o' => 'organization', + 'ou' => 'department', + 'manager_identifier' => 'manager_identifier', + 'sponsor_identifier' => 'sponsor_identifier', + 'status' => 'status', + 'title' => 'title', + 'valid_from' => 'valid_from', + 'valid_through' => 'valid_through', + // Fix up changelog + 'org_identity_id' => 'external_identity_role_id', + 'revision' => 'revision', + 'deleted' => 'deleted', + 'actor_identifier' => 'actor_identifier', + 'created' => 'created', + 'modified' => 'modified' + ] as $oldKey => $newKey) { + $roleRow[$newKey] = $origRow[$oldKey]; + } + + // Affiliation requires special handling. We need to use the post-fixed $row + // because map_affiliation_type calls findCoId which uses the foreign key to + // lookup the CO ID in the cache, however by the time we've been called + // affiliation has been null'd out (since we're moving it to the role row). + // So shove it back in before calling map_affiliation_type. + $row['affiliation'] = $origRow['affiliation']; + $roleRow['affiliation_type_id'] = $this->map_affiliation_type($row); + + // Fix up changelog + // Since we're creating a new row, we have to manually fix up booleans + $roleRow['deleted'] = ($roleRow['deleted'] ? 't' : 'f'); + + $this->outconn->insert('external_identity_roles', $roleRow); + } +} diff --git a/app/src/Console/Installer.php b/app/src/Console/Installer.php new file mode 100644 index 000000000..8d98c7709 --- /dev/null +++ b/app/src/Console/Installer.php @@ -0,0 +1,246 @@ +getIO(); + + $rootDir = dirname(dirname(__DIR__)); + + static::createAppLocalConfig($rootDir, $io); + static::createWritableDirectories($rootDir, $io); + + static::setFolderPermissions($rootDir, $io); + static::setSecuritySalt($rootDir, $io); + + $class = 'Cake\Codeception\Console\Installer'; + if (class_exists($class)) { + $class::customizeCodeceptionBinary($event); + } + } + + /** + * Create config/app_local.php file if it does not exist. + * + * @param string $dir The application's root directory. + * @param \Composer\IO\IOInterface $io IO interface to write to console. + * @return void + */ + public static function createAppLocalConfig($dir, $io) + { + $appLocalConfig = $dir . '/config/app_local.php'; + $appLocalConfigTemplate = $dir . '/config/app_local.example.php'; + if (!file_exists($appLocalConfig)) { + copy($appLocalConfigTemplate, $appLocalConfig); + $io->write('Created `config/app_local.php` file'); + } + } + + /** + * Create the `logs` and `tmp` directories. + * + * @param string $dir The application's root directory. + * @param \Composer\IO\IOInterface $io IO interface to write to console. + * @return void + */ + public static function createWritableDirectories($dir, $io) + { + foreach (static::WRITABLE_DIRS as $path) { + $path = $dir . '/' . $path; + if (!file_exists($path)) { + mkdir($path); + $io->write('Created `' . $path . '` directory'); + } + } + } + + /** + * Set globally writable permissions on the "tmp" and "logs" directory. + * + * This is not the most secure default, but it gets people up and running quickly. + * + * @param string $dir The application's root directory. + * @param \Composer\IO\IOInterface $io IO interface to write to console. + * @return void + */ + public static function setFolderPermissions($dir, $io) + { + // ask if the permissions should be changed + if ($io->isInteractive()) { + $validator = function ($arg) { + if (in_array($arg, ['Y', 'y', 'N', 'n'])) { + return $arg; + } + throw new Exception('This is not a valid answer. Please choose Y or n.'); + }; + $setFolderPermissions = $io->askAndValidate( + 'Set Folder Permissions ? (Default to Y) [Y,n]? ', + $validator, + 10, + 'Y' + ); + + if (in_array($setFolderPermissions, ['n', 'N'])) { + return; + } + } + + // Change the permissions on a path and output the results. + $changePerms = function ($path) use ($io) { + $currentPerms = fileperms($path) & 0777; + $worldWritable = $currentPerms | 0007; + if ($worldWritable == $currentPerms) { + return; + } + + $res = chmod($path, $worldWritable); + if ($res) { + $io->write('Permissions set on ' . $path); + } else { + $io->write('Failed to set permissions on ' . $path); + } + }; + + $walker = function ($dir) use (&$walker, $changePerms) { + $files = array_diff(scandir($dir), ['.', '..']); + foreach ($files as $file) { + $path = $dir . '/' . $file; + + if (!is_dir($path)) { + continue; + } + + $changePerms($path); + $walker($path); + } + }; + + $walker($dir . '/tmp'); + $changePerms($dir . '/tmp'); + $changePerms($dir . '/logs'); + } + + /** + * Set the security.salt value in the application's config file. + * + * @param string $dir The application's root directory. + * @param \Composer\IO\IOInterface $io IO interface to write to console. + * @return void + */ + public static function setSecuritySalt($dir, $io) + { + $newKey = hash('sha256', Security::randomBytes(64)); + static::setSecuritySaltInFile($dir, $io, $newKey, 'app_local.php'); + } + + /** + * Set the security.salt value in a given file + * + * @param string $dir The application's root directory. + * @param \Composer\IO\IOInterface $io IO interface to write to console. + * @param string $newKey key to set in the file + * @param string $file A path to a file relative to the application's root + * @return void + */ + public static function setSecuritySaltInFile($dir, $io, $newKey, $file) + { + $config = $dir . '/config/' . $file; + $content = file_get_contents($config); + + $content = str_replace('__SALT__', $newKey, $content, $count); + + if ($count == 0) { + $io->write('No Security.salt placeholder to replace.'); + + return; + } + + $result = file_put_contents($config, $content); + if ($result) { + $io->write('Updated Security.salt value in config/' . $file); + + return; + } + $io->write('Unable to update Security.salt value.'); + } + + /** + * Set the APP_NAME value in a given file + * + * @param string $dir The application's root directory. + * @param \Composer\IO\IOInterface $io IO interface to write to console. + * @param string $appName app name to set in the file + * @param string $file A path to a file relative to the application's root + * @return void + */ + public static function setAppNameInFile($dir, $io, $appName, $file) + { + $config = $dir . '/config/' . $file; + $content = file_get_contents($config); + $content = str_replace('__APP_NAME__', $appName, $content, $count); + + if ($count == 0) { + $io->write('No __APP_NAME__ placeholder to replace.'); + + return; + } + + $result = file_put_contents($config, $content); + if ($result) { + $io->write('Updated __APP_NAME__ value in config/' . $file); + + return; + } + $io->write('Unable to update __APP_NAME__ value.'); + } +} diff --git a/app/src/Controller/AdHocAttributesController.php b/app/src/Controller/AdHocAttributesController.php new file mode 100644 index 000000000..a5898f686 --- /dev/null +++ b/app/src/Controller/AdHocAttributesController.php @@ -0,0 +1,42 @@ + [ + 'AdHocAttributes.tag' => 'asc' + ] + ]; +} \ No newline at end of file diff --git a/app/src/Controller/AddressesController.php b/app/src/Controller/AddressesController.php new file mode 100644 index 000000000..1db4f3218 --- /dev/null +++ b/app/src/Controller/AddressesController.php @@ -0,0 +1,42 @@ + [ + 'Addresses.street' => 'asc' + ] + ]; +} \ No newline at end of file diff --git a/app/src/Controller/ApiUsersController.php b/app/src/Controller/ApiUsersController.php new file mode 100644 index 000000000..25a690757 --- /dev/null +++ b/app/src/Controller/ApiUsersController.php @@ -0,0 +1,68 @@ + [ + 'ApiUsers.username' => 'asc' + ] + ]; + + /** + * Generate a new API Key. + * + * @since COmanage Registry v5.0.0 + * @param string $id API User ID (This is really an int, but Cake throws an error since it wants to pass type string) + */ + + public function generate(string $id) { + // We don't autogenerate after add because we'd have to interfere with performRedirect. + + try { + $this->set('vv_obj', $this->ApiUsers->get($id)); + $this->set('vv_api_key', $this->ApiUsers->generateKey((int)$id)); + } + catch(Exception $e) { + $this->Flash->error($e->getMessage()); + } + + // Let the view render, but tell it to use a different fields file + $this->set('vv_fields_inc', 'fields-generate.inc'); + [$title, , ] = StringUtilities::entityAndActionToTitle(null, + 'api.key', + $this->request->getParam('action')); + $this->set('vv_title', $title); + + $this->render('/Standard/add-edit-view'); + } +} \ No newline at end of file diff --git a/app/src/Controller/ApiV2Controller.php b/app/src/Controller/ApiV2Controller.php new file mode 100644 index 000000000..e40f30841 --- /dev/null +++ b/app/src/Controller/ApiV2Controller.php @@ -0,0 +1,368 @@ +request->getParam('model'); + // $this->name = Models + // We override $this->name (which is ApiV2) to make it match to the expected + // behavior for UI calls (which is Models, eg "Cous"). We need to do this + // before RegistryAuthComponent runs. + $modelsName = Inflector::camelize($reqModel); + $this->name = $modelsName; + // Similarly, for compatibility with UI related calls we load the model + $this->$modelsName = TableRegistry::getTableLocator()->get($modelsName); + $this->tableName = $this->$modelsName->getTable(); + + // We want API auth, not Web Auth + $this->RegistryAuth->setConfig('apiUser', true); + } + + /** + * Handle an add action for a Standard object. + * + * @since COmanage Registry v5.0.0 + */ + + public function add() { + // $this->name = Models + $modelsName = $this->name; + // $tableName = models + $tableName = $this->tableName; + + $json = $this->request->getData(); // Parsed by BodyParserMiddleware + + if(empty($json[$modelsName])) { + $this->llog('debug', $modelsName . " object not found in request"); + throw new BadRequestException(__d('error', 'api.object', [$modelsName])); + } + + $results = []; + + foreach($json[$modelsName] as $rec) { + try { + $obj = $this->$modelsName->newEntity($rec); + + if($this->$modelsName->saveOrFail($obj)) { + $results[] = ['id' => $obj->id]; + + // Trigger provisioning, letting errors bubble up (AR-GMR-5) + if(method_exists($this->modelsName, "requestProvisioning")) { + $this->llog('rule', "AR-GMR-5 Requesting provisioning for $modelsName " . $obj->id); + $table->requestProvisioning(id: $obj->id, context: ProvisioningContextEnum::Automatic); + } + } + } + catch(\Exception $e) { + // The default exception error isn't particularly user friendly, so + // we dig into the entity errors and try to make a message from there. + $err = $this->exceptionToError($e); + + $this->llog('debug', $err); + $results[] = ['error' => $err]; + } + } + + $this->set('vv_results', $results); + + // Let the view render + $this->render('/Standard/api/v2/json/add-edit'); + } + + /** + * Callback run prior to the request rendering. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Cake Event + * @return EventInterface + */ + + public function beforeRender(\Cake\Event\EventInterface $event) { + $this->set('vv_model_name', $this->name); + $this->set('vv_table_name', $this->tableName); + + return parent::beforeRender($event); + } + + /** + * Handle a delete action for a Standard object. + * + * @since COmanage Registry v5.0.0 + * @param Integer $id Object ID + */ + + public function delete($id) { + // $this->name = Models (ie: from ModelsTable) + $modelsName = $this->name; + + // Make sure the requested object exists + try { + $obj = $this->$modelsName->findById($id)->firstOrFail(); + +// XXX document AR-CO-1 when we implement hard delete/changelog +// note similar logic in StandardController + $this->$modelsName->deleteOrFail($obj); + + if(method_exists($obj, "isReadOnly") && $obj->isReadOnly()) { + throw new BadRequestException(__d('error', 'edit.readonly')); + } + + // Trigger provisioning, letting errors bubble up (AR-GMR-5) + if(method_exists($table, "requestProvisioning")) { + $this->llog('rule', "AR-GMR-5 Requesting provisioning for deleted entity $modelsName " . $obj->id); + $table->requestProvisioning(id: $obj->id, context: ProvisioningContextEnum::Automatic); + } + + // Render an empty view + $this->render('/Standard/api/v2/json/delete'); + } + catch(\Exception $e) { + // findById throws Cake\Datasource\Exception\RecordNotFoundException + + // Rethrow the error so it formats correctly + throw new BadRequestException($this->exceptionToError($e)); + } + } + + /** + * Handle an edit action for a Standard object. + * + * @since COmanage Registry v5.0.0 + * @param Integer $id Object ID + */ + + public function edit($id) { + // $this->name = Models (ie: from ModelsTable) + $modelsName = $this->name; + // $tableName = models + $tableName = $this->$modelsName->getTable(); + + $query = $this->$modelsName->findById($id); + + try { + // Pull the current record + $obj = $query->firstOrFail(); + + if(method_exists($obj, "isReadOnly") && $obj->isReadOnly()) { + throw new BadRequestException(__d('error', 'edit.readonly')); + } + + $json = $this->request->getData(); // Parsed by BodyParserMiddleware + + if(empty($json[$modelsName])) { + throw new BadRequestException(__d('error', 'api.object', [$modelsName])); + } + + $obj = $this->$modelsName->patchEntity($obj, $json[$modelsName]); + + $this->$modelsName->saveOrFail($obj); + + // Trigger provisioning, letting errors bubble up (AR-GMR-5) + if(method_exists($this->$modelsName, "requestProvisioning")) { + $this->llog('rule', "AR-GMR-5 Requesting provisioning for $modelsName " . $obj->id); + $this->$modelsName->requestProvisioning(id: $obj->id, context: ProvisioningContextEnum::Automatic); + } + + // Let the view render + $this->render('/Standard/api/v2/json/add-edit'); + } + catch(\Exception $e) { + // findById throws Cake\Datasource\Exception\RecordNotFoundException + + // The default exception error isn't particularly user friendly, so + // we dig into the entity errors and try to make a message from there. + $err = $this->exceptionToError($e); + + $this->llog('debug', $err); + $results[] = ['error' => $err]; + + throw new BadRequestException($this->exceptionToError($e)); + } + } + + /** + * Convert an Exception to an error string suitable for the REST response, + * including field validation errors. + * + * @since COmanage Registry v5.0.0 + * @param Exception $e Exception + * @return string Error string + */ + + protected function exceptionToError(\Exception $e): string { + // Default error + $err = $e->getMessage(); + + if(method_exists($e, "getEntity")) { + // Check for field validation errors + $errors = $e->getEntity()->getErrors(); + + if(!empty($errors)) { + // Flatten the array into a text string + + $byAttr = []; + + foreach($errors as $attr => $msgs) { + $byAttr[] = $attr . ": " . implode(',', array_values($msgs)); + } + + $err = implode(';', $byAttr); + } + } + + return $err; + } + + /** + * Generate an API Key for an API User. + * + * @since COmanage Registry v5.0.0 + * @param string $id API User ID + */ + + public function generateApiKey(string $id) { + // $id is always an int, but (1) in theory could be a string if we ever + // switched to UUIDs, and (2) ControllerFactory::invoke() (as triggered by + // the configuration in routes.php) only supports type string. + + // Just let exceptions pop up the stack + $api_key = $this->ApiUsers->generateKey((int)$id); + + $this->set('vv_results', ['api_key' => $api_key]); + + // Let the view render + $this->render('/Standard/api/v2/json/add-edit'); + } + + /** + * Generate an index for a set of Standard Objects. + * + * @since COmanage Registry v5.0.0 + */ + + public function index() { + // $modelsName = Models + $modelsName = $this->name; + + $query = $this->$modelsName->find(); + + // PrimaryLinkTrait + $link = $this->getPrimaryLink(true); + + // We automatically allow API calls to be filtered on primary link + if(!empty($link->attr) && !empty($link->value)) { + $query = $query->where([$this->$modelsName->getAlias().'.'.$link->attr => $link->value]); + } + + // This will produce a nested object which is very useful for vue integration + if($this->request->getQuery('extended') !== null) { + $modelContain = []; + $associations = $this->$modelsName->associations(); + foreach($associations->getByType(['BelongsTo']) as $a) { + $modelContain[] = $a->getClassName(); + } + + if(!empty($modelContain)) { + $query = $query->contain($modelContain); + } + } + + if($modelsName == 'AuthenticationEvents') { + // Special case for filtering on authenticated identifier. There is a + // similar filter in AuthenticationEventsController::beforeFilter. + // If other special cases show up this should get refactored into a trait + // populated by the table (or something similar). + + if($this->getRequest()->getQuery('authenticated_identifier')) { + $query = $query->where(['authenticated_identifier' => \App\Lib\Util\StringUtilities::urlbase64decode($this->getRequest()->getQuery('authenticated_identifier'))]); + } else { + // We only allow unfiltered queries for platform users + + if(!$this->RegistryAuth->isPlatformAdmin()) { + throw new \InvalidArgumentException(__d('error', 'input.notprov', 'authenticated_identifier')); + } + } + } + + // This magically makes REST calls paginated... can use eg direction=, + // sort=, limit=, page= + $this->set($this->tableName, $this->paginate($query)); + + // Let the view render + $this->render('/Standard/api/v2/json/index'); + } + + /** + * Generate a view for a set of Standard Objects. + * + * @since COmanage Registry v5.0.0 + */ + + public function view($id = null) { + // $this->name = Models + $modelsName = $this->name; + // $tableName = models + $tableName = $this->$modelsName->getTable(); + + if(empty($id)) { + throw new InvalidArgumentException(__d('error', 'notprov', ['id'])); + } + + $obj = $this->$modelsName->findById($id)->firstOrFail(); + + $this->set($tableName, [$obj]); + + // Let the view render + $this->render('/Standard/api/v2/json/index'); + } +} \ No newline at end of file diff --git a/app/src/Controller/AppController.php b/app/src/Controller/AppController.php new file mode 100644 index 000000000..d8665cd9a --- /dev/null +++ b/app/src/Controller/AppController.php @@ -0,0 +1,608 @@ +loadComponent('RequestHandler'); + + // Add a detector so we can tell restful from non-restful calls + $request = $this->getRequest(); + + $request->addDetector('restful', function($request) { + // $request->is(json|xml) well check the mimetype + return ($request->is('json') || $request->is('xml')); + }); + + // COmanage specific component that handles authn/z processintg + $this->loadComponent('RegistryAuth'); + + // Breadcrumb Manager + $this->loadComponent('Breadcrumb'); + + $ChangelogEventListener = new ChangelogEventListener($this->RegistryAuth); + EventManager::instance()->on($ChangelogEventListener); + + $RuleBuilderEventListener = new RuleBuilderEventListener(); + EventManager::instance()->on($RuleBuilderEventListener); + + if(!$this->request->is('restful')) { + // Initialization for non-RESTful + $this->loadComponent('Flash'); + + /* + * Enable the following components for recommended CakePHP security settings. + * see https://book.cakephp.org/3.0/en/controllers/components/security.html + * + * In general, we don't need these protections for transactional API calls. + */ + $this->loadComponent('Security'); + + // CSRF Protection is enabled via in Middleware via Application.php. + } + } + + /** + * Callback run prior to the request action. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Cake Event + */ + + public function beforeFilter(\Cake\Event\EventInterface $event) { + // Determine the timezone + $this->setTZ(); + + // Determine the requested CO + $this->setCO(); + + if(isset($this->RegistryAuth)) { + // Components might not be loaded on error, so check + + // We need to populate this in beforeFilter (rather than beforeRender) + // so it's available to CosController::select + $this->populateAvailableCos(); + } + + return parent::beforeFilter($event); + } + + /** + * Callback run prior to the view rendering. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Cake Event + */ + + public function beforeRender(\Cake\Event\EventInterface $event) { + // $this->name = Models + $modelsName = $this->name; + + // Views can also inspect the request object to determine the current + // controller and action, but it seems slightly easier to do it once here. + $this->set('vv_controller', $this->request->getParam('controller')); + $this->set('vv_action', $this->request->getParam('action')); + + if(isset($this->RegistryAuth)) { + // Components might not be loaded on error, so check + $this->set('vv_menu_permissions', $this->RegistryAuth->getMenuPermissions($this->getCOID())); + + // Provide the user's application roles to the views. + $this->set('vv_user_roles', $this->RegistryAuth->getApplicationUserRoles($this->getCOID())); + } + + return parent::beforeRender($event); + } + + /** + * Get the current CO. + * + * @since COmanage Registry v5.0.0 + * @return \App\Model\Entity\Co Co Entity or null + */ + + public function getCO(): ?\App\Model\Entity\Co { + if(!$this->cur_co) { + $this->setCO(); + } + + // We'll return null if no CO, since some contexts may need to know that + return $this->cur_co; + } + + /** + * Get the current CO ID. + * + * @since COmanage Registry v5.0.0 + * @return int CO ID, or null + */ + + public function getCOID(): ?int { + $cur_co = $this->getCO(); + + return $cur_co ? $cur_co->id : null; + } + + /** + * Obtain information about the Standard Object's Primary Link, if set. + * The $vv_primary_link view variable is also set. + * + * @since COmanage Registry v5.0.0 + * @param boolean $lookup If true, get the value of the primary link, not just the attribute + * @return object Object holding the primary link attribute, and optionally its value + * @throws \RuntimeException + */ + + public function getPrimaryLink(bool $lookup=false) { + // Did we already figure this out? (But only if $lookup) + if($lookup && isset($this->cur_pl->value)) { + return $this->cur_pl; + } + + // $this->name = Models + $modelsName = $this->name; + // $modelName = Model + $modelName = \Cake\Utility\Inflector::singularize($modelsName); + + $this->cur_pl = new \stdClass(); + + // PrimaryLinkTrait + if(method_exists($this->$modelsName, "getPrimaryLinks") + && $this->$modelsName->getPrimaryLinks()) { + // Some models, in particular MVEAs, can have multiple potential primary + // links. In these cases, only one primary link is valid at a time, so we + // have to look through the available primary links and find one. + + $availablePrimaryLinks = $this->$modelsName->getPrimaryLinks(); + + if($lookup) { + foreach($availablePrimaryLinks as $potentialPrimaryLink) { + // $potentialPrimaryLink will be something like 'attribute_collector_id' + // $potentialPrimaryLinkTable will be something like 'CoreEnroller.AttributeCollectors' + $potentialPrimaryLinkTable = $this->$modelsName->getPrimaryLinkTableName($potentialPrimaryLink); + $potentialPlugin = null; + + // Try to find a value + + if(strstr($potentialPrimaryLinkTable, '.')) { + // For looking up values in records here, we want only the attribute + // itself and not the plugin name (used for hacky notation by + // PrimaryLinkTrait::setPrimaryLink(). Note this is a field and not + // a model, but pluginModel() gets us the bit we need. + + // Store the plugin for possible later reference. + $potentialPlugin = StringUtilities::pluginPlugin($potentialPrimaryLinkTable); + } + + if($this->request->is('get')) { + // If this action allows unkeyed, asserted primary link IDs, check the query + // string (eg: 'add' or 'index' allow matchgrid_id to be passed in) + if($this->$modelsName->allowUnkeyedPrimaryLink($this->request->getParam('action')) + && $this->request->getQuery()) { + $this->cur_pl->value = $this->request->getQuery($potentialPrimaryLink); + } elseif($this->$modelsName->allowLookupPrimaryLink($this->request->getParam('action'))) { + // Try to map the requested object ID + $param = (int)$this->request->getParam('pass.0'); + + if(!empty($param)) { + $this->cur_pl = $this->$modelsName->findPrimaryLink($param); + // Break the loop here since we also have the link attribute, + // which might not be $potentialPrimaryLink + $this->set('vv_primary_link', $this->cur_pl->attr); + break; + } + } + } elseif($this->request->is('post') && $this->request->getParam('action') != 'delete') { + // Post = add, where we can have a list of objects and nothing in /objects/{id} + // We don't support different primary links across objects, so we throw an error + // if different parent keys are provided. + + $linkValue = null; + + // Data in API format + $reqData = $this->request->getData($modelsName); + + if(!$reqData + // Don't create $reqData if the POST data is also empty + && !empty($this->request->getData())) { + // Data in POST format + $reqData[] = $this->request->getData(); + } + + if(!empty($reqData)) { + foreach($reqData as $rec) { + if(!empty($rec[$potentialPrimaryLink])) { + if(!$linkValue) { + // This is the first record we've seen, use this primary link value + $linkValue = $rec[$potentialPrimaryLink]; + } elseif($linkValue != $rec[$potentialPrimaryLink]) { + // We don't support multiple records with different parents + throw new \InvalidArgumentException(__d('error', 'primary_link.mismatch')); + } + } + + $this->cur_pl->value = $linkValue; + } + } + + // If we didn't find the primary link in the submitted form or API + // request, it might be available via the URL. + + if(!$linkValue + && $this->$modelsName->allowLookupPrimaryLink($this->request->getParam('action'))) { + // Try to map the requested object ID (this is probably a delete, so no attribute in post body) + $param = (int)$this->request->getParam('pass.0'); + + if(!empty($param)) { + $this->cur_pl = $this->$modelsName->findPrimaryLink($param); + // Break the loop here since we also have the link attribute, + // which might not be $potentialPrimaryLink + $this->set('vv_primary_link', $this->cur_pl->attr); + break; + } + } + } elseif($this->request->is('put') || $this->request->getParam('action') == 'delete') { + // Put = edit, so we should look up the parent ID via the object itself + if($this->$modelsName->allowLookupPrimaryLink($this->request->getParam('action'))) { + // Try to map the requested object ID (this is probably a delete, so no attribute in post body) + $param = (int)$this->request->getParam('pass.0'); + + if(!empty($param)) { + $this->cur_pl = $this->$modelsName->findPrimaryLink($param); + // Break the loop here since we also have the link attribute, + // which might not be $potentialPrimaryLink + $this->set('vv_primary_link', $this->cur_pl->attr); + break; + } + } + } + + if(!empty($this->cur_pl->value)) { + // We found a populated primary link. Store the attribute and break the loop. + $this->cur_pl->attr = $potentialPrimaryLink; + if($potentialPlugin) { + $this->cur_pl->plugin = $potentialPlugin; + } + $this->set('vv_primary_link', $this->cur_pl->attr); + break; + } + } + + if(empty($this->cur_pl->value) && !$this->$modelsName->allowEmptyPrimaryLink()) { + throw new \RuntimeException(__d('error', 'primary_link')); + } + } + + if(!empty($this->cur_pl->value)) { + // Look up the link value to find the related entity + + $linkTableName = $this->$modelsName->getPrimaryLinkTableName($this->cur_pl->attr); + $linkTable = $this->getTableLocator()->get($linkTableName); + + $this->set('vv_primary_link_model', $linkTableName); + + try { + $plObj = $linkTable->findById($this->cur_pl->value)->firstOrFail(); + + $this->set('vv_primary_link_obj', $plObj); + + // While we're here, note the CO since we'll probably need it soon + if(!empty($plObj->co_id)) { + $this->cur_pl->co_id = $plObj->co_id; + } elseif(method_exists($linkTable, "findCoForRecord")) { + $this->cur_pl->co_id = $linkTable->findCoForRecord((int)$this->cur_pl->value); + } + } + catch(RecordNotFoundException $e) { + $this->llog('error', "Could not find value '" . $this->cur_pl->value . "' for primary link object " . $linkTableName); + // Mask this with a generic UnauthorizedException + throw new UnauthorizedException(__d('error', 'perm')); + } + } + } + + return $this->cur_pl; + } + + /** + * Get the redirect goal for this table. + * + * @since COmanage Registry v5.0.0 + * @param string $action Action + * @return string Redirect goal + */ + + protected function getRedirectGoal(string $action): ?string { + // $this->name = Models + $modelsName = $this->name; + + // PrimaryLinkTrait + if(method_exists($this->$modelsName, "getRedirectGoal")) { + return $this->$modelsName->getRedirectGoal($this->request->getParam('action')); + } + + return 'index'; + } + + /** + * Populate the list of Available COs, primarily for the CO Selector. + * + * @since COmanage Registry v5.0.0 + */ + + protected function populateAvailableCos() { + // Prepare the list of available COs, primarily for the CO Selector. We do + // this here because the menuTop element, which renders on every page, needs it. + + $availableCos = []; + + $username = $this->RegistryAuth->getAuthenticatedUser(); + + if(!empty($username)) { + // There are two data sets to look at: the COs the current user is a member + // of, and (if the current user is a Platform Admin) all other COs. We then + // bubble the COmanage CO to the top (if present), followed by an alphabetical + // list of member COs, then an alphabetical list of non-member COs. + + $Cos = TableRegistry::getTableLocator()->get("Cos"); + + // Pull the set of COs this user is a member of, for rendering via menuMain + $memberCos = Hash::sort($Cos->getCosForIdentifier(loginIdentifier: $username), '{n}.name', 'asc'); + $allCos = null; + + if($this->RegistryAuth->isPlatformAdmin()) { + // Pull all available (active COs) + $allCos = Hash::sort($Cos->find('all')->where(['Cos.status' => TemplateableStatusEnum::Active])->toArray(), '{n}.name', 'asc'); + } + + // See if the COmanage CO is in the $memberCos list. (If the user is a + // Platform Admin it will always be in the $memberCos list.) + + $COmanageCO = null; + + foreach($memberCos as $key => $co) { + if($co->isCOmanageCO()) { + $COmanageCO = $co; + unset($memberCos[$key]); + } else { + $availableCos[$key] = $co; + } + } + + if($COmanageCO) { + $availableCos = array_merge([$COmanageCO->id => $COmanageCO], $availableCos); + } + + if(!empty($allCos)) { + foreach($allCos as $co) { + if(!Hash::extract($availableCos, '{n}[id='.$co->id.']')) { + // Not already in the list as a member + $co->name = __d('field', 'Cos.member.not', [$co->name]); + + $availableCos[] = $co; + } + } + } + } + + $this->set('vv_available_cos', $availableCos); + } + + /** + * Determine the (requested) current CO and make it available to the + * rest of the application. + * + * @since COmanage Registry v5.0.0 + * @throws Cake\Datasource\Exception\RecordNotFoundException + * @throws \InvalidArgumentException + */ + + protected function setCO() { + if($this->cur_co) { + // Nothing to do... + return; + } + + // Try to find the requested CO + $coid = null; + + if(method_exists($this, 'calculateRequestedCOID')) { + // This controller implements special logic + + $coid = $this->calculateRequestedCOID(); + } + + if(!$coid) { + // $this->name = Models, unless we're in an API call + $modelsName = $this->name; + + $attrs = $this->request->getAttributes(); + + // Unlike Match, where the Matchgrid is embedded in the request API URL, + // Registry API calls are more similar to UI calls, where we may or may + // not be able to find the CO ID directly in the URL. + if($this->request->is('restful') + && !empty($attrs['params']['model'])) { + $modelsName = \Cake\Utility\Inflector::camelize($attrs['params']['model']); + $this->$modelsName = TableRegistry::getTableLocator()->get($modelsName); + } + + if(!method_exists($this->$modelsName, "requiresCO") + || !$this->$modelsName->requiresCO()) { + // Nothing to do, CO not required by this model/controller + return; + } + + // Not all models have CO as their primary link. This will also + // trigger setting of the viewVar for breadcrumbs and anything else. + $link = $this->getPrimaryLink(true); + + // getPrimaryLink has already done our work + if($link->attr == 'co_id') { + $coid = $link->value; + } else { + if(!empty($link->co_id)) { + $coid = $link->co_id; + } + } + } + + if(!$coid + && !$this->$modelsName->allowEmptyCO() + && !$this->request->is('restful')) { + // If we get this far without a CO ID, something went wrong. + throw new \RuntimeException(__d('error', 'coid')); + } + + if($coid) { + $this->Cos = $this->fetchTable('Cos'); + + // This throws Cake\Datasource\Exception\RecordNotFoundException which + // we just let pass up the stack. + $this->cur_co = $this->Cos->findById($coid)->firstOrFail(); + + // While the COmanage CO cannot be suspended (AR-CO-2), this is enforced + // at cos/edit, not here. + + if($this->cur_co->status === TemplateableStatusEnum::Active) { + $this->set('vv_cur_co', $this->cur_co); + } else { + throw new \InvalidArgumentException(__d('error', 'inactive', [__d('controller', 'Cos', [1]), $coid])); + } + + if(!empty($modelsName) && !empty($this->$modelsName)) { + // We store the CO ID in Configuration to facilitate its access from + // model contexts such as validation where passing the value via the + // Controller is not particularly feasible. Note that for API calls + // $modelsName may not be set, so (eg) StandardApiController does + // something similar. + + // This only works for the current model, not related models. If/when we + // need to support relatedmodels, we could have setCurCoId() cascade the + // CO to any of its related models that require it, or use the event + // listener approach commented out below. + if(method_exists($this->$modelsName, "acceptsCoId") + && $this->$modelsName->acceptsCoId()) { + $this->$modelsName->setCurCoId((int)$coid); + } + + /* This doesn't work for the current model since it has already been + initialized, but it could be an option for related models later... + (eg when we try to save a name via EIS or EF). But see also the new + approach below. + $CoIdEventListener = new CoIdEventListener($coid); + EventManager::instance()->on($CoIdEventListener);*/ + + // Walk through the first level associations and pass the CO ID to them, + // as well. We could ultimately cascade this via the table once we have + // a use case to do so, though note it's possible a child associations + // wants the CO ID even though the parent doesn't. + + foreach($this->$modelsName->associations()->getIterator() as $a) { + $aTable = $a->getTarget(); + + if(method_exists($aTable, "acceptsCoId") + && $aTable->acceptsCoId()) { + $aTable->setCurCoId((int)$coid); + } + } + } + } + } + + /** + * Determine the current timezone and make it available to the + * rest of the application. + * + * @since COmanage Registry v5.0.0 + */ + + protected function setTZ() { + // $this->name = Models + $modelsName = $this->name; + + // See if we've collected it from the browser in a previous page load. Otherwise, + // use the system default. If the user set a preferred timezone, we'll catch that below. + + $tz = date_default_timezone_get(); + + if(!empty($_COOKIE['cm_registry_tz_auto'])) { + // We have an auto-detected timezone from a previous page render from the browser. + // Note we don't call date_default_timezone_set() because we still want to record + // times internally in UTC (at the expense of having to convert back and forth). + $tz = $_COOKIE['cm_registry_tz_auto']; + } + +// XXX need to implement person-specific timezone detection (after CoPerson model and +// login authentication are implemented) + + $this->set('vv_tz', $tz); + + if($this->$modelsName->behaviors()->has('Timezone')) { + // Tell TimezoneBehavior what the current timezone is + $this->$modelsName->setTimeZone($tz); + } + } +} \ No newline at end of file diff --git a/app/src/Controller/AuthenticationEventsController.php b/app/src/Controller/AuthenticationEventsController.php new file mode 100644 index 000000000..2c835c02a --- /dev/null +++ b/app/src/Controller/AuthenticationEventsController.php @@ -0,0 +1,64 @@ + [ + 'AuthenticationEvents.id' => 'desc' + ] + ]; + + // Cached permissions + protected ?array $permCache = null; + + /** + * Callback run prior to the request action. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Cake Event + */ + + public function beforeFilter(\Cake\Event\EventInterface $event) { + // If an identifier was passed in, use that to filter the index query. + // (Authz is handled in the closure passed to setIndexFilter by AuthenticationEventsTable.) + $targetIdentifier = $this->getRequest()->getQuery('authenticated_identifier'); + + if($targetIdentifier) { + $this->AuthenticationEvents->setIndexFilter(['authenticated_identifier' => \App\Lib\Util\StringUtilities::urlbase64decode($targetIdentifier)]); + } + + return parent::beforeFilter($event); + } +} \ No newline at end of file diff --git a/app/src/Controller/CoSettingsController.php b/app/src/Controller/CoSettingsController.php new file mode 100644 index 000000000..0ca438200 --- /dev/null +++ b/app/src/Controller/CoSettingsController.php @@ -0,0 +1,64 @@ +Breadcrumb->skipParents(['/^\/co-settings\/edit/']); + } + + /** + * Manage CO Settings. + * + * @since COmanage Registry v5.0.0 + * @return \Cake\Http\Response + */ + + public function manage() { + // We basically use this as a switch into the correct settings entry and + // then redirect to the edit view + + $settings = $this->CoSettings->find('all', ['conditions' => ['CoSettings.co_id' => $this->getCOID()]])->first(); + + return $this->redirect(['action' => 'edit', $settings->id]); + } +} \ No newline at end of file diff --git a/app/src/Controller/Component/BreadcrumbComponent.php b/app/src/Controller/Component/BreadcrumbComponent.php new file mode 100644 index 000000000..3c4d994ae --- /dev/null +++ b/app/src/Controller/Component/BreadcrumbComponent.php @@ -0,0 +1,321 @@ + Alfa Community > Configuration > External Identity Sources > Test Filesource Plugin > Configure Test Filesource Plugin + * + * Root link(prepend): COmanage Registry // Root node (link) + * CO Level Link: Alfa Community // if Current CO is defined (link) + * configuration: Configuration // Configuration breadcrumb (link) + * path: Parent // Render the path from dashboard (link) + * Title Links: + * Page Title: Title // e.g. Configure Test Filesource Plugin (string) + */ + + // Configuration provided by the controller + // Don't render any breadcrumbs + protected $skipAllPaths = []; + // Don't render the configuration link + protected $skipConfigPaths = []; + // Don't render the parent links + protected $skipParentPaths = []; + // Inject parent links (these render before the index link, if set) + // The parent links are constructed as part of the injectPrimaryLink function. This in the StandardController as well + // as in the StandardPluginController, MVEAController, ProvisioningHistoryRecordController, etc. These controllers are + // a descendant from the StandardController we will calculate the Parents twice. In order to avoid duplicates the + // injectParents table has to be an associative array. The uniqueness of the key will preserve the uniqueness of the parent + // path while the order of firing will create the correct breadcumb path order + protected $injectParents = []; + // Inject title links (immediately before the title breadcrumb) + protected $injectTitleLinks = []; + + /** + * Callback run prior to rendering the view. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Cake Event + */ + + public function beforeRender(EventInterface $event) { + $controller = $event->getSubject(); + $request = $controller->getRequest(); + + if($request->is('restful') || $request->is('ajax')) { + return; + } + + $modelsName = $controller->getName(); + + // Determine the request target, but strip off query params + $requestTarget = $request->getRequestTarget(false); + + $skipAll = false; + $skipConfig = false; + + foreach($this->skipAllPaths as $p) { + if(preg_match($p, $requestTarget)) { + $skipAll = true; + break; + } + } + + foreach($this->skipConfigPaths as $p) { + if(preg_match($p, $requestTarget)) { + $skipConfig = true; + break; + } + } + + // Determine if the current request maps to a path where + // breadcrumb rendering should be skipped in whole or in part + $controller->set('vv_bc_skip', $skipAll); + + $controller->set('vv_bc_skip_config', $skipConfig); + + // Do we have a target model, and if so is it a configuration + // model (eg: ApiUsers) or an object model (eg: CoPeople)? + if(isset($controller->$modelsName) // May not be set under certain error conditions + && method_exists($controller->$modelsName, "isConfigurationTable")) { + $controller->set('vv_bc_configuration_link', $controller->$modelsName->isConfigurationTable()); + } else { + $controller->set('vv_bc_configuration_link', false); + } + + // Build a list of intermediate parent links, starting with any + // injected parents. This overrides $skipParentPaths. + $parents = $this->injectParents; + + $skipParent = false; + + foreach($this->skipParentPaths as $p) { + if(preg_match($p, $requestTarget)) { + $skipParent = true; + break; + } + } + + if(!$skipParent) { + // For non-index views, insert a link back to the index. + $action = $request->getParam('action'); + $primaryLink = $controller->getPrimaryLink(true); + + if($action != 'index') { + $target = [ + 'plugin' => $primaryLink->plugin ?? null, + 'controller' => $modelsName, + 'action' => 'index' + ]; + + if(!empty($primaryLink->attr)) { + $target['?'] = [$primaryLink->attr => $primaryLink->value]; + } + + $label = (!empty($primaryLink->plugin) + ? __d(Inflector::underscore($primaryLink->plugin), 'controller.'.$modelsName, [99]) + : __d('controller', $modelsName, [99])); + + $parents[] = [ + 'label' => $label, + 'target' => $target + ]; + } + } + + $controller->set('vv_bc_parents', $parents); + + $controller->set('vv_bc_title_links', $this->injectTitleLinks); + } + + /** + * Inject the primary link into the breadcrumb path. + * + * @param object $link Primary Link (as returned by getPrimaryLink()) + * @param bool $index Include link to parent index + * @param string|null $linkLabel Override the constructed label + * + *@since COmanage Registry v5.0.0 + */ + + public function injectPrimaryLink(object $link, bool $index=true, string $linkLabel=null): void + { + // eg: "People" + $modelsName = StringUtilities::foreignKeyToClassName($link->attr); + $modelPath = $modelsName; + + if(!empty($link->plugin)) { + // eg: "CoreEnroller.AttributeCollectors" + $modelPath = $link->plugin . "." . $modelsName; + } + + // Construct the getContains function name + $requestAction = $this->getController()->getRequest()->getParam('action'); + // In the case we are dealing with non-standard actions we need to fallback to a standard one + // in order to get access to the contain array. We will use the permissions to decide which + // action to fallback to + if(!in_array($requestAction, [ + 'index', 'view', 'delete', 'add', 'edit' + ])) { + $permissionsArray = $this->getController()->RegistryAuth->calculatePermissionsForView($requestAction); + $id = $this->getController()->getRequest()->getParam('pass')[0] ?? null; + if (isset($id)) { + $requestAction = ( isset($permissionsArray['edit']) && $permissionsArray['edit'] ) ? 'edit' : 'view'; + } else { + $requestAction = 'index'; + } + } + $containsList = "get" . ucfirst($requestAction) . "Contains"; + + $linkTable = TableRegistry::getTableLocator()->get($modelPath); + $contain = method_exists($linkTable, $containsList) ? $linkTable->$containsList() : []; + + $linkObj = $linkTable->get($link->value, ['contain' => $contain]); + + if($index) { + // We need to determine the primary link of the parent, which might or might + // not be co_id + + if(method_exists($linkTable, "findPrimaryLink")) { + // If findPrimaryLink doesn't exist, we're probably working with CosTable + + $parentLink = $linkTable->findPrimaryLink($linkObj->id); + + $this->injectParents[ $modelPath . $parentLink->value] = [ + 'target' => [ + 'plugin' => $parentLink->plugin ?? null, + 'controller' => $modelsName, + 'action' => 'index', + '?' => [ + $parentLink->attr => $parentLink->value + ] + ], + 'label' => StringUtilities::localizeController( + controllerName: $modelsName, + pluginName: $link->plugin ?? null, + plural: true + ) + ]; + } + } + + // Find the allowed action + $breadcrumbAction = method_exists($linkObj, 'isReadOnly') ? + ($linkObj->isReadOnly() ? 'view' : 'edit') : + 'edit'; + + // The action in the following injectParents dictates the action here + [$title,,] = StringUtilities::entityAndActionToTitle($linkObj, $modelPath, $breadcrumbAction); + + $this->injectParents[ $linkTable->getTable() . $linkObj->id ] = [ + 'target' => [ + 'plugin' => $link->plugin ?? null, + 'controller' => $modelsName, + 'action' => $breadcrumbAction, + $linkObj->id + ], + 'label' => $linkLabel ?? $title + ]; + } + + /** + * Inject a title link based on the display field of an entity into the breadcrumb set. + * + * @since COmanage Registry v5.0.0 + * @param Table $table Table for $entity + * @param Entity $entity Entity to generate title link for + * @param string $action Action to link to + * @param string $label If set, use this label instead of the entity's displayField + */ + + public function injectTitleLink( + $table, + $entity, + string $action='edit', + ?string $label=null + ) { + $displayField = $table->getDisplayField(); + + $this->injectTitleLinks[] = [ + 'target' => [ + 'plugin' => null, + 'controller' => $table->getTable(), + 'action' => $action, + $entity->id + ], + 'label' => $label ?: $entity->$displayField + ]; + } + + /** + * Set the set of paths that should be skipped when rendering breadcrumbs. + * Paths are specified as regular expressions, eg: '/^\/cos\/select/' + * + * @since COmanage Registry v5.0.0 + * @param array $skipPaths Array of regular expressions describing paths to be skipped + */ + + public function skipAll(array $skipPaths) { + $this->skipAllPaths = $skipPaths; + } + + /** + * Set the set of paths which should not get a "configuration" breadcrumb even + * though they might otherwise ordinarily get one (by being configuration objects). + * Paths are specified as regular expressions, eg: '/^\/provisioning-targets\/status/' + * + * @since COmanage Registry v5.0.0 + * @param array $skipPaths Array of regular expressions describing paths + */ + + public function skipConfig(array $skipPaths) { + $this->skipConfigPaths = $skipPaths; + } + + /** + * Set the set of paths that should not automatically get a link back to their parent. + * Paths are specified as regular expressions, eg: '/^\/co-settings\/edit/' + * + * @since COmanage Registry v5.0.0 + * @param array $skipPaths Array of regular expressions describing paths + */ + + public function skipParents(array $skipPaths) { + $this->skipParentPaths = $skipPaths; + } +} \ No newline at end of file diff --git a/app/src/Controller/Component/RegistryAuthComponent.php b/app/src/Controller/Component/RegistryAuthComponent.php new file mode 100644 index 000000000..5f7df4a13 --- /dev/null +++ b/app/src/Controller/Component/RegistryAuthComponent.php @@ -0,0 +1,794 @@ +llog('error', "Empty value(s) received for PHP_AUTH_USER and/or PHP_AUTH_PW"); + throw new \InvalidArgumentException(__d('error', 'auth.api.invalid')); + } + + $ApiUsers = TableRegistry::getTableLocator()->get('ApiUsers'); + + try { + // validateKey takes care of all validity logic, as well as rehashing (if needed) + if($ApiUsers->validateKey($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'], $_SERVER['REMOTE_ADDR'])) { + $this->authenticatedUser = $_SERVER['PHP_AUTH_USER']; + $this->authenticatedApiUser = true; + $this->llog('debug', "Authenticated API User \"" . $this->authenticatedUser . "\""); + + return true; + } + } + catch(\Exception $e) { + $this->llog('debug', "User authentication failed: " . $e->getMessage()); + throw new \InvalidArgumentException($e->getMessage()); + } + + return false; + } + + /** + * Callback run prior to the request action. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Cake Event + */ + + public function beforeFilter(EventInterface $event) { + $controller = $event->getSubject(); + $request = $controller->getRequest(); + $session = $request->getSession(); + + $id = null; + $passed = $request->getParam('pass'); + + if(!empty($passed[0])) { + $id = (int)$passed[0]; + } + + // Perform authorization check + + // Controllers can handle their own authn and/or authz, as indicated + // by implementing the willHandleAuth() function. This applies to both + // regular and API requests. + + $controllerAuthz = false; + + if(method_exists($controller, 'willHandleAuth')) { + // The Controller might handle its own authn/z + + $mode = $controller->willHandleAuth($event); + + switch($mode) { + case 'authz': + // The controller will handle authorization, but we still need + // to make sure we have an authenticated user + $controllerAuthz = true; + break; + case 'open': + // The current request is open/public, no auth required + return true; + break; + case 'no': + // The controller will not do either authn or authz, so apply + // standard behavior + break; + case 'yes': + // The controller will handle both authn and authz, simply return + return true; + break; + default: + throw new \InvalidArgumentException("Unknown willHandleAuth return value $mode"); + break; + } + } + + // Do we have an authenticated user session? + + // Note we don't stuff anything into the session anymore, the only attribute + // is the username, which is actually loaded by login.php. + + $auth = $session->read('Auth'); + + // Registry UI is now a hybrid implementation of VUE and CAKEPHP MVC. + // In order to allow a logged-in user to reach out to the backend without + // the need of an API User, but just with the use of the Session, we will + // skip the API user authorization if a user Session is available. + if(empty($auth) && $this->getConfig('apiUser')) { + // There are no unauthenticated API calls, so always require a valid user + + try { + if($this->authenticateApiUser()) { + $authok = false; + + if($controllerAuthz) { + // Don't merge these if statements together! We want to hand off + // to the controller to determine if authz was met, and if not redirect + // appropriately. We _don't_ want to call our own calculatePermission(). + if($controller->calculatePermission()) { + // Controller asserts authorization successful + $authok = true; + } + } elseif($this->calculatePermission(action: $request->getParam('action'), id: $id)) { + // Authorization successful + $authok = true; + } + + if($authok) { + $AuthenticationEvents = TableRegistry::getTableLocator()->get('AuthenticationEvents'); + + $AuthenticationEvents->record(identifier: $this->authenticatedUser, + eventType: AuthenticationEventEnum::ApiLogin, + remoteIp: $_SERVER['REMOTE_ADDR']); + + return true; + } + } + + // Permission denied + throw new ForbiddenException(__d('error', 'perm')); + } + catch(RecordNotFoundException $e) { + // Requested record does not exist. For platform API users, we can return + // a RecordNotFoundException, otherwise we recast to generate permission denied. + $this->llog('debug', "User authorization failed: " . $e->getMessage()); + + $ApiUsers = TableRegistry::getTableLocator()->get('ApiUsers'); + + if($ApiUsers->getUserPrivilege($this->authenticatedUser) === true) { + throw $e; + } else { + throw new UnauthorizedException(__d('error', 'auth.api.failed')); + } + } + catch(\Exception $e) { + $this->llog('debug', $e->getMessage()); + // Obfuscate the error message, which is available in the logs + throw new UnauthorizedException(__d('error', 'auth.api.failed')); + } + } else { + // Certain requests do not require authentication + + if(!empty($auth['external']['user'])) { + // We have a valid username that is *authenticated* for the current request. + // Note we haven't checked authorization, but this is how the authorization + // checks can get the authenticated username. + $controller->set('vv_user', ['username' => $auth['external']['user']]); + $this->authenticatedUser = $auth['external']['user']; + + if($controllerAuthz) { + // Don't merge these if statements together! We want to hand off + // to the controller to determine if authz was met, and if not redirect + // appropriately. We _don't_ want to call our own calculatePermission(). + if($controller->calculatePermission()) { + return true; + } + } elseif($this->calculatePermission($request->getParam('action'), $id)) { + // Authorization successful + return true; + } + + if(Configure::read('debug')) { + // For testing purposes throw an error, but in production we want to + // redirect to /login + $controller->Flash->error("Authorization Failed (RegistryAuthComponent)"); + return $controller->redirect("/"); + } + } + + // No authentication, redirect to login + + // We want to come back to where we started + $session->write('Auth.target', $request->getRequestTarget()); + + return $controller->redirect("/auth/login/login.php"); + } + } + + /** + * Calculate permissions for this action. + * + * @since COmanage Registry v5.0.0 + * @param string $action Action requested + * @param int $id Subject id, if applicable + * @return bool true if the action is permitted, false otherwise + * @throws UnauthorizedException + */ + + protected function calculatePermission(string $action, ?int $id=null): bool { + $perms = $this->calculatePermissions($id); + + if(!isset($perms[$action])) { + throw new UnauthorizedException('Invalid Request (RegistryAuthComponent)'); + } + + return $perms[$action]; + } + + /** + * Obtain the permission set for this request. + * + * @since COmanage Registry v5.0.0 + * @param int $id Subject ID, if applicable + * @return array Array of actions and authorized roles + */ + + protected function calculatePermissions(?int $id=null): array { + $controller = $this->getController(); + + $ret = []; + + // This will need to be prefixed to the model, if set + $pluginName = $controller->getPlugin(); + + // $this->name = Models (ie: from ModelsTable) + $modelsName = ($pluginName ? "$pluginName." : "") . $controller->getName(); + // $table = the actual table object + $table = $controller->getTableLocator()->get($modelsName); + + // Do we have an authenticated user? + $authenticatedUser = (bool)$this->getAuthenticatedUser(); + + // Is this user a Platform Administrator? + $platformAdmin = $this->isPlatformAdmin(); + + // Is this user a CO Administrator? + $coAdmin = $this->isCoAdmin($controller->getCOID()); + + // Is this user a CO Member? + $coMember = $this->isCoMember($controller->getCOID()); + + // Is this record read only? + $readOnly = false; + + // Can this record be deleted? + $canDelete = true; + + // Pull the table's permission definitions + $permissions = $this->getTablePermissions($table, $id); + + if($id) { + $readOnlyActions = ['view']; + + // Pull the record so we can interrogate it + + $obj = $table->get($id); + + if(method_exists($obj, "isReadOnly")) { + $readOnly = $obj->isReadOnly(); + + if(!empty($permissions['readOnly'])) { + // Merge in controller specific actions permitted on read only entities + $readOnlyActions = array_merge($readOnlyActions, $permissions['readOnly']); + } + } + + if(method_exists($obj, "canDelete")) { + $canDelete = $obj->canDelete(); + } + + // Permissions for actions that operate over individual entities + + foreach($permissions['entity'] as $action => $roles) { + $ok = false; + + if((($action != 'delete' || $canDelete) + && + !$readOnly) || in_array($action, $readOnlyActions)) { + if(is_array($roles)) { + // A list of roles authorized to perform this action, see if the + // current user has any + foreach($roles as $role) { + // eg: $role = "platformAdmin", which corresponds to the variables set, above + if($$role) { + $ok = true; + break; + } + } + } elseif($roles === true) { + // Any authenticated user is permitted + $ok = true; + } + } + + $ret[$action] = $ok; + } + + if(!empty($permissions['related'])) { + foreach($permissions['related'] as $rtable) { + $RelatedTable = TableRegistry::getTableLocator()->get($rtable); + $rpermissions = $this->getTablePermissions($RelatedTable, $id); + + foreach($rpermissions['table'] as $action => $roles) { + $ok = false; + + if(is_array($roles)) { + // A list of roles authorized to perform this action, see if the + // current user has any + foreach($roles as $role) { + // eg: $role = "platformAdmin", which corresponds to the variables set, above + if($$role) { + $ok = true; + break; + } + } + } elseif($roles === true) { + // Any authenticated user is permitted + $ok = true; + } + + $ret[$rtable][$action] = $ok; + } + } + } + } else { + // Permissions for actions that operate over tables + + foreach($permissions['table'] as $action => $roles) { + $ok = false; + + if(is_array($roles)) { + // A list of roles authorized to perform this action, see if the + // current user has any + foreach($roles as $role) { + // eg: $role = "platformAdmin", which corresponds to the variables set, above + if($$role) { + $ok = true; + break; + } + } + } elseif($roles === true) { + // Any authenticated user is permitted + $ok = true; + } + + $ret[$action] = $ok; + } + } + + return $ret; + } + + /** + * Calculate permissions for a Result Set. + * + * @since COmanage Registry v5.0.0 + * @param ResultSet $rs Result Set + * @return array Array of permissions keyed on record ID + */ + + public function calculatePermissionsForResultSet(ResultSet $rs): array { + // We return an array since this is intended to be passed to a view + $ret = []; + + // Note these are Cake ORM functions (rewind, current, etc), and not array + // functions that PHP deprecated in 8.1.0. + $rs->rewind(); + + while($rs->valid()) { + $o = $rs->current(); + + $ret[ $o->id ] = $this->calculatePermissions($o->id); + + $rs->next(); + } + + return $ret; + } + + /** + * Calculate permissions for use in a view. + * + * @since COmanage Registry v5.0.0 + * @param string $action Action requested + * @param int $id Subject id, if applicable + * @return array Array of permissions, suitable for the view + */ + + public function calculatePermissionsForView(string $action, ?int $id=null): array { + return $this->calculatePermissions($id); + } + + /** + * Obtain the application role of the user for general use in the views + * + * @since COmanage Registry v5.0.0 + * @param int $coId Current CO ID, if known + * @return array $appRoles Array of roles + */ + + public function getApplicationUserRoles(?int $coId): array { + $appUserRoles = []; + + // True for platform administrator + $appUserRoles['platform'] = $this->isPlatformAdmin(); + + // True for administrator of the current CO + $appUserRoles['co'] = $this->isCoAdmin($coId); + + // TODO: add other application roles such as 'cou' and 'support' + // See: https://spaces.at.internet2.edu/display/COmanage/Registry+PE+Permissions + + // True if user is authenticated + $appUserRoles['authuser'] = $this->isAuthenticatedUser(); + + return $appUserRoles; + } + + /** + * Obtain the identifier of the currently authenticated user. + * + * @since COmanage Registry v5.0.0 + * @return string The authenticated user identifier or false if no authenticated user + */ + + public function getAuthenticatedUser(): ?string { + return $this->authenticatedUser; + } + + /** + * Obtain permissions suitable for menu rendering, specifically by + * templates/element/menuMain.php. + * + * @since COmanage Registry v5.0.0 + * @param int $coId Current CO ID, if known + * @return array Array of permissions + */ + + public function getMenuPermissions(?int $coId): array { + $permissions = []; + + $permissions['platform'] = $this->isPlatformAdmin(); + + // Can access the Configuration Dashboard for the current CO + $permissions['configuration'] = $this->isPlatformAdmin() + || $this->isCoAdmin($coId); + + // Can manage Groups in the current CO + $permissions['groups'] = $this->isPlatformAdmin() + || $this->isCoAdmin($coId); + + // Can manage People in the current CO + $permissions['people'] = $this->isPlatformAdmin() + || $this->isCoAdmin($coId); + + return $permissions; + } + + /** + * Obtain the set of permissions as provided by the table. + * + * @since COmanage Registry v5.0.0 + * @param table $table Cake Table + * @param int $id Entity ID, if applicable + * @return array Table permissions + */ + + protected function getTablePermissions($table, ?int $id): array { + $p = $table->getPermissions(); + + if(is_callable($p)) { + $controller = $this->getController(); + $request = $controller->getRequest(); + + return $p($request, $this, $id); + } else { + return $p; + } + } + + /** + * Determine if the current user is an administrator (CMP/CO/COU) for the + * provided identifier. Note that the identifier is not bound to any + * particular CO, this function will return true if the user is an + * administrator in any CO for which the subject identifier has an associated + * Person record. + * + * @since COmanage Registry v5.0.0 + * @param string $identifier Identifiers + * @return bool true if the current user is an administrator over $identifier, false otherwise + */ + + public function isAdminForIdentifier(string $identifier): bool { + if(!isset($this->cache['isAdminForIdentifier'][$identifier])) { + $this->cache['isAdminForIdentifier'][$identifier] = false; + + if($this->isPlatformAdmin()) { + // Platform Admins are admins for every identifier + $this->cache['isAdminForIdentifier'][$identifier] = true; + } else { + // Map $identifier to a set of People. Note we may be crossing COs when + // we do this. Note for now we only examine login identifiers since this + // is largely in support of the AuthenticationEvents index view, but + // there may be different use cases in the future. + + $Identifiers = TableRegistry::getTableLocator()->get('Identifiers'); + + $identifiers = $Identifiers->find('all') + ->where([ + 'Identifiers.identifier' => $identifier, + 'Identifiers.status' => SuspendableStatusEnum::Active, + 'Identifiers.login' => true, + 'Identifiers.person_id IS NOT NULL' + ]) + ->contain(['People' => 'Cos']) + ->all(); + + foreach($identifiers as $i) { + if(!empty($i->person->co_id) + && $this->isCoAdmin($i->person->co_id)) { + // If the current user is an admin for this Person we're done + $this->cache['isAdminForIdentifier'][$identifier] = true; + break; + } + } + } + } + + return $this->cache['isAdminForIdentifier'][$identifier]; + } + + /** + * Determine if the current user is an API user. + * + * @since COmanage Registry v5.0.0 + * @return bool True if the current user is an API user + */ + + public function isApiUser(): bool { + return $this->authenticatedApiUser; + } + + /** + * Determine if the current user is authenticated. + * + * @since COmanage Registry v5.0.0 + * @return bool True if the current user is authenticated + */ + + public function isAuthenticatedUser(): bool { + return !empty($this->authenticatedUser); + } + + /** + * Determine if the current user is a CO Administrator. + * + * @since COmanage Registry v5.0.0 + * @param int $coId CO ID + * @return bool True if the current user is a CO Administrator + */ + + public function isCoAdmin(?int $coId): bool { + // We might get called in some contexts without a coId, in which case there + // are no CO Admins. + + if(!$coId) { + return false; + } + + if(!isset($this->cache['isCoAdmin'][$coId])) { + $this->cache['isCoAdmin'][$coId] = false; + + if($this->authenticatedApiUser) { + $ApiUsers = TableRegistry::getTableLocator()->get('ApiUsers'); + + $priv = $ApiUsers->getUserPrivilege($this->authenticatedUser); + + $this->cache['isCoAdmin'][$coId] = ($priv === true || $priv === $coId); + } else { + if(!empty($this->authenticatedUser)) { + $this->cache['isCoAdmin'][$coId] = $this->isIdentifierAdmin(identifier: $this->authenticatedUser, coId: $coId); + } + } + } + + return $this->cache['isCoAdmin'][$coId]; + } + + /** + * Determine if the current user is a member of the specified CO. + * + * @since COmanage Registry v5.0.0 + * @param int $coId CO ID + * @return bool True if the current user is a CO Administrator + */ + + public function isCoMember(?int $coId): bool { + // We might get called in some contexts without a coId, in which case there + // are no members. + + if(!$coId) { + return false; + } + + if(!isset($this->cache['isCoMember'][$coId])) { + $this->cache['isCoMember'][$coId] = false; + + if($this->authenticatedApiUser) { + $ApiUsers = TableRegistry::getTableLocator()->get('ApiUsers'); + + $apiUser = $ApiUsers->find() + ->where([ + 'ApiUsers.username' => $this->authenticatedUser, + 'ApiUsers.co_id' => $coId, + 'ApiUsers.status' => SuspendableStatusEnum::Active + ]) + ->first(); + + if($apiUser) { + $now = Chronos::now(); + + if((!$apiUser->valid_from || $now->gt($apiUser->valid_from)) + && (!$apiUser->valid_through || $now->gt($apiUser->valid_through))) { + $this->cache['isCoMember'][$coId] = true; + } + } + } else { + if(!empty($this->authenticatedUser)) { + $Cos = TableRegistry::getTableLocator()->get('Cos'); + + $memberCos = $Cos->getCosForIdentifier($this->authenticatedUser); + + $this->cache['isCoMember'][$coId] = isset($memberCos[$coId]); + } + } + } + + return $this->cache['isCoMember'][$coId]; + } + + /** + * Determine if an identifier represents an administrator in the specified CO. + * + * @since COmanage Registry v5.0.0 + * @param string $identifier Identifier + * @param int $coId CO ID + * @return bool true if the identifier represent an administrator, false otherwise + */ + + protected function isIdentifierAdmin(string $identifier, int $coId): bool { + $Cos = TableRegistry::getTableLocator()->get('Cos'); + + // First see if this Identifier is a login Identifier in the requested CO + // This is similar to CosTable::getCosForIdentifier + $identifiers = $Cos->People + ->Identifiers + ->find('all') + ->where([ + 'Identifiers.identifier' => $identifier, + 'Identifiers.status' => SuspendableStatusEnum::Active, + 'Identifiers.login' => true, + 'Identifiers.person_id IS NOT NULL' + ]) + ->contain(['People' => 'Cos']) + ->all(); + + foreach($identifiers as $i) { + // Both the Person and the CO must be active + if($i->person && $i->person->isActive() + && $i->person->co->status == TemplateableStatusEnum::Active + && $i->person->co->id == $coId) { + // We found a Person in this CO, now see if it's an admin + // (for which we'll need the admin group) + + $adminGroup = $Cos->Groups->find('adminGroup', ['co_id' => $i->person->co_id])->firstOrFail(); + + return $Cos->Groups->GroupMembers->isMember(groupId: $adminGroup->id, personId: $i->person->id); + } + } + + return false; + } + + /** + * Determine if the current user is a Platform Administrator. + * + * @since COmanage Registry v5.0.0 + * @return bool True if the current user is a Platform Administrator + */ + + public function isPlatformAdmin(): bool { + if(!isset($this->cache['isPlatformAdmin'])) { + $this->cache['isPlatformAdmin'] = false; + + if($this->authenticatedApiUser) { + $ApiUsers = TableRegistry::getTableLocator()->get('ApiUsers'); + + $this->cache['isPlatformAdmin'] = ($ApiUsers->getUserPrivilege($this->authenticatedUser) === true); + } else { + if(!empty($this->authenticatedUser)) { + $Cos = TableRegistry::getTableLocator()->get('Cos'); + + // Find the COmanage CO + $COmanageCO = $Cos->find('COmanageCO')->firstOrFail(); + + $this->cache['isPlatformAdmin'] = $this->isIdentifierAdmin(identifier: $this->authenticatedUser, coId: $COmanageCO->id); + } + } + } + + return $this->cache['isPlatformAdmin']; + } +} \ No newline at end of file diff --git a/app/src/Controller/CosController.php b/app/src/Controller/CosController.php new file mode 100644 index 000000000..5156077a1 --- /dev/null +++ b/app/src/Controller/CosController.php @@ -0,0 +1,109 @@ + [ + 'Cos.name' => 'asc' + ] + ]; + + /** + * Perform Controller initialization. + * + * @since COmanage Registry v5.0.0 + */ + + public function initialize(): void { + parent::initialize(); + + // Configure breadcrumb rendering + $this->Breadcrumb->skipAll(['/^\/cos\/select/']); + } + + /** + * Callback run prior to the view rendering. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Cake Event + */ + + public function beforeRender(\Cake\Event\EventInterface $event) { + // In order to get the sidebar to render we need to set the current CO, + // which for cos is the COmanage CO. + $this->set('vv_cur_co', $this->Cos->find('COmanageCO')->firstOrFail()); + + return parent::beforeRender($event); + } + + /* + * XXX implement, also REST API + * + * @since COmanage Registry v5.0.0 + * @param Integer $id CO ID + */ + + public function duplicate(int $id) { + + } + + /** + * Provide a set of COs to operate on. + * + * @since COmanage Registry v5.0.0 + */ + + public function select() { + // Population of vv_available_cos is currently done in AppController + // since it's also used to determine if the "change collaboration" menu + // should render. + + // If only one CO is found, auto-redirect into it. + + $availableCos = $this->viewBuilder()->getVar('vv_available_cos'); + + if($availableCos && count($availableCos) === 1) { + return $this->redirect([ + 'controller' => 'dashboards', + 'action' => 'dashboard', + '?' => [ + 'co_id' => $availableCos[0]->id + ] + ]); + } + } +} \ No newline at end of file diff --git a/app/src/Controller/CousController.php b/app/src/Controller/CousController.php new file mode 100644 index 000000000..7520c505c --- /dev/null +++ b/app/src/Controller/CousController.php @@ -0,0 +1,73 @@ + [ + 'Cous.name' => 'asc' + ] + ]; + + /** + * Callback run prior to the request render. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Cake Event + */ + + public function beforeRender(\Cake\Event\EventInterface $event) { + if(!$this->request->is('restful')) { + // Pull the set of potential Parent COUs + + switch($this->request->getParam('action')) { + case 'add': + $this->set('parents', $this->Cous->potentialParents($this->getCOID(), null, true)); + break; + case 'edit': + $p = $this->request->getParam('pass'); + $couId = (int)$p[0]; + $this->set('parents', $this->Cous->potentialParents($this->getCOID(), $couId, true)); + break; + case 'index': + $this->set('parents', $this->Cous->potentialParents($this->getCOID())); + break; + default: + break; + } + } + + return parent::beforeRender($event); + } +} \ No newline at end of file diff --git a/app/src/Controller/DashboardsController.php b/app/src/Controller/DashboardsController.php new file mode 100644 index 000000000..0de92af39 --- /dev/null +++ b/app/src/Controller/DashboardsController.php @@ -0,0 +1,408 @@ +Breadcrumb->skipConfig([ + '/^\/dashboards\/artifacts/', + '/^\/dashboards\/dashboard/', + '/^\/dashboards\/registries/', + '/^\/dashboards\/search/' + ]); + // There is currently no inventory of dashboards, so we skip parents + // for configuration, dashboard, and registries actions + $this->Breadcrumb->skipParents(['/^\/dashboards/']); + } + + /** + * Render the CO Configuration Dashboard. + * + * @since COmanage Registry v5.0.0 + */ + + public function configuration() { + $cur_co = $this->getCO(); + + [$title, , ] = StringUtilities::entityAndActionToTitle(null, + 'co.features', + 'all', + 'menu'); + $this->set('vv_title', $title); + + // Construct the set of configuration items. For everything except CO Settings + // we want to order by the localized text string. + + // We're assuming that the permission for each of these items is the same as for + // configuration() itself, ie: CMP or CO Admin. But plausibly some of this stuff + // could be delegated to (eg) a COU Admin at some point... + + $configMenuItems = [ + __d('controller', 'ApiUsers', [99]) => [ + 'icon' => 'vpn_key', + 'controller' => 'api_users', + 'action' => 'index' + ], + __d('controller', 'Cous', [99]) => [ + 'icon' => 'people_outline', + 'controller' => 'cous', + 'action' => 'index' + ], + __d('controller', 'ExternalIdentitySources', [99]) => [ + 'icon' => 'cloud_download', + 'controller' => 'external_identity_sources', + 'action' => 'index' + ], + __d('controller', 'IdentifierAssignments', [99]) => [ + 'icon' => 'badge', + 'controller' => 'identifier_assignments', + 'action' => 'index' + ], + __d('controller', 'Pipelines', [99]) => [ + 'icon' => 'cable', + 'controller' => 'pipelines', + 'action' => 'index' + ], + __d('controller', 'ProvisioningTargets', [99]) => [ + 'icon' => 'cloud_upload', + 'controller' => 'provisioning_targets', + 'action' => 'index' + ], +// XXX restore when Reports are ready to be exposed. +// __d('controller', 'Reports', [99]) => [ +// 'icon' => 'summarize', +// 'controller' => 'reports', +// 'action' => 'index' +// ], + __d('controller', 'Types', [99]) => [ + 'icon' => 'widgets', + 'controller' => 'types', + 'action' => 'index' + ] + ]; + + ksort($configMenuItems); + + // Insert CO Settings to the front of the list + + $configMenuItems = array_merge([ + __d('controller', 'CoSettings', [99]) => [ + 'icon' => 'settings', + 'controller' => 'co_settings', + 'action' => 'manage' + ]], + $configMenuItems + ); + + $this->set('vv_configuration_menu_items', $configMenuItems); + + $platformMenuItems = []; + + if($this->getCOID() == 1) { + // Also pass the platform menu items + + $platformMenuItems = [ + __d('controller', 'Cos', [99]) => [ + 'icon' => 'home', + 'controller' => 'cos', + 'action' => 'index' + ], + __d('controller', 'Plugins', [99]) => [ + 'icon' => 'electrical_services', + 'controller' => 'plugins', + 'action' => 'index' + ] + ]; + } + + ksort($platformMenuItems); + + $this->set('vv_platform_menu_items', $platformMenuItems); + $registryMenuItems = [ + __d('controller', 'Groups', [99]) => [ + 'icon' => 'people', + 'controller' => 'groups', + 'action' => 'index' + ], + __d('controller', 'People', [99]) => [ + 'icon' => 'person', + 'controller' => 'people', + 'action' => 'index' + ], + __d('controller', 'Servers', [99]) => [ + 'icon' => 'computer', + 'controller' => 'servers', + 'action' => 'index' + ] + ]; + + ksort($registryMenuItems); + + $this->set('vv_registries_menu_items', $registryMenuItems); + + $artifactMenuItems = [ + __d('controller', 'ExtIdentitySourceRecords', [99]) => [ + 'icon' => 'assignment', + 'controller' => 'ext_identity_source_records', + 'action' => 'index' + ], + __d('controller', 'Jobs', [99]) => [ + 'icon' => 'assignment', + 'controller' => 'jobs', + 'action' => 'index' + ] + ]; + + ksort($artifactMenuItems); + + $this->set('vv_artifacts_menu_items', $artifactMenuItems); + } + + /** + * Render a Dashboard. + * + * @since COmanage Registry v5.0.0 + * @param int $id Dashboard ID + */ + public function dashboard(?int $id=null) { + // XXX placeholder + } + + /** + * Perform a cross model search. + * + * @since COmanage Registry v5.0.0 + */ + + public function search() { + /* To add a new backend to search: + * (1) Implement $model->search($id, $q, $limit) + * (2) Add the model to $models here, and define which roles can query it + * (3) Update documentation at https://spaces.at.internet2.edu/pages/viewpage.action?pageId=243078053 + */ + + $models = [ + 'Addresses' => [ + 'parent' => ['People' => 'person_id', 'PersonRoles' => 'person_role_id'], + 'roles' => ['platformAdmin', 'coAdmin'], + 'displayField' => 'street', + 'searchLimited' => false + ], + 'EmailAddresses' => [ + 'parent' => ['People' => 'person_id'], + 'roles' => ['platformAdmin', 'coAdmin'], + 'displayField' => 'mail', + 'searchLimited' => true + ], + 'Groups' => [ + 'parent' => ['Cos' => 'co_id'], + 'roles' => ['platformAdmin', 'coAdmin'], + 'displayField' => 'name', + 'searchLimited' => false + ], + 'Identifiers' => [ + 'parent' => ['Groups' => 'group_id', 'People' => 'person_id'], + 'roles' => ['platformAdmin', 'coAdmin'], + 'displayField' => 'identifier', + 'searchLimited' => true + ], + 'Names' => [ + 'parent' => ['People' => 'person_id'], + 'roles' => ['platformAdmin', 'coAdmin'], + 'displayField' => 'full_name', + 'searchLimited' => true + ], + 'PersonRoles' => [ + 'parent' => ['People' => 'person_id'], + 'roles' => ['platformAdmin', 'coAdmin'], + 'displayField' => 'title', + 'searchLimited' => false + ], + 'TelephoneNumbers' => [ + 'parent' => ['People' => 'person_id', 'PersonRoles' => 'person_role_id'], + 'roles' => ['platformAdmin', 'coAdmin'], + 'displayField' => 'number', + 'searchLimited' => false + ], + 'Urls' => [ + 'parent' => ['People' => 'person_id'], + 'roles' => ['platformAdmin', 'coAdmin'], + 'displayField' => 'url', + 'searchLimited' => false + ] + ]; + + $this->set('vv_supported_models', $models); + + // XXX inject plugins here + + // $results tracks the per-model backend results + $results = [ + 'Cos' => [], + 'Groups' => [], + 'People' => [] + ]; + + // XXX Still need to implement this (see also CFM-126) + $roles = []; + + // Gather our search string. + $q = ''; + if(!empty($this->request->getData('q'))) { + // A search was passed in from the form on the Global Search bar. + $q = trim($this->request->getData('q')); + } + + // Only process the request if we have a string of non-space characters + if(!empty($q)) { + + // Pull our search configuration + $CoSettings = TableRegistry::getTableLocator()->get('CoSettings'); + + $settings = $CoSettings->find()->where(['co_id' => $this->getCOID()])->firstOrFail(); + + $searchLimit = $settings->search_global_limit; + + foreach(array_keys($models) as $m) { + // If we're in limited search mode, we don't search all models + if($settings->search_global_limited_models + && !$models[$m]['searchLimited']) { + continue; + } + + $authorized = true; // XXX dynamically calculate this + + $table = $this->getTableLocator()->get($m); + + $searchResults = $table->search(coId: $this->getCOID(), + q: $q, + limit: $searchLimit); + + // For models with a parent other than Co, we aggregate the results to the parent + // model, but track what the matching model was. We key on the foreign key to the parent + // to also unique-ify the results while we're here. + + foreach($searchResults as $r) { + // Some tables support multiple parent models (eg: Identifiers), so we walk through + // the possibilities to see which one matched + foreach($models[$m]['parent'] as $pmodel => $pkey) { + if(!empty($r->$pkey)) { + if($m == 'Groups') { + // We special case Groups since (unlike People) they can match on both the + // primary model (Groups::name) or associated models (Identifiers::identifier). + // We force any Groups matches into the parent key format. + $results['Groups'][$r->id]['Groups'] = $r; + } elseif($pmodel == 'Cos') { + // This will look something like $results['Cos']['Departments'][] = $entity + $results[$pmodel][$m][] = $r; + } elseif($pmodel == 'PersonRoles') { + // Although we matched on a PersonRole we're really interested in the Person + $results['People'][$r->person_role->person_id][$m] = $r->person_role; + } else { + // Note we're also keying on the matched model, so this will look something like + // $results['People'][123]['Names'] = $entity + $results[$pmodel][$r->$pkey][$m] = $r; + } + } + } + } + } + + if(count($results['Cos']) + count($results['Groups']) + count($results['People']) >= $searchLimit) { + $this->Flash->information(__d('result', 'search.limit')); + } + } + + // It's a single match if there is a single person or person role result, + // or if there is a single result overall, redirect to that result. + if((count($results['Cos']) == 0 + && (count($results['People']) + count($results['Groups'])) == 1) + || + (count($results['Cos']) == 1 + && (count($results['People']) + count($results['Groups'])) == 0)) { + // Figure out which model matched, as well as the target model to redirect to + $matchClass = null; + $targetClass = null; + $targetRecordId = null; + + foreach(['Cos', 'Groups', 'People'] as $m) { + if(!empty($results[$m])) { + $targetClass = $m; + $targetRecordId = array_key_first($results[$m]); + $matchClass = array_key_first($results[$m][$targetRecordId]); + } + } + + $this->Flash->information(__d('result', + 'search.exact', + [filter_var($this->request->getData('q'), FILTER_SANITIZE_SPECIAL_CHARS), + __d('controller', $matchClass, [1])])); + + // Redirect to the matchClass controller + return $this->redirect([ + 'controller' => Inflector::dasherize($targetClass), + 'action' => 'edit', + $targetRecordId + ]); + + // XXX handle plugins + } elseif(count($results['Cos']) + + count($results['People']) + + count($results['Groups']) == 0) { + $this->Flash->information(__d('result', 'search.none')); + } + + $this->set('vv_results', $results); + // XXX The action is search and the result is not a modelPath. In this use the pattern is reversed. We should + // probably reconsider the po naming for the result domain + [$title, , ] = StringUtilities::entityAndActionToTitle(null, + 'search', + 'results', + 'result'); + $this->set('vv_title', $title); + } +} \ No newline at end of file diff --git a/app/src/Controller/EmailAddressesController.php b/app/src/Controller/EmailAddressesController.php new file mode 100644 index 000000000..c6b63d422 --- /dev/null +++ b/app/src/Controller/EmailAddressesController.php @@ -0,0 +1,42 @@ + [ + 'EmailAddresses.mail' => 'asc' + ] + ]; +} \ No newline at end of file diff --git a/app/src/Controller/ErrorController.php b/app/src/Controller/ErrorController.php new file mode 100644 index 000000000..27dfde501 --- /dev/null +++ b/app/src/Controller/ErrorController.php @@ -0,0 +1,70 @@ +loadComponent('RequestHandler'); + } + + /** + * beforeFilter callback. + * + * @param \Cake\Event\EventInterface $event Event. + * @return \Cake\Http\Response|null|void + */ + public function beforeFilter(EventInterface $event) + { + } + + /** + * beforeRender callback. + * + * @param \Cake\Event\EventInterface $event Event. + * @return \Cake\Http\Response|null|void + */ + public function beforeRender(EventInterface $event) + { + parent::beforeRender($event); + + $this->viewBuilder()->setTemplatePath('Error'); + } + + /** + * afterFilter callback. + * + * @param \Cake\Event\EventInterface $event Event. + * @return \Cake\Http\Response|null|void + */ + public function afterFilter(EventInterface $event) + { + } +} diff --git a/app/src/Controller/ExtIdentitySourceRecordsController.php b/app/src/Controller/ExtIdentitySourceRecordsController.php new file mode 100644 index 000000000..5f07920d1 --- /dev/null +++ b/app/src/Controller/ExtIdentitySourceRecordsController.php @@ -0,0 +1,42 @@ + [ + 'ExtIdentitySourceRecords.id' => 'asc' + ] + ]; +} \ No newline at end of file diff --git a/app/src/Controller/ExternalIdentitiesController.php b/app/src/Controller/ExternalIdentitiesController.php new file mode 100644 index 000000000..441f526e3 --- /dev/null +++ b/app/src/Controller/ExternalIdentitiesController.php @@ -0,0 +1,48 @@ + [ + 'Name.family' => 'asc' + ], + 'sortableFields' => [ + 'Names.given', + 'Names.family' + ] + ]; +} \ No newline at end of file diff --git a/app/src/Controller/ExternalIdentityRolesController.php b/app/src/Controller/ExternalIdentityRolesController.php new file mode 100644 index 000000000..28bb1e86d --- /dev/null +++ b/app/src/Controller/ExternalIdentityRolesController.php @@ -0,0 +1,45 @@ + [ + 'ExternalIdentityRoles.ordr' => 'asc', + 'ExternalIdentityRoles.title' => 'asc' + ] + ]; +} \ No newline at end of file diff --git a/app/src/Controller/ExternalIdentitySourcesController.php b/app/src/Controller/ExternalIdentitySourcesController.php new file mode 100644 index 000000000..5e8e9da38 --- /dev/null +++ b/app/src/Controller/ExternalIdentitySourcesController.php @@ -0,0 +1,222 @@ +get($m); + + $Table->setCurCoId($this->getCOID()); + } + } + + public $paginate = [ + 'order' => [ + 'ExternalIdentitySources.description' => 'asc' + ] + ]; + + /** + * Callback run prior to the request action. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Cake Event + * @return \Cake\Http\Response HTTP Response + */ + + public function beforeFilter(\Cake\Event\EventInterface $event) { + if(!$this->request->is('restful')) { + // Provide additional hints to BreadcrumbsComponent. This needs to be here + // and not in beforeRender because the component beforeRender will run first. + + if(in_array($this->request->getParam('action'), ['retrieve', 'search'])) { + $eis = $this->ExternalIdentitySources->get($this->request->getParam('pass')[0]); + + $this->Breadcrumb->injectTitleLink($this->ExternalIdentitySources, $eis); + + if($this->request->getParam('action') == 'retrieve') { + $this->Breadcrumb->injectTitleLink( + table: $this->ExternalIdentitySources, + entity: $eis, + action: 'search', + label: __d('operation', 'ExternalIdentitySources.search') + ); + + $this->set('vv_eis', $eis); + } + } + } + + return parent::beforeFilter($event); + } + + /** + * Calculate the redirect for this request. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Subject entity + * @return array Redirect + */ + + public function calculateRedirectTarget($entity): array { + // We should only be called for sync, after which we want to redirect to a URL like + // /registry/external-identity-sources/retrieve/3?source_key=foo + + return [ + 'controller' => 'ExternalIdentitySources', + 'action' => 'retrieve', + $this->request->getParam('pass.0'), + '?' => [ + 'source_key' => $this->request->getQuery('source_key') + ] + ]; + } + + /** + * Retrieve a record from an External Identity Source backend. + * + * @since COmanage Registry v5.0.0 + * @param string $id External Identity Source to search + */ + + public function retrieve(string $id) { + try { + $source_key = $this->request->getQuery('source_key'); + + $this->set('vv_eis_record', $this->ExternalIdentitySources->retrieve((int)$id, $source_key)); + + $externalIdentityRecordObj = $this->ExternalIdentitySources + ->ExtIdentitySourceRecords + ->find() + ->where(['ExtIdentitySourceRecords.source_key' => $source_key, + 'ExtIdentitySourceRecords.external_identity_source_id' => $id]) + ->contain(['ExternalIdentities']) + ->first(); + $this->set('vv_external_identity_record', $externalIdentityRecordObj); + + if($externalIdentityRecordObj === null) { + // I need an empty entity + $ExtIdentitySourceRecords = $this->getTableLocator()->get('ExtIdentitySourceRecords'); + // Create an empty entity for FormHelper + $externalIdentityRecordObj = $ExtIdentitySourceRecords->newEmptyEntity(); + } + + [$title, , ] = StringUtilities::entityAndActionToTitle($externalIdentityRecordObj, + StringUtilities::entityToClassName($externalIdentityRecordObj), + 'view'); + $this->set('vv_title', $title); + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + + return $this->generateRedirect(null); + } + } + + /** + * Perform a search against an External Identity Source backend. + * + * @since COmanage Registry v5.0.0 + * @param string $id External Identity Source to search + */ + + public function search(string $id) { + if($this->request->is('post')) { + try { + // The search attributes are backend specific, pass them as an array + $search = $this->request->getData('search'); + + $matches = $this->ExternalIdentitySources->search((int)$id, $search); + + $this->set('vv_search_results', $matches); + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + } + } + + // Obtain the searchable attributes and pass to the view + + $this->set('vv_search_attrs', $this->ExternalIdentitySources->searchableAttributes((int)$id)); + + [$title, , ] = StringUtilities::entityAndActionToTitle(null, + $this->getName(), + $this->request->getParam('action')); + $this->set('vv_title', $title); + } + + /** + * Perform an External Identity sync. + * + * @since COmanage Registry v5.0.0 + * @param string $id External Identity Source ID + */ + + public function sync(string $id) { + try { + $source_key = $this->request->getQuery('source_key'); + + $this->ExternalIdentitySources->sync((int)$id, $source_key); + // XXX Sync does not have a view. We do not need to set a title. Yet need to fetch the updated Identity + $this->set('vv_eis_record', $this->ExternalIdentitySources->retrieve((int)$id, $source_key)); + + $this->Flash->success(__d('result', 'ExternalIdentitySources.synced')); + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + } + + return $this->generateRedirect(null); + } +} \ No newline at end of file diff --git a/app/src/Controller/GroupMembersController.php b/app/src/Controller/GroupMembersController.php new file mode 100644 index 000000000..3d54f1087 --- /dev/null +++ b/app/src/Controller/GroupMembersController.php @@ -0,0 +1,61 @@ + [ + 'People.primary_name.name' => 'asc' + ] + ]; + + /** + * Callback run prior to the request render. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Cake Event + */ + + public function beforeRender(\Cake\Event\EventInterface $event) { + // Pull the Group name for breadcrumb rendering + + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->GroupMembers->Groups->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->GroupMembers->Groups->getDisplayField()); + } + + return parent::beforeRender($event); + } +} \ No newline at end of file diff --git a/app/src/Controller/GroupNestingsController.php b/app/src/Controller/GroupNestingsController.php new file mode 100644 index 000000000..a42f86c2f --- /dev/null +++ b/app/src/Controller/GroupNestingsController.php @@ -0,0 +1,67 @@ + [ + 'Group.name' => 'asc' + ] + ]; + + /** + * Callback run prior to the request render. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Cake Event + */ + + public function beforeRender(\Cake\Event\EventInterface $event) { + // Pull the Group name for breadcrumb rendering + + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->GroupNestings->Groups->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->GroupNestings->Groups->getDisplayField()); + } + + // We need to calculate the available set of groups for nesting. We do this + // here rather than via autoViewVars because we need to know the current + // group (to exclude it). + + $this->set('targetGroups', $this->GroupNestings->availableGroups((int)$link->value)); + + return parent::beforeRender($event); + } +} \ No newline at end of file diff --git a/app/src/Controller/GroupsController.php b/app/src/Controller/GroupsController.php new file mode 100644 index 000000000..cbd9bae10 --- /dev/null +++ b/app/src/Controller/GroupsController.php @@ -0,0 +1,63 @@ + [ + 'Groups.name' => 'asc' + ] + ]; + + /** + * Reconcile a Group's memberships. + * + * @since COmanage Registry v5.0.0 + * @param string $id Group ID + */ + + public function reconcile(string $id) { + try { + $group = $this->Groups->get((int)$id); + + $this->Groups->reconcile($group->id); + + $this->Flash->success(__d('result', 'Groups.reconciled')); + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + } + + return $this->generateRedirect($group ?? null); + } +} \ No newline at end of file diff --git a/app/src/Controller/HistoryRecordsController.php b/app/src/Controller/HistoryRecordsController.php new file mode 100644 index 000000000..2a84b79bd --- /dev/null +++ b/app/src/Controller/HistoryRecordsController.php @@ -0,0 +1,42 @@ + [ + 'HistoryRecords.id' => 'desc' + ] + ]; +} \ No newline at end of file diff --git a/app/src/Controller/IdentifierAssignmentsController.php b/app/src/Controller/IdentifierAssignmentsController.php new file mode 100644 index 000000000..d7d528e31 --- /dev/null +++ b/app/src/Controller/IdentifierAssignmentsController.php @@ -0,0 +1,80 @@ + [ + 'IdentifierAssignments.description' => 'asc' + ] + ]; + + /** + * Assign Identifiers. + * + * @since COmanage Registry v5.0.0 + */ + + public function assign() { + $link = $this->getPrimaryLink(true); + + try { + $results = $this->IdentifierAssignments->assign( + entityType: StringUtilities::foreignKeyToClassName($link->attr), + entityId: (int)$link->value + ); + + if(!empty($results)) { + // We could get multiple types of results from different Identifier Assignments + + if(!empty($results['assigned'])) { + $this->Flash->success(__d('result', 'IdentifierAssignments.assigned.ok', implode(',', array_keys($results['assigned'])))); + } + + if(!empty($results['errors'])) { + $this->Flash->error(implode(',', $results['errors'])); + } + + if(!empty($results['already'])) { + $this->Flash->information(__d('result', 'IdentifierAssignments.assigned.already', implode(',', array_keys($results['already'])))); + } + } + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + } + + $this->generateRedirect(null); + } +} \ No newline at end of file diff --git a/app/src/Controller/IdentifiersController.php b/app/src/Controller/IdentifiersController.php new file mode 100644 index 000000000..73abf0152 --- /dev/null +++ b/app/src/Controller/IdentifiersController.php @@ -0,0 +1,42 @@ + [ + 'Identifiers.identifier' => 'asc' + ] + ]; +} \ No newline at end of file diff --git a/app/src/Controller/JobHistoryRecordsController.php b/app/src/Controller/JobHistoryRecordsController.php new file mode 100644 index 000000000..66948a95d --- /dev/null +++ b/app/src/Controller/JobHistoryRecordsController.php @@ -0,0 +1,62 @@ + [ + 'JobHistoryRecords.id' => 'desc' + ] + ]; + + /** + * Callback run prior to the request render. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Cake Event + */ + + public function beforeRender(\Cake\Event\EventInterface $event) { + // Pull the Group name for breadcrumb rendering + + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->JobHistoryRecords->Jobs->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->JobHistoryRecords->Jobs->getDisplayField()); + } + + return parent::beforeRender($event); + } +} \ No newline at end of file diff --git a/app/src/Controller/JobsController.php b/app/src/Controller/JobsController.php new file mode 100644 index 000000000..4d399ddc8 --- /dev/null +++ b/app/src/Controller/JobsController.php @@ -0,0 +1,61 @@ + [ + 'Jobs.id' => 'desc' + ] + ]; + + /** + * Cancel a Job. + * + * @since COmanage Registry v5.0.0 + * @param string $id Job ID + */ + + public function cancel(string $id) { + try { + $this->Jobs->cancel((int)$id, $this->RegistryAuth->getAuthenticatedUser()); + $this->Flash->success(__d('result', 'Jobs.canceled', [$id])); + } + catch(Exception $e) { + $this->Flash->error($e->getMessage()); + } + + return $this->generateRedirect(null); + } +} \ No newline at end of file diff --git a/app/src/Controller/MVEAController.php b/app/src/Controller/MVEAController.php new file mode 100644 index 000000000..aedda6a3c --- /dev/null +++ b/app/src/Controller/MVEAController.php @@ -0,0 +1,161 @@ +name = Models + $modelsName = $this->name; + + if(!$this->request->is('restful')) { + // Provide additional hints to BreadcrumbsComponent. This needs to be here + // and not in beforeRender because the component beforeRender will run first. + + // This is all we need where person_id is the primary link, but for MVEAs + // that are more deeply linked (to person_role_id, external_identity_id, + // or external_identity_role_id) we need to look up the further links. + $primaryLink = $this->getPrimaryLink(true); + + if($primaryLink->attr == 'person_id' || $primaryLink->attr == 'group_id') { + $this->Breadcrumb->injectPrimaryLink($primaryLink); + } else { + $parentModel = StringUtilities::foreignKeyToClassName($primaryLink->attr); + + $parentPrimaryLink = $this->$modelsName->$parentModel->findPrimaryLink((int)$primaryLink->value); + + $this->Breadcrumb->injectPrimaryLink($parentPrimaryLink); + $this->Breadcrumb->injectPrimaryLink($primaryLink); + } + + // Set up the supertitle and links for subnavigation + if(!empty($primaryLink->value)) { + $this->set('vv_primary_link_attr', $primaryLink->attr); + $this->set('vv_primary_link_id', $primaryLink->value); + + $Names = $this->getTableLocator()->get('Names'); + + switch($primaryLink->attr) { + case 'external_identity_role_id': + $ExternalIdentityRoles = $this->getTableLocator()->get('ExternalIdentityRoles'); + $roleEntity = $ExternalIdentityRoles->findById((int)$primaryLink->value)->firstOrFail(); + + // Note this is a string, but vv_person_name is an entity + $this->set('vv_ei_role', $ExternalIdentityRoles->generateDisplayField($roleEntity)); + $this->set('vv_ei_role_id', $primaryLink->value); + // fall through + case 'external_identity_id': + $ExternalIdentity = $this->getTableLocator()->get('ExternalIdentities'); + + // What's the Person ID for the ExternalIdentity? + $eiId = isset($roleEntity) ? $roleEntity->external_identity_id : $primaryLink->value; + + $externalIdentity = $ExternalIdentity->findById($eiId)->firstOrFail(); + + // What's the primary name for the External Identity? The first name found... + $this->set('vv_ei_name', $Names->primaryName($externalIdentity->id, 'external_identity')); + $this->set('vv_ei_id', $externalIdentity->id); + + // What's the primary name of the Person? + $personName = $Names->primaryName($externalIdentity->person_id); + $this->set('vv_person_name', $personName); + $this->set('vv_supertitle', $personName->full_name); + $this->set('vv_person_id', $externalIdentity->person_id); + break; + case 'person_role_id': + $PersonRoles = $this->getTableLocator()->get('PersonRoles'); + $roleEntity = $PersonRoles->findById((int)$primaryLink->value)->firstOrFail(); + // Note this is a string, but vv_person_name is an entity + $this->set('vv_person_role', $PersonRoles->generateDisplayField($roleEntity)); + $this->set('vv_person_role_id', $primaryLink->value); + + // Also set a name + $personName = $Names->primaryName($roleEntity->person_id); + $this->set('vv_person_name', $personName); + $this->set('vv_supertitle', $personName->full_name); + $this->set('vv_person_id', $roleEntity->person_id); + break; + case 'person_id': + $personName = $Names->primaryName((int)$primaryLink->value); + $this->set('vv_person_name', $personName); + $this->set('vv_supertitle', $personName->full_name); + $this->set('vv_person_id', $primaryLink->value); + break; + default; + break; + } + } + } + + return parent::beforeFilter($event); + } + + /** + * Callback run prior to the request render. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Cake Event + */ + + public function beforeRender(\Cake\Event\EventInterface $event) { + // $this->name = Models + $modelsName = $this->name; + // field = model (or model_name) + $fieldName = Inflector::underscore(Inflector::singularize($modelsName)); + + if(!$this->request->is('restful')) { + // If there is a default type setting for this model, pass it to the view + if($this->$modelsName->getSchema()->hasColumn('type_id')) { + $defaultTypeField = "default_" . $fieldName . "_type_id"; + + $CoSettings = TableRegistry::getTableLocator()->get('CoSettings'); + + $settings = $CoSettings->find()->where(['co_id' => $this->getCOID()])->firstOrFail(); + + $this->set('vv_default_type', $settings->$defaultTypeField); + } + } + + return parent::beforeRender($event); + } +} \ No newline at end of file diff --git a/app/src/Controller/NamesController.php b/app/src/Controller/NamesController.php new file mode 100644 index 000000000..36084b4e7 --- /dev/null +++ b/app/src/Controller/NamesController.php @@ -0,0 +1,98 @@ + [ + 'Names.family' => 'asc', + 'Names.given' => 'asc' + ] + ]; + + /** + * Callback run prior to the request render. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Cake Event + * @return \Cake\Http\Response HTTP Response + */ + + public function beforeRender(\Cake\Event\EventInterface $event) { + if(!$this->request->is('restful')) { + // Get the set of permitted name fields to pass to the view. + // (We don't need required name fields since FormHelper will handle that.) + +// XXX maybe $CoSettings should be available via AppController, like $this->getCOID()? + $CoSettings = TableRegistry::getTableLocator()->get('CoSettings'); + + $settings = $CoSettings->find()->where(['co_id' => $this->getCOID()])->firstOrFail(); + + $this->set('vv_permitted_fields', $settings->name_permitted_fields_array()); + $this->set('vv_default_type', $settings->default_name_type_id); + } + + return parent::beforeRender($event); + } + + /** + * Set a Name as primary. + * + * @since COmanage Registry v5.0.0 + * @param string $id Name ID + * @return \Cake\Http\Response HTTP Response + */ + + public function primary(string $id) { + // All we need to do is set this name to be primary, the model code will + // handle the various Application Rules. + + try { + $query = $this->Names->findById($id); + + // Pull the current record + $obj = $query->firstOrFail(); + + $obj->primary_name = true; + $this->Names->save($obj); + + $this->Flash->success(__d('result', 'Names.primary_name')); + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + } + + return $this->generateRedirect($obj ?? null); + } +} \ No newline at end of file diff --git a/app/src/Controller/PagesController.php b/app/src/Controller/PagesController.php new file mode 100644 index 000000000..709348c60 --- /dev/null +++ b/app/src/Controller/PagesController.php @@ -0,0 +1,102 @@ +Breadcrumb->skipAll(['/^\/$/']); + } + + /** + * Displays a view + * + * @param array ...$path Path segments. + * @return \Cake\Http\Response|null + * @throws \Cake\Http\Exception\ForbiddenException When a directory traversal attempt. + * @throws \Cake\View\Exception\MissingTemplateException When the view file could not + * be found and in debug mode. + * @throws \Cake\Http\Exception\NotFoundException When the view file could not + * be found and not in debug mode. + * @throws \Cake\View\Exception\MissingTemplateException In debug mode. + */ + public function display(...$path): ?Response + { + if (!$path) { + return $this->redirect('/'); + } + if (in_array('..', $path, true) || in_array('.', $path, true)) { + throw new ForbiddenException(); + } + $page = $subpage = null; + + if (!empty($path[0])) { + $page = $path[0]; + } + if (!empty($path[1])) { + $subpage = $path[1]; + } + $this->set(compact('page', 'subpage')); + + try { + return $this->render(implode('/', $path)); + } catch (MissingTemplateException $exception) { + if (Configure::read('debug')) { + throw $exception; + } + throw new NotFoundException(); + } + + return $this->render(); + } + + /** + * Indicate whether this Controller will handle some or all authnz. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Cake event, ie: from beforeFilter + * @return string "no", "open", "authz", or "yes" + */ + + public function willHandleAuth(\Cake\Event\EventInterface $event): string { + return "open"; + } +} diff --git a/app/src/Controller/PeopleController.php b/app/src/Controller/PeopleController.php new file mode 100644 index 000000000..5d529a590 --- /dev/null +++ b/app/src/Controller/PeopleController.php @@ -0,0 +1,81 @@ + [ +// XXX this will sort by family name, but it this universally correct? +// so we need a configuration, or can we do something automagic? +// (ie: what is CJK sort order?) +// C=pinyin, so basically latin; J=KSTNHMYRW/AIUEO; K=hangugl +// so basically a mess... let's just use family name for now and wait for +// (and we haven't even gotten to other languages like Hindi) +// someone to file an RFE + 'PrimaryName.family' => 'asc', + 'PrimaryName.given' => 'asc' + ], + 'sortableFields' => [ + 'PrimaryName.given', + 'PrimaryName.family' + ], + 'finder' => 'indexed' + ]; + + /** + * Callback run prior to the request render. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Cake Event + */ + + public function beforeRender(\Cake\Event\EventInterface $event) { + if(!$this->request->is('restful') && $this->request->getParam('action') == 'add') { + // Get the set of permitted and required name fields to pass to the view. + + // We need to pull a few settings for default enrollment. +// XXX maybe $CoSettings should be available via AppController, like $this->getCOID()? + $CoSettings = TableRegistry::getTableLocator()->get('CoSettings'); + + $settings = $CoSettings->find()->where(['co_id' => $this->getCOID()])->firstOrFail(); + + $this->set('vv_permitted_name_fields', $settings->name_permitted_fields_array()); + $this->set('vv_required_name_fields', $settings->name_required_fields_array()); + $this->set('vv_default_name_type', $settings->default_name_type_id); + } + + return parent::beforeRender($event); + } +} \ No newline at end of file diff --git a/app/src/Controller/PersonRolesController.php b/app/src/Controller/PersonRolesController.php new file mode 100644 index 000000000..95aeb67fb --- /dev/null +++ b/app/src/Controller/PersonRolesController.php @@ -0,0 +1,97 @@ + [ + 'PersonRoles.ordr' => 'asc', + 'PersonRoles.title' => 'asc' + ] + ]; + + // Cache the personStatus on add/edit actions, in order to render a flash message + protected $cachedPerson = null; + + /** + * Callback run prior to the request action. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Cake Event + */ + + public function beforeFilter(\Cake\Event\EventInterface $event) { + if(!$this->request->is('restful') + && ($this->request->is('post') || $this->request->is('put')) + && in_array($this->request->getParam('action'), ['add', 'edit'])) { + // Cache the current Person so to see if status was recalculated + $this->cachedPerson = $this->PersonRoles->People->get($this->request->getData('person_id')); + } + + return parent::beforeFilter($event); + } + + /** + * Set supplemental Flash messages. + * + * @since COmanage Registry v5.0.0 + */ + + public function setSupplementalFlash($entity) { + // If we auto-recalculated the Person Role status, set a Flash message + $autoStatus = $this->PersonRoles->getAutoStatus(); + + if(!empty($autoStatus)) { + $this->Flash->information(__d('result', + 'PersonRoles.status.recalculated', + [__d('enumeration', 'StatusEnum.'.$autoStatus['from']), + __d('enumeration', 'StatusEnum.'.$autoStatus['to'])])); + } + + if(!empty($this->cachedPerson)) { + // See if we have a new Person status value, and if so set a Flash message + + $person = $this->PersonRoles->People->get($this->cachedPerson->id); + + if($this->cachedPerson->status != $person->status) { + $this->Flash->information(__d('result', + 'People.status.recalculated', + [__d('enumeration', 'StatusEnum.'.$this->cachedPerson->status), + __d('enumeration', 'StatusEnum.'.$person->status)])); + } + } + } +} \ No newline at end of file diff --git a/app/src/Controller/PipelinesController.php b/app/src/Controller/PipelinesController.php new file mode 100644 index 000000000..bd841023f --- /dev/null +++ b/app/src/Controller/PipelinesController.php @@ -0,0 +1,41 @@ + [ + 'Pipelines.description' => 'asc' + ] + ]; +} \ No newline at end of file diff --git a/app/src/Controller/PluginsController.php b/app/src/Controller/PluginsController.php new file mode 100644 index 000000000..c8255cab6 --- /dev/null +++ b/app/src/Controller/PluginsController.php @@ -0,0 +1,132 @@ + [ + 'Plugins.plugin' => 'asc' + ] + ]; + + /** + * Apply the database schema for a Plugin. + * + * @since COmanage Registry v5.0.0 + * @param string $id Plugin ID + */ + + public function applySchema(string $id) { + try { + $this->Plugins->applySchema((int)$id); + $this->Flash->success(__d('result', 'applied.schema')); + } + catch(Exception $e) { + $this->Flash->error($e->getMessage()); + } + + return $this->redirect(['action' => 'index']); + } + + /** + * Activate a Plugin. + * + * @since COmanage Registry v5.0.0 + * @param string $id Plugin ID + */ + + public function activate(string $id) { + try { + $this->Plugins->activate((int)$id); + $this->Flash->success(__d('result', 'activated', __d('controller', 'Plugins', [1]))); + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + } + + return $this->redirect(['action' => 'index']); + } + + /** + * Callback run prior to the view rendering. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Cake Event + */ + + public function beforeRender(\Cake\Event\EventInterface $event) { + // In order to get the sidebar to render we need to set the current CO, + // which for plugins is the COmanage CO. + $CosTable = $this->fetchTable('Cos'); + $this->set('vv_cur_co', $CosTable->find('COmanageCO')->firstOrFail()); + + // The Plugins table is a "meta" table rather than configuration, but we should load the + // "configuration" breadcrumb. Override vv_bc_configuration_link to be true so that the + // breadcrumb renders. + $this->set('vv_bc_configuration_link', true); + + return parent::beforeRender($event); + } + + /** + * Deactivate a Plugin. + * + * @since COmanage Registry v5.0.0 + * @param string $id Plugin ID + */ + + public function deactivate(string $id) { + try { + $this->Plugins->deactivate((int)$id); + $this->Flash->success(__d('result', 'deactivated', __d('controller', 'Plugins', [1]))); + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + } + + return $this->redirect(['action' => 'index']); + } + + /** + * Generate an index for the global set of Plugins. + * + * @since COmanage Registry v5.0.0 + */ + + public function index() { + // Loading this page (Configuration > Plugins) triggers the Plugin Registry refresh (AR-Plugin-11). + $this->Plugins->syncPluginRegistry(); + + parent::index(); + } +} \ No newline at end of file diff --git a/app/src/Controller/PronounsController.php b/app/src/Controller/PronounsController.php new file mode 100644 index 000000000..36f0c3606 --- /dev/null +++ b/app/src/Controller/PronounsController.php @@ -0,0 +1,42 @@ + [ + 'Pronouns.pronouns' => 'asc' + ] + ]; +} \ No newline at end of file diff --git a/app/src/Controller/ProvisioningHistoryRecordsController.php b/app/src/Controller/ProvisioningHistoryRecordsController.php new file mode 100644 index 000000000..888e2f031 --- /dev/null +++ b/app/src/Controller/ProvisioningHistoryRecordsController.php @@ -0,0 +1,61 @@ + [ + 'ProvisioningHistoryRecords.id' => 'desc' + ] + ]; + + /** + * Callback run prior to the request action. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Cake Event + * @return \Cake\Http\Response HTTP Response + */ + + public function beforeFilter(\Cake\Event\EventInterface $event) { + if(!$this->request->is('restful')) { + // Provide additional hints to BreadcrumbsComponent. This needs to be here + // and not in beforeRender because the component beforeRender will run first. + + $this->Breadcrumb->injectPrimaryLink($this->getPrimaryLink(true)); + } + + return parent::beforeFilter($event); + } +} \ No newline at end of file diff --git a/app/src/Controller/ProvisioningTargetsController.php b/app/src/Controller/ProvisioningTargetsController.php new file mode 100644 index 000000000..27633787f --- /dev/null +++ b/app/src/Controller/ProvisioningTargetsController.php @@ -0,0 +1,107 @@ + [ + 'ProvisioningTargets.description' => 'asc' + ] + ]; + + /** + * Perform Controller initialization. + * + * @since COmanage Registry v5.0.0 + */ + + public function initialize(): void { + parent::initialize(); + + // Configure breadcrumb rendering + $this->Breadcrumb->skipConfig(['/^\/provisioning-targets\/status/']); + $this->Breadcrumb->skipParents(['/^\/provisioning-targets\/status/']); + } + + /** + * Callback run prior to the request action. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Cake Event + * @return \Cake\Http\Response HTTP Response + */ + + public function beforeFilter(\Cake\Event\EventInterface $event) { + if(!$this->request->is('restful')) { + // Provide additional hints to BreadcrumbsComponent. This needs to be here + // and not in beforeRender because the component beforeRender will run first. + + if($this->request->getParam('action') == 'status') { + $this->Breadcrumb->injectPrimaryLink($this->getPrimaryLink(true)); + } + } + + return parent::beforeFilter($event); + } + + /** + * Generate a status index. + * + * @since COmanage Registry v5.0.0 + */ + + public function status() { + // PrimaryLinkTrait - Look up our primary link to see which object type we're + // working with, an also get our CO ID + $link = $this->getPrimaryLink(true); + // Use argument unpacking operator with names parameters in order to make the call more dynamic + $statusCalculateParams = [ + 'coId' => $link->co_id, + // Currently supported function parameters are personId, groupId + Inflector::variable($link->attr) => (int)$link->value + ]; + $statuses = $this->ProvisioningTargets->status(...$statusCalculateParams); + + $this->set('vv_provisioning_statuses', $statuses); + + if(!$this->request->is('restful')) { + [$title, , ] = StringUtilities::entityAndActionToTitle(null, + 'provisioning', + $this->request->getParam('action')); + $this->set('vv_title', $title); + } + } +} \ No newline at end of file diff --git a/app/src/Controller/ServersController.php b/app/src/Controller/ServersController.php new file mode 100644 index 000000000..9cb5931f2 --- /dev/null +++ b/app/src/Controller/ServersController.php @@ -0,0 +1,41 @@ + [ + 'Servers.description' => 'asc' + ] + ]; +} \ No newline at end of file diff --git a/app/src/Controller/StandardApiController.php b/app/src/Controller/StandardApiController.php new file mode 100644 index 000000000..c3d039079 --- /dev/null +++ b/app/src/Controller/StandardApiController.php @@ -0,0 +1,65 @@ +get($m); + + $Table->setCurCoId($this->getCOID()); + } + + // We want API auth, not Web Auth + $this->RegistryAuth->setConfig('apiUser', true); + } +} \ No newline at end of file diff --git a/app/src/Controller/StandardController.php b/app/src/Controller/StandardController.php new file mode 100644 index 000000000..808f6214b --- /dev/null +++ b/app/src/Controller/StandardController.php @@ -0,0 +1,914 @@ +name = Models (ie: from ModelsTable) + $modelsName = $this->name; + // $table = the actual table object + $table = $this->$modelsName; + // $tableName = models + $tableName = $table->getTable(); + // Create an empty entity for FormHelper + $obj = $table->newEmptyEntity(); + + if($this->request->is('post')) { + try { + // Try to save + $obj = $table->newEntity($this->request->getData()); + + if($table->save($obj)) { + $this->Flash->success(__d('result', 'saved')); + + // Give the controller an opportunity to set additional Flash messages + if(method_exists($this, "setSupplementalFlash")) { + $this->setSupplementalFlash($obj); + } + + // Trigger provisioning, letting errors bubble up (AR-GMR-5) + if(method_exists($table, "requestProvisioning")) { + $this->llog('rule', "AR-GMR-5 Requesting provisioning for $modelsName " . $obj->id); + $table->requestProvisioning(id: $obj->id, context: ProvisioningContextEnum::Automatic); + } + + // If this is a Pluggable Model, instantiate the plugin and redirect + // into the Entry Point Model + if(!empty($obj->plugin) && method_exists($this, "instantiatePlugin")) { + // instantiatePlugin() is implemented in StandardPluggableController + return $this->instantiatePlugin($obj); + } + + return $this->generateRedirect($obj); + } + + $errors = $obj->getErrors(); + + if(!empty($errors)) { + $this->Flash->error(__d('error', 'fields', [ implode(',', + array_map(function($v) use ($errors) { + return __d('error', 'flash', [$v, implode(',', array_values($errors[$v]))]); + }, + array_keys($errors))) ])); + } else { + $this->Flash->error(__d('error', 'save', [$modelsName])); + } + } + catch(\Exception $e) { + // This throws \Cake\ORM\Exception\RolledbackTransactionException if + // aborted in afterSave + + $this->Flash->error($e->getMessage()); + } + } + + // Pass $obj as context so the view can render validation errors + $this->set('vv_obj', $obj); + + // PrimaryLinkTrait, via AppController + $this->getPrimaryLink(); + + // AutoViewVarsTrait, via AppController + $this->populateAutoViewVars(); + + // Default title is add new object + [$title, $supertitle, $subtitle] = StringUtilities::entityAndActionToTitle($obj, $modelsName, 'add'); + $this->set('vv_title', $title); + $this->set('vv_supertitle', $supertitle); + $this->set('vv_subtitle', $subtitle); + + // Let the view render + $this->render('/Standard/add-edit-view'); + } + + /** + * Callback run prior to the request action. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Cake Event + * @return \Cake\Http\Response HTTP Response + */ + + public function beforeFilter(\Cake\Event\EventInterface $event) { + if(!$this->request->is('restful')) { + // Provide additional hints to BreadcrumbsComponent. This needs to be here + // and not in beforeRender because the component beforeRender will run first. + + $primaryLink = $this->getPrimaryLink(true); + + if(!empty($primaryLink->attr) && $primaryLink->attr != 'co_id') { + // eg: EnrollmentFlowSteps -> EnrollmentFlow, JobHistoryRecords -> Job, etc + $this->Breadcrumb->injectPrimaryLink($primaryLink); + } + } + + return parent::beforeFilter($event); + } + + /** + * Standard operations before the view is rendered. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event BeforeRender event + * @return \Cake\Http\Response HTTP Response + */ + +// XXX can we merge calls ot (eg) getPrimaryLink and populateAutoViewVars here? + public function beforeRender(\Cake\Event\EventInterface $event) { + // $this->name = Models (ie: from ModelsTable) + $modelsName = $this->name; + // $table = the actual table object + $table = $this->$modelsName; + + // Provide some hints to the views + $this->getFieldTypes(); + $this->getRequiredFields(); + + // Set the display field as a view var to make it available to the views + $this->set('vv_display_field', $table->getDisplayField()); + + // Populate permissions info, which uses the requested object ID if one + // was provided. As a first approximation, those actions that permit lookup + // primary link are also those that pass an $id that can be used to establish + // permissions, and also Cos (which has no primary link). + + $id = null; + + $params = $this->request->getParam('pass'); + + if(!empty($params[0])) { + if((method_exists($table, "allowLookupPrimaryLink") + && $table->allowLookupPrimaryLink($this->request->getParam('action'))) + || + $modelsName == 'Cos') { + $id = (int)$params[0]; + } + } + + $this->set('vv_permissions', $this->RegistryAuth->calculatePermissionsForView($this->request->getParam('action'), $id)); + + // The template path may vary if we're in a plugin context + $vv_template_path = ROOT . DS . "templates" . DS . $modelsName; + + if(!empty($this->getPlugin())) { + $vv_template_path = $this->getPluginPath($this->getPlugin(), "templates") . DS . $modelsName; + } + + $this->set('vv_template_path', $vv_template_path); + + // Primarily of interest to detailed record views, if this attribute supports + // Pipeline sourcing (ie: has a source_foo_id field) set the name of the source + // foreign key into a view var since it's not always calculable. + if(method_exists($table, "sourceForeignKey")) { + $this->set('vv_source_fk', $table->sourceForeignKey()); + } + + // Check to see if the model names a specific layout + if(method_exists($table, "getLayout")) { + $this->viewBuilder()->setLayout($table->getLayout()); + } + + return parent::beforeRender($event); + } + + /** + * Handle a delete action for a Standard object. + * + * @since COmanage Registry v5.0.0 + * @param Integer $id Object ID + */ + + public function delete($id) { + // $this->name = Models (ie: from ModelsTable) + $modelsName = $this->name; + // $table = the actual table object + $table = $this->$modelsName; + + // Allow a delete via a POST or DELETE + $this->request->allowMethod(['post', 'delete']); + + // Make sure the requested object exists + try { + $obj = $table->findById($id)->firstOrFail(); + +// XXX throw 404 on RESTful not found? + // By default, a delete is a soft delete. The exceptions are when + // deleting a CO (AR-CO-1) or when an expunge flag is passed and + // expunge is enabled within the CO (XXX not yet implemented). + + $useHardDelete = ($modelsName == "Cos"); + + $table->deleteOrFail($obj, ['useHardDelete' => $useHardDelete]); + + // Use the display field to generate the flash message + + $field = $table->getDisplayField(); + + if(!empty($obj->$field)) { + $this->Flash->success(__d('result', 'deleted.a', [$obj->$field])); + } else { + $this->Flash->success(__d('result', 'deleted')); + } + + // Trigger provisioning, letting errors bubble up (AR-GMR-5) + // In general, tables should check that they were passed a deleted + // record and martial data/set eligibility appropriately + if(method_exists($table, "requestProvisioning")) { + $this->llog('rule', "AR-GMR-5 Requesting provisioning for deleted entity $modelsName " . $obj->id); + $table->requestProvisioning(id: (int)$id, context: ProvisioningContextEnum::Automatic); + } + + // Return to index since there is no delete view + return $this->generateRedirect(null); + } + catch(\Cake\ORM\Exception\PersistenceFailedException $e) { + // deleteOrFail throws Cake\ORM\Exception\PersistenceFailedException + + // Application Rules that apply to the entity as a whole (or more than + // one field) can use "id" as their errorField, and we'll catch that here. + + $errors = $obj->getErrors(); + + if(!empty($errors['id'])) { + $this->Flash->error(implode(',', array_values($errors['id']))); + } else { + $this->Flash->error($e->getMessage()); + } + } + catch(\Exception $e) { + // findById throws Cake\Datasource\Exception\RecordNotFoundException + $errors = $obj->getErrors(); + + if(!empty($errors)) { + // Format is [field => [rule => error]] + $errstr = ""; + + foreach($errors as $f => $r) { + foreach($r as $rule => $err) { + $errstr .= $err . ","; + } + } + + $this->Flash->error(rtrim($errstr, ",")); + } else { + $this->Flash->error($e->getMessage()); + } + } + + // The record is still valid, so redirect back to it + return $this->redirect(['action' => 'edit', $id]); + } + + /** + * Handle an edit action for a Standard object. + * + * @since COmanage Registry v5.0.0 + * @param string $id Object ID + */ + + public function edit(string $id) { + // $this->name = Models (ie: from ModelsTable) + $modelsName = $this->name; + // $table = the actual table object + $table = $this->$modelsName; + // $tableName = models + $tableName = $table->getTable(); + + // We use findById() rather than get() so we can apply subsequent + // query modifications via traits + $query = $table->findById($id); + + // QueryModificationTrait + if(method_exists($this->$modelsName, "getEditContains")) { + $query = $query->contain($this->$modelsName->getEditContains()); + } + + try { + // Pull the current record + $obj = $query->firstOrFail(); + + if(method_exists($obj, "isReadOnly")) { + // If this is a read only record, redirect to view + if($obj->isReadOnly()) { + $redirect = [ + 'action' => 'view', + $obj->id + ]; + + return $this->redirect($redirect); + } + } + + if($this->request->is(['post', 'put'])) { + // This is an update request + $opts = []; + + // AssociationTrait + /* + if(method_exists($table, "getPatchAssociated")) { + $opts['associated'] = $table->getPatchAssociated(); + }*/ + + // $obj will have whatever editContains also pulled, but we don't want + // to save all that stuff by default, so we'll pull a new copy of the + // object without the associated data. + $saveObj = $table->findById($id)->firstOrFail(); + + // Attempt the update the record + $table->patchEntity($saveObj, $this->request->getData(), $opts); + + // This throws \Cake\ORM\Exception\RolledbackTransactionException if aborted + // in afterSave + if($table->save($saveObj)) { + $this->Flash->success(__d('result', 'saved')); + + // Give the controller an opportunity to set additional Flash messages + if(method_exists($this, "setSupplementalFlash")) { + $this->setSupplementalFlash($obj); + } + + // Trigger provisioning, letting errors bubble up (AR-GMR-5) + if(method_exists($table, "requestProvisioning")) { + $this->llog('rule', "AR-GMR-5 Requesting provisioning for $modelsName " . $obj->id); + $table->requestProvisioning(id: (int)$id, context: ProvisioningContextEnum::Automatic); + } + + return $this->generateRedirect($saveObj); + } + + $errors = $saveObj->getErrors(); + + if(!empty($errors)) { + $this->Flash->error(__d('error', 'fields', [ implode(',', + array_map(function($v) use ($errors) { + return __d('error', 'flash', [$v, implode(',', array_values($errors[$v]))]); + }, + array_keys($errors))) ])); + } else { + $this->Flash->error(__d('error', 'save', [$modelsName])); + } + } + } + catch(\Exception $e) { + // findById throws Cake\Datasource\Exception\RecordNotFoundException + $this->Flash->error($e->getMessage()); + return $this->generateRedirect(null); + } + + $this->set('vv_obj', $obj); + // XXX should we also set '$model'? cake seems to autopopulate edit fields just fine without it + // note index() uses $tableName, not 'vv_objs' or event 'vv_table_name' + + // PrimaryLinkTrait + $this->getPrimaryLink(); + + // AutoViewVarsTrait + $this->populateAutoViewVars($obj); + + // Calculate and set title, supertitle and subtitle + [$title, $supertitle, $subtitle] = StringUtilities::entityAndActionToTitle($obj, $modelsName, 'edit'); + + $this->set('vv_title', $title); + $this->set('vv_supertitle', $supertitle); + $this->set('vv_subtitle', $subtitle); + + // Let the view render + $this->render('/Standard/add-edit-view'); + } + + /** + * Generate a redirect for a Standard Object operation. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Entity to redirect to + * @return \Cake\Http\Response + */ + + public function generateRedirect($entity) { + $redirect = []; + + // By default we return to the index, but we'll also accept "self" or "primaryLink". + $redirectGoal = $this->getRedirectGoal($this->request->getParam('action')); + + if(!$redirectGoal) { + // Our default behavior is index unless we're in a plugin context + + if(!empty($this->getPlugin())) { + $redirectGoal = 'pluggableLink'; + } else { + $redirectGoal = 'index'; + } + } + + if($redirectGoal == 'self' + && $entity + && in_array($this->request->getParam('action'), ['add', 'edit'])) { + // We typically want to redirect to the edit view of the record, + // but in some cases (eg: if the record was just frozen) we want to + // redirect to "view" instead. + + $readOnly = false; + + if(method_exists($entity, "isReadOnly")) { + $readOnly = $entity->isReadOnly(); + } + + $redirect = [ + 'action' => $readOnly ? "view" : "edit", + $entity->id + ]; + } elseif($redirectGoal == 'pluggableLink' || $redirectGoal == 'primaryLink') { + // pluggableLink and primaryLink do basically the same thing, except that + // pluggableLink checks for special handling of the 'plugin' parameter + $link = $this->getPrimaryLink(true); + + if(!empty($link->attr) && !empty($link->value)) { + $redirect = [ + 'controller' => StringUtilities::foreignKeyToClassName($link->attr), + 'action' => 'edit', + $link->value + ]; + + if($redirectGoal == 'pluggableLink') { + // If the primary link points to a plugin, we want to redirect + // into that plugin, otherwise the core code + $redirect['plugin'] = $link->plugin ?? null; + } + } + } elseif($redirectGoal == 'special') { + // The controller will implement a special calculation + + $redirect = $this->calculateRedirectTarget($entity); + } else { + // Default is to redirect to the index view + $redirect = ['action' => 'index']; + + $link = $this->getPrimaryLink(true); + + if(!empty($link->attr) && !empty($link->value)) { + $redirect['?'] = [$link->attr => $link->value]; + } + + if(!empty($this->getPlugin())) { + $redirect['plugin'] = $this->getPlugin(); + } + } + + return $this->redirect($redirect); + } + + /** + * Make a list of fields types suitable for FieldHelper + * + * @since COmanage Registry v5.0.0 + */ + + protected function getFieldTypes() { + // $this->name = Models (ie: from ModelsTable) + $modelsName = $this->name; + // $table = the actual table object + $table = $this->$modelsName; + + $schema = $table->getSchema(); + + // We don't pass the schema object as is, partly because cake might change it + // and partly to simplify access to the parts the views (FieldHelper, really) + // actually need. + + // Note the schema does have field lengths for strings, but typeMap + // doesn't return them and we're not doing anything with them at the moment. + $this->set('vv_field_types', $schema->typeMap()); + } + + /** + * Build a list of required fields suitable for FieldHelper + * + * @since COmanage Registry v5.0.0 + */ + + protected function getRequiredFields() { + // $this->name = Models (ie: from ModelsTable) + $modelsName = $this->name; + // $table = the actual table object + $table = $this->$modelsName; + + // Build a list of required fields for FieldHelper + $reqFields = []; + + $validator = $table->getValidator(); + $fields = $validator->getIterator(); + + foreach($fields as $name => $cfg) { + if(!$validator->isEmptyAllowed($name, ($this->request->getParam('action') == 'add'))) { + $reqFields[] = $name; + } + } + + $this->set('vv_required_fields', $reqFields); + } + + /** + * Generate an index for a set of Standard Objects. + * + * @since COmanage Registry v5.0.0 + */ + + public function index() { + // $this->name = Models + $modelsName = $this->name; + // $table = the actual table object + $table = $this->$modelsName; + // $tableName = models + $tableName = $table->getTable(); + + $query = null; + + // PrimaryLinkTrait + $link = $this->getPrimaryLink(true); + + // AutoViewVarsTrait + $this->populateAutoViewVars(); + + if(!empty($link->attr)) { + // If a link attribute is defined but no value is provided, then query + // where the link attribute is NULL + // "all" is the default finder. But since we are utilizing the paginator here, we will check the configuration + // for any custom finder. + $query = $table->find( + $this->paginate['finder'] ?? "all" + )->where([$table->getAlias().'.'.$link->attr => $link->value]); + } else { + $query = $table->find($this->paginate['finder'] ?? "all"); + } + + // QueryModificationTrait + if(method_exists($table, "getIndexContains") + && $table->getIndexContains()) { + $query->contain($table->getIndexContains()); + } + + // SearchFilterTrait + if(method_exists($table, "getSearchableAttributes")) { + $searchableAttributes = $table->getSearchableAttributes($this->name, $this->viewBuilder()->getVar('vv_tz')); + + if(!empty($searchableAttributes)) { + // Here we iterate over the attributes, and we add a new where clause for each one + foreach($searchableAttributes as $attribute => $options) { + if(!empty($this->request->getQuery($attribute))) { + $query = $table->whereFilter($query, $attribute, $this->request->getQuery($attribute)); + } elseif (!empty($this->request->getQuery($attribute . "_starts_at")) + || !empty($this->request->getQuery($attribute . "_ends_at"))) { + $search_date = []; + // We allow empty for dates since we might refer to infinity (from whenever or to always) + $search_date[] = $this->request->getQuery($attribute . "_starts_at") ?? ""; + $search_date[] = $this->request->getQuery($attribute . "_ends_at") ?? ""; + $query = $table->whereFilter($query, $attribute, $search_date); + } + } + + $this->set('vv_searchable_attributes', $searchableAttributes); + } + } + + // Filter on requested filter, if requested + // QueryModificationTrait + if(method_exists($table, "getIndexFilter")) { + $filter = $table->getIndexFilter(); + + if(is_callable($filter)) { + $query->where($filter($this->request)); + } else { + $query->where($table->getIndexFilter()); + } + } + + $resultSet = $this->paginate($query); + + $this->set($tableName, $resultSet); + $this->set('vv_permission_set', $this->RegistryAuth->calculatePermissionsForResultSet($resultSet)); + + // Default index view title is model name + [$title, , ] = StringUtilities::entityAndActionToTitle($resultSet, $modelsName, 'index'); + $this->set('vv_title', $title); + + // Let the view render + $this->render('/Standard/index'); + } + + /** + * Populate any auto view variables, as requested via AutoViewVarsTrait. + * + * @since COmanage Registry v5.0.0 + * @param object $obj Current object (eg: from edit), if set + */ + + protected function populateAutoViewVars(object $obj=null) { + // $this->name = Models + $modelsName = $this->name; + // $table = the actual table object + $table = $this->$modelsName; + + // Populate certain view vars (eg: selects) automatically. + + // AutoViewVarsTrait + if(method_exists($table, "getAutoViewVars") + && $table->getAutoViewVars()) { + foreach($table->getAutoViewVars() as $vvar => $avv) { + switch($avv['type']) { + case 'array': + // Use the provided array of values. By default, we use the values + // for the keys as well, to generate HTML along the lines of + // . (See also 'hash'.) + $this->set($vvar, array_combine($avv['array'], $avv['array'])); + break; + case 'enum': + // We just want the localized text strings for the defined constants. + $class = '\\App\\Lib\\Enum\\'.$avv['class']; + // We support plugin notation for plugin defined enumerations. + if(strstr($avv['class'], ".")) { + $bits = explode('.', $avv['class'], 2); + $class = '\\'.$bits[0].'\\Lib\\Enum\\'.$bits[1]; + } + $this->set($vvar, $class::getLocalizedConsts()); + break; + case 'hash': + // Like 'array' but we assume we are passed key/value pairs + $this->set($vvar, $avv['hash']); + break; + // "auxiliary" and "select" do basically the same thing, but the former + // returns the full object and the latter just returns a hash suitable + // for a select. "type" is a shorthand for "select" for type_id. + case 'type': + // Inject configuration. Since we're only ever looking at the types + // table, inject the current CO along with the requested attribute + $avv['model'] = 'Types'; + if(is_array($avv['attribute'])) { + $avv['where'] = [ + 'attribute IN' => $avv['attribute'], + 'status' => SuspendableStatusEnum::Active + ]; + } else { + $avv['where'] = [ + 'attribute' => $avv['attribute'], + 'status' => SuspendableStatusEnum::Active + ]; + } + // fall through + case 'auxiliary': +// XXX add list as in match? + case 'select': + // We assume $modelName has a direct relationship to $avv['model'] + $avvmodel = $avv['model']; + $this->$avvmodel = $this->fetchTable($avvmodel); + + if($avv['type'] == 'auxiliary') { + $query = $this->$avvmodel->find(); + } else { + $query = $this->$avvmodel->find('list'); + } + + if(!empty($avv['find'])) { + if($avv['find'] == 'filterPrimaryLink') { + // We're filtering the requested model, not our current model. + // See if the requested key is available, and if so run the find. + + $linkFilter = $table->getPrimaryLink(); + + if($linkFilter) { + // Try to find the $linkFilter value + $v = null; + + // We might have been passed an object with the current value + if($obj && !empty($obj->$linkFilter)) { + $v = $obj->$linkFilter; + } elseif(!empty($this->request->getQuery($linkFilter))) { + $v = $this->request->getQuery($linkFilter); + } +// XXX also need to check getData()? +// XXX shouldn't this use $this->getPrimaryLink() instead? Or maybe move $this->primaryLink +// to PrimaryLinkTrait and call it there? + + if($v) { + $avv['where'][$table->getAlias().'.'.$linkFilter] = $v; + //$query = $query->where([$table->getAlias().'.'.$linkFilter => $v]); + } + } + } else { + // Use the specified finder, if configured + $query = $query->find($avv['find']); + } + } else { +// XXX is this the best logic? maybe some relation to filterPrimaryLink? + // By default, filter everything on CO ID + + $avv['where']['co_id'] = $this->getCOID(); + //$query = $query->where([$table->getAlias().'.co_id' => $this->getCOID()]); + } + + if(!empty($avv['where'])) { + // Filter on the specified clause (of the form [column=>value]) + $query = $query->where($avv['where']); + } + + // Sort the list by display field + if(!empty($avv['model']) && method_exists($this->$avvmodel, "getDisplayField")) { + $query->order([$this->$avvmodel->getDisplayField() => 'ASC']); + } elseif(method_exists($table, "getDisplayField")) { + $query->order([$table->getDisplayField() => 'ASC']); + } + + $this->set($vvar, $query->toArray()); + break; + case 'parent': + $modelsName = $this->name; + // $table = the actual table object + $table = $this->$modelsName; + // XXX We assume that all models that load the Tree behavior will + // implement a potentialParents method + $this->set($vvar, $table->potentialParents($this->getCOID())); + break; + case 'plugin': + $PluginTable = $this->getTableLocator()->get('Plugins'); + $this->set($vvar, $PluginTable->getActivePluginModels($avv['pluginType'])); + break; + default: +// XXX I18n? and in match? + throw new \LogicException('Unknonwn Auto View Var Type {0}', [$avv['type']]); + break; + } + } + } + } + + /** + * Handle a provisioning request for a Standard object. + * + * @since COmanage Registry v5.0.0 + * @param string $id Object ID + */ + + public function provision($id) { + // $this->name = Models + $modelsName = $this->name; + // $table = the actual table object + $table = $this->$modelsName; + // $tableName = models + $tableName = $table->getTable(); + + // Note that only Primary Models support provisioning, but those that + // don't won't have permission to execute this function. + + try { + $table->requestProvisioning( + id: (int)$id, + context: ProvisioningContextEnum::Manual, + provisioningTargetId: (int)$this->getRequest()->getQuery('provisioning_target_id') + ); + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + } + + // We don't render any flash messages since they could get complex + // depending on what was provisioned, so instead we redirect into the + // provisioning status index for the object. + // Redirect to the provisioning status view + + $redirect = [ + 'controller' => 'ProvisioningTargets', + 'action' => 'status', + '?' => [ + StringUtilities::tableToForeignKey($table) => $id + ] + ]; + + return $this->redirect($redirect); + } + + /** + * Unfreeze a frozen record. + * + * @since COmanage Registry v5.0.0 + * @param string $id Entity ID + */ + + public function unfreeze($id) { + // $this->name = Models + $modelsName = $this->name; + // $table = the actual table object + $table = $this->$modelsName; + + try { + // Pull the current record + $obj = $table->get((int)$id); + } + catch(\Exception $e) { + // findById throws Cake\Datasource\Exception\RecordNotFoundException + $this->Flash->error($e->getMessage()); + return $this->generateRedirect(null); + } + + // Normally we'd wrap this in a function on the table or entity, but + // it's such a simple change that it doesn't seem to be worth it atm. + $obj->frozen = false; + $table->save($obj); + + return $this->generateRedirect($obj); + } + + /** + * Handle a view action for a Standard object. + * + * @since COmanage Registry v5.0.0 + * @param string $id Object ID + */ + + public function view($id = null) { + // $this->name = Models + $modelsName = $this->name; + // $table = the actual table object + $table = $this->$modelsName; + // $tableName = models + $tableName = $table->getTable(); + + // We use findById() rather than get() so we can apply subsequent + // query modifications via traits + $query = $table->findById($id); + + // QueryModificationTrait + if(method_exists($table, "getViewContains")) { + $query = $query->contain($table->getViewContains()); + } + + try { + // Pull the current record + $obj = $query->firstOrFail(); + } + catch(\Exception $e) { + // findById throws Cake\Datasource\Exception\RecordNotFoundException + $this->Flash->error($e->getMessage()); + return $this->generateRedirect(null); + } + + $this->set('vv_obj', $obj); + + // PrimaryLinkTrait + $this->getPrimaryLink(); + + // AutoViewVarsTrait + // We still used this in view() to map select values + $this->populateAutoViewVars($obj); + + // Calculate and set title, supertitle and subtitle + [$title, $supertitle, $subtitle] = StringUtilities::entityAndActionToTitle($obj, $modelsName, 'view'); + + $this->set('vv_title', $title); + $this->set('vv_supertitle', $supertitle); + $this->set('vv_subtitle', $subtitle); + + // Let the view render + $this->render('/Standard/add-edit-view'); + } +} \ No newline at end of file diff --git a/app/src/Controller/StandardPluggableController.php b/app/src/Controller/StandardPluggableController.php new file mode 100644 index 000000000..e8f33c287 --- /dev/null +++ b/app/src/Controller/StandardPluggableController.php @@ -0,0 +1,114 @@ +name = Models (ie: from ModelsTable) + $modelsName = $this->name; + // $table = the actual table object + $table = $this->$modelsName; + + $parentId = $this->request->getParam('pass')[0]; + $parentObj = $table->findById($parentId) + ->firstOrFail(); + + $pluginTable = $this->getTableLocator()->get($parentObj->plugin); + $pluginObj = $pluginTable->find() + ->where([StringUtilities::tableToForeignKey($table) => $parentId]) + ->firstOrFail(); + + return $this->redirect([ + 'plugin' => StringUtilities::pluginPlugin($parentObj->plugin), + 'controller' => StringUtilities::pluginModel($parentObj->plugin), + 'action' => 'edit', + $pluginObj->id + ]); + } + + /** + * Instantiate the plugin for this Pluggable model. Upon success, a redirect into + * the edit view for the instantiated object will be issued. + * + * @since COmanage Registry v5.0.0 + * @param object $obj Pluggable object + */ + + protected function instantiatePlugin(object $obj) { + // Create the row for the entry point model, then redirect into it + + // eg: report_id + $parentKey = StringUtilities::entityToForeignKey($obj); + + // For now, we just populate the foreign key from the instantiated plugin + // to its parent object, but we might want to allow the plugin model to + // set some default values. + $created = new \Datetime('now'); + + $iValues = [ + $parentKey => $obj->id, + 'created' => $created->format('Y-m-d H:i:s') + ]; + + $pTable = $this->getTableLocator()->get($obj->plugin); + + $iObj = $pTable->newEntity($iValues); + + // We skip validation and rule checking because we're saving a skeletal record. (AR-Plugin-9) + + if($pTable->save($iObj, ['validate' => false, 'checkRules' => false])) { + // Redirect into plugin + + return $this->redirect([ + 'plugin' => StringUtilities::pluginPlugin($obj->plugin), + 'controller' => StringUtilities::pluginModel($obj->plugin), + 'action' => 'edit', + $iObj->id + ]); + } else { + $this->Flash->error(__d('error', 'save.plugin', [$obj->plugin])); + } + } +} \ No newline at end of file diff --git a/app/src/Controller/StandardPluginController.php b/app/src/Controller/StandardPluginController.php new file mode 100644 index 000000000..b15b29a9a --- /dev/null +++ b/app/src/Controller/StandardPluginController.php @@ -0,0 +1,132 @@ +name = Models + $modelsName = $this->name; + + if(!$this->request->is('restful')) { + // Provide additional hints to BreadcrumbsComponent. This needs to be here + // and not in beforeRender because the component beforeRender will run first. + + // This is all we need where person_id is the primary link, but for MVEAs + // that are more deeply linked (to person_role_id, external_identity_id, + // or external_identity_role_id) we need to look up the further links. + $primaryLink = $this->getPrimaryLink(true); + + $this->Breadcrumb->skipParents(['/^\/[a-zA-Z0-9-]+\/[a-zA-Z0-9-]+\/edit\//']); + + if($primaryLink->attr == 'server_id') { + // Servers shouldn't show up as configuration, so automatically hide it + // eg for server plugins + $this->Breadcrumb->skipConfig(['/^\//']); + } + + $this->Breadcrumb->injectPrimaryLink($primaryLink); + } + + return parent::beforeFilter($event); + } + + /** + * Callback run prior to the request render. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Cake Event + */ + + public function beforeRender(\Cake\Event\EventInterface $event) { + // $this->name = Models (ie: from ModelsTable, eg FileProvisionersTable) + $modelsName = $this->name; + // $table = the actual table object + $table = $this->$modelsName; + + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $parentClassName = StringUtilities::foreignKeyToClassName($link->attr); + + $parentObj = $table->$parentClassName->get($link->value); + $parentDisplayField = $table->$parentClassName->getDisplayField(); + + $this->set('vv_bc_parent_obj', $parentObj); + $this->set('vv_bc_parent_displayfield', $parentDisplayField); + + // Override the title set in StandardController. Since that was set in edit() + // which is called before the rendering hooks, this title will take precedence. + + [$title, , ] = StringUtilities::entityAndActionToTitle($parentObj, $parentClassName, 'configure'); + $this->set('vv_title', $title); + } + + return parent::beforeRender($event); + } + + /** + * Determine the filesystem path to a file within a plugin. + * + * @since COmanage Registry v5.0.0 + * @param string $pluginName Physical plugin name + * @param string $file File name within plugin + * @return string Path to file + */ + + protected function getPluginPath(string $pluginName, string $file): string { + $PluginTable = $this->getTableLocator()->get('Plugins'); + + // Because plugins are uniquely named (AR-Plugin-1) we can do a find based + // on the name to get the object. + + $plugin = $PluginTable->find() + ->where([ + 'plugin' => $pluginName, + 'status' => SuspendableStatusEnum::Active + ]) + ->firstOrFail(); + + return $PluginTable->pluginPath($plugin, $file); + } +} \ No newline at end of file diff --git a/app/src/Controller/TelephoneNumbersController.php b/app/src/Controller/TelephoneNumbersController.php new file mode 100644 index 000000000..9d867600b --- /dev/null +++ b/app/src/Controller/TelephoneNumbersController.php @@ -0,0 +1,63 @@ + [ + 'TelephoneNumbers.number' => 'asc' + ] + ]; + + /** + * Callback run prior to the request render. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Cake Event + * @return \Cake\Http\Response HTTP Response + */ + + public function beforeRender(\Cake\Event\EventInterface $event) { + if(!$this->request->is('restful')) { + $CoSettings = TableRegistry::getTableLocator()->get('CoSettings'); + + $settings = $CoSettings->find()->where(['co_id' => $this->getCOID()])->firstOrFail(); + +// XXX move this into MVEAController or a trait + $this->set('vv_permitted_fields', $settings->telephone_number_permitted_fields_array()); + } + + return parent::beforeRender($event); + } +} \ No newline at end of file diff --git a/app/src/Controller/TrafficController.php b/app/src/Controller/TrafficController.php new file mode 100644 index 000000000..88024c1f5 --- /dev/null +++ b/app/src/Controller/TrafficController.php @@ -0,0 +1,72 @@ +getRequest(); + $session = $request->getSession(); + + $username = $session->read('Auth.external.user'); + + if(!$username) { + throw new \InvalidArgumentException('Auth.external.user not found in TrafficController'); + } + + $target = $session->read('Auth.target'); + + if(!$target) { + throw new \InvalidArgumentException('Auth.target not found in TrafficController'); + } + + // Record the login event + $AuthenticationEvents = TableRegistry::getTableLocator()->get('AuthenticationEvents'); + + $AuthenticationEvents->record(identifier: $username, + eventType: AuthenticationEventEnum::RegistryLogin, + remoteIp: $_SERVER['REMOTE_ADDR']); + + // Redirect to $target + return $this->redirect($target); + } +} \ No newline at end of file diff --git a/app/src/Controller/TypesController.php b/app/src/Controller/TypesController.php new file mode 100644 index 000000000..87fa53861 --- /dev/null +++ b/app/src/Controller/TypesController.php @@ -0,0 +1,63 @@ + [ + 'Types.attribute' => 'asc', + 'Types.display_name' => 'asc' + ] + ]; + + /** + * Restore default types for the requested CO. + * + * @since COmanage Registry v5.0.0 + */ + + public function restore() { + try { + $this->Types->addDefaults($this->getCOID()); + + $this->Flash->success(__d('result', 'saved')); + } + catch(\Exception $e) { + // findById throws Cake\Datasource\Exception\RecordNotFoundException + + $this->Flash->error($e->getMessage()); + } + + return $this->generateRedirect(null); + } +} \ No newline at end of file diff --git a/app/src/Controller/UrlsController.php b/app/src/Controller/UrlsController.php new file mode 100644 index 000000000..d608ce95f --- /dev/null +++ b/app/src/Controller/UrlsController.php @@ -0,0 +1,42 @@ + [ + 'Urls.url' => 'asc' + ] + ]; +} \ No newline at end of file diff --git a/app/src/Lib/Enum/ActionEnum.php b/app/src/Lib/Enum/ActionEnum.php new file mode 100644 index 000000000..8c5d17bab --- /dev/null +++ b/app/src/Lib/Enum/ActionEnum.php @@ -0,0 +1,54 @@ + 14, + self::GracePeriod => 13, + + // Next come expired statuses, since there may be provisioned skeletal records + // that need to be maintained + self::Suspended => 12, + + // Finally, we generally don't want Deleted or Duplicate unless all roles are deleted or duplicates + self::Archived => 2, + // "Deleted" is managed by Registry, not the EIS backend, but we'll basically treat + // it the same as Archived + self::Deleted => 2, + self::Duplicate => 1 + ); + + if(!isset($statusRanks[$status])) { + throw new \InvalidArgumentException("Invalid status $status"); + } + + return $statusRanks[$status]; + } +} \ No newline at end of file diff --git a/app/src/Lib/Enum/GroupTypeEnum.php b/app/src/Lib/Enum/GroupTypeEnum.php new file mode 100644 index 000000000..ae4144a47 --- /dev/null +++ b/app/src/Lib/Enum/GroupTypeEnum.php @@ -0,0 +1,38 @@ +getConstants(); + + // get_called_class() will return something like App\Lib\Enum\StatusEnum + // or CoreServer\Lib\Enum\RdbmsTypeEnum + $classBits = explode('\\', get_called_class(), 4); + + if($classBits[0] == 'App') { + foreach(array_values($consts) as $key) { + $ret[$key] = __d('enumeration', $classBits[3].'.'.$key); + } + } else { + $pluginDomain = Inflector::underscore($classBits[0]); + + foreach(array_values($consts) as $key) { + $ret[$key] = __d($pluginDomain, 'enumeration.'.$classBits[3].'.'.$key); + } + } + + return $ret; + } + + /** + * Get the values for the constants in the Enumeration. + * + * @since COmanage Registry v5.0.0 + * @return array Array of enumeration values + */ + + public static function getConstValues() : array { + // Get the keys for this enum + $reflect = new ReflectionClass(get_called_class()); + + $consts = $reflect->getConstants(); + + return array_values($consts); + } +} \ No newline at end of file diff --git a/app/src/Lib/Enum/StatusEnum.php b/app/src/Lib/Enum/StatusEnum.php new file mode 100644 index 000000000..4664329f7 --- /dev/null +++ b/app/src/Lib/Enum/StatusEnum.php @@ -0,0 +1,109 @@ + Expired. + + // Note a similar chart is defined in ExternalIdentityStatusEnum. + + $statusRanks = array( + // Active statuses are most preferred + self::Active => 15, + self::GracePeriod => 14, + + // Next come expired statuses, since there may be provisioned skeletal records + // that need to be maintained + self::Suspended => 13, + self::Expired => 12, + + // Then invitation statuses + self::Approved => 11, + self::PendingApproval => 10, + self::Confirmed => 9, + self::PendingConfirmation => 8, + self::Invited => 7, + self::PendingActivation => 6, + self::Pending => 5, // It's not clear this is used for anything + + // Denied and Declined are below expired since other roles are more likely to have been used + self::Denied => 4, + self::Declined => 3, + + // Finally, we generally don't want Archived or Duplicate unless all roles are deleted or duplicates + self::Archived => 2, + self::Duplicate => 1 + ); + + if($status == self::Locked) { + // Locked status should only apply to the Person and not Person Roles, so it + // shouldn't be valid for ranking. + + throw new \InvalidArgumentException("Cannot calculate Rank for Locked status"); + } + + if(!isset($statusRanks[$status])) { + throw new \InvalidArgumentException("Invalid status $status"); + } + + return $statusRanks[$status]; + } +} \ No newline at end of file diff --git a/app/src/Lib/Enum/SuspendableStatusEnum.php b/app/src/Lib/Enum/SuspendableStatusEnum.php new file mode 100644 index 000000000..922f25e04 --- /dev/null +++ b/app/src/Lib/Enum/SuspendableStatusEnum.php @@ -0,0 +1,35 @@ +RegistryAuth = $Auth; + } + + /** + * Before save event listener. + * + * @since COmanage Registry v5.0.0 + * @param Event $event Cake Event + * @param EntityInterface $entity Entity subject of the event (ie: object to be saved) + * @param ArrayObject $options Save options + */ + + public function beforeSave(Event $event, EntityInterface $entity, ArrayObject $options) { + // Tweak the options so ChangelogBehavior can see who performed the save + + if($this->RegistryAuth) { + $options['actor'] = $this->RegistryAuth->getAuthenticatedUser(); + $options['apiuser'] = $this->RegistryAuth->isApiUser(); + } + } + + /** + * Define the list of implemented events. + * + * @since COmanage Registry v5.0.0 + * @return array Array of implemented events and associated configuration. + */ + + public function implementedEvents(): array { + return [ + 'Model.beforeSave' => [ + 'callable' => 'beforeSave', + // We need this beforeSave to run before the ChangelogBehavior beforeSave + 'priority' => -100 + ] + ]; + } +} diff --git a/app/src/Lib/Events/RuleBuilderEventListener.php b/app/src/Lib/Events/RuleBuilderEventListener.php new file mode 100644 index 000000000..a271e6b8f --- /dev/null +++ b/app/src/Lib/Events/RuleBuilderEventListener.php @@ -0,0 +1,259 @@ +getSubject(); + + $schema = $subjectTable->getSchema(); + + // We need to skip some metadata fields, including changelog and EIS fks + // changelog + $cl = Inflector::singularize($subjectTable->getTable()) . "_id"; + // external identity source + $eis = "source_" . $cl; + + // Figure out the primary link(s) for this table. + $primaryLinks = []; + + if(method_exists($subjectTable, "getPrimaryLinks")) { + $primaryLinks = $subjectTable->getPrimaryLinks(); + } + + foreach($schema->columns() as $col) { + if(in_array($col, [$cl, $eis])) { + // Skip the changelog key since it will only every have pointed to a + // record within the CO, and the self-dependency confuses the association + // calculation, below + continue; + } + + if(in_array($col, $primaryLinks)) { + $rules->addUpdate( + [$this, 'ruleFreezePrimaryLink'], + 'freezePrimaryLink', + ['errorField' => $col] + ); + } elseif(preg_match('/^.*_id$/', $col)) { + +// XXX still need to handle whatever "unfreeze" is going to become + $rules->add( + [$this, 'ruleValidateCO'], + 'validateCO', + ['errorField' => $col] + ); + } + } + + // The documentation is ambiguous as to whether or not we need to return $rules. + // The API docs say yes but the example doesn't have it. + return $rules; + } + + /** + * Define the list of implemented events. + * + * @since COmanage Registry v5.0.0 + * @return array Array of implemented events and associated configuration. + */ + + public function implementedEvents(): array { + return [ + 'Model.buildRules' => [ + 'callable' => 'buildRules', + // We don't currently need to set the priority +// 'priority' => -100 + ] + ]; + } + + /** + * Application Rule to prevent the CO ID of an object from being changed once + * attached. This is more of a Security Rule than an Application Rule, but for + * now we don't distinguish between the two types. + * + * This function arguably belongs in a trait or something, but then any table + * we apply it to needs to add that trait. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Entity to be validated + * @param array $options Application rule options + * @return boolean true if the Rule check passes, false otherwise + */ + + public function ruleFreezePrimaryLink(EntityInterface $entity, array $options) { + // Tables can have multiple primary link fields, but only one can be + // populated at a time, and the primary link cannot change. + + // The table we are validating, eg Name + $table = $options['repository']; + + // The field to check is (confusingly) $options['errorField']. + + if(empty($options['errorField'])) { + return __d('error', 'rule.ValidateCo.errorField'); + } + + // The foreign key we are validating + $targetField = $options['errorField']; + + $want = $entity->get($targetField); + $have = $entity->getOriginal($targetField); + + // GMR-3 The Primary Link key cannot be changed once set. If the primary + // link field goes to or from NULL throw an error. If it is NULL in both + // places, then this Primary Link is not in use for the object. + + // Changing the primary link key (eg: person_id to external_identity_id) + // is not permitted. To be clear, the _value_ CAN be changed (within the + // same CO), just not which key is being used. + + if($want === NULL && $have === NULL) { + // This primary link is not in use + return true; + } + + if($want != $have && ($want === NULL || $have === NULL)) { + // GMR-3 + $this->llog('error', "GMR-3 The Primary Link key cannot be changed once set, changing " . $table->getAlias() . " record " . $entity->id . " " . $options['errorField'] . " from " . $have . " to " . $want . " is not allowed"); + return __d('error', 'primary_link.frozen'); + } + + // GMR-1 Once an entity is created within a CO, it cannot be moved to + // another CO. + + $wantCO = $table->calculateCoForRecord($entity); + $haveCO = $table->calculateCoForRecord($entity, true); + + if($wantCO != $haveCO) { + $this->llog('error', "GMR-1 Attempt to move " . $table->getAlias() . " record " . $entity->id . " from CO " . $have . " to CO " . $want . " is not allowed"); + return __d('error', 'coid.frozen'); + } + + return true; + } + + /** + * Application Rule to require foreign keys to be within the same CO as the + * entity being saved. This is more of a Security Rule than an Application + * Rule, but for now we don't distinguish between the two types. + * + * This function arguably belongs in a trait or something, but then any table + * we apply it to needs to add that trait. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Entity to be validated + * @param array $options Application rule options + * @return boolean true if the Rule check passes, false otherwise + */ + + public function ruleValidateCO(EntityInterface $entity, array $options) { + // GMR-2 Foreign keys from one entity to another cannot cross COs. + // The logic here requires an "anchor" that cannot change, which is the + // primary link, which is enforce by ruleFreezePrimaryLink (which verifies + // that the primary object cannot be altered). + + // The field to check is (confusingly) $options['errorField']. + // We don't need to check "unfreeze" here since it should be checked in + // buildRules(). + + if(empty($options['errorField'])) { + return __d('error', 'rule.ValidateCo.errorField'); + } + + // The foreign key we are validating + $targetField = $options['errorField']; + // The property name for the fk, which following cake's internal convention + // simply drops the _id. Note this property must also be set in the Table's + // belongsTo association definition. + $targetProperty = substr($targetField, 0, strlen($targetField)-3); + // The table we are validating, eg Name + $table = $options['repository']; + + // Use the table associations to find the correct target table name + $assn = $table->associations()->getByProperty($targetProperty); + + if(empty($assn)) { + // If you're debugging this, you most likely didn't set up your + // associations correctly. + throw new \LogicException("Missing association from " . $table->getAlias(). " to $targetProperty in ruleValidateCO"); + } + + // The table holding the foreign key we are validating, eg Type + $targetTable = $assn->getTarget(); + + if(empty($entity->$targetField)) { + // If the foreign key field is blank there's nothing to check + return true; + } + + // First we need to determine the CO of this record. + + $have = $table->calculateCoForRecord($entity); + $want = $targetTable->findCoForRecord($entity->$targetField); + + if($want != $have) { + $this->llog('error', "GMR-2 Field $targetField for " . $table->getAlias() . " record " . $entity->id . " cannot cross from CO " . $have . " to CO " . $want); + return __d('error', 'rule.ValidateCo.mismatch', $targetField, $want, $have); + } + + return true; + } +} diff --git a/app/src/Lib/Random/RandomString.php b/app/src/Lib/Random/RandomString.php new file mode 100644 index 000000000..7b62dd0e2 --- /dev/null +++ b/app/src/Lib/Random/RandomString.php @@ -0,0 +1,68 @@ +autoViewVars; + } + + /** + * Set the auto view variables. + * + * @since COmanage Registry v5.0.0 + * @param array $vars Array of auto view variables + */ + + public function setAutoViewVars($vars) { + $this->autoViewVars = $vars; + } +} diff --git a/app/src/Lib/Traits/ChangelogBehaviorTrait.php b/app/src/Lib/Traits/ChangelogBehaviorTrait.php new file mode 100644 index 000000000..42bc7cc39 --- /dev/null +++ b/app/src/Lib/Traits/ChangelogBehaviorTrait.php @@ -0,0 +1,65 @@ +deleted) && $entity->deleted)) { + $table = $event->getSubject(); + + if(method_exists($table, "localAfterSave")) { + return $table->localAfterSave($event, $entity, $options); + } + } + + return true; + } +} diff --git a/app/src/Lib/Traits/CoLinkTrait.php b/app/src/Lib/Traits/CoLinkTrait.php new file mode 100644 index 000000000..7832474d8 --- /dev/null +++ b/app/src/Lib/Traits/CoLinkTrait.php @@ -0,0 +1,129 @@ +allowEmptyCO; + } + + /** + * Check to see whether the specified action is allowed to assert a CO ID + * directly (ie: not via lookup of an associated record). + * + * @since COmanage Registry v5.0.0 + * @param string $action Action + * @return boolean true if permitted, false otherwise + */ + + public function allowUnkeyedCO(string $action) { + return in_array($action, $this->unkeyedActions, true); + } + + /** + * Calculate the CO ID associated with the requested object ID. + * + * @since COmanage Registry v5.0.0 + * @param int $id Object ID + * @return int CO ID + * @throws Cake\Datasource\Exception\RecordNotFoundException + */ + + public function calculateCoId(int $id) { + // For now we assume we have a direct foreign key to Cos. + + $obj = $this->findById($id)->firstOrFail(); + + return $obj->co_id; + } + + /** + * Determine if the associated controller requires a CO ID. + * + * @since COmanage Registry v5.0.0 + * @return boolean True if a CO ID is required, false otherwise + */ + + public function requiresCO() { + return $this->requiresCO; + } + + /** + * Set if the associated controller normally requires a CO ID, whether the + * CO ID can be empty. + * + * @since COmanage Registry v5.0.0 + * @param boolean $allowEmpty True if the CO ID is permitted to be empty + */ + + public function setAllowEmptyCO(bool $allowEmpty) { + $this->allowEmptyCO = $allowEmpty; + } + + /** + * Set whether the CO can be asserted directly. + * + * @since COmanage Registry v5.0.0 + * @param boolean $allowEmpty true if the CO can be asserted directly + */ + + public function setAllowUnkeyedPrimaryCO(array $actions) { + $this->unkeyedActions = array_merge($this->unkeyedActions, $actions); + } + + /** + * Set if the associated controller requires a CO ID. + * + * @since COmanage Registry v5.0.0 + * @param boolean $required Boolean True if a Matchgrid ID is required, false otherwise + */ + + public function setRequiresCO(bool $required) { + $this->requiresCO = $required; + } +} diff --git a/app/src/Lib/Traits/EntityMetaTrait.php b/app/src/Lib/Traits/EntityMetaTrait.php new file mode 100644 index 000000000..bd8d7ddf3 --- /dev/null +++ b/app/src/Lib/Traits/EntityMetaTrait.php @@ -0,0 +1,91 @@ + $value) { + if((!isset($this->$field) && !empty($value)) // Value in $data but not $entity + || (isset($this->$field) && empty($value) // Value in $entity but not $data + && !empty($this->$field)) // ... and $entity is not falsey + || (isset($this->$field) && $this->$field != $value)) { // Values don't match + // Not a match + $match = false; + break; + } + } + + return $match; + } + + /** + * Determine the source attribute foreign key (eg: source_name_id) for this entity. + * + * @since COmanage Registry v5.0.0 + * @return string Source Attribute column name + */ + + public function sourceAttributeName() { + // The class name is something like `\App\Model\Entity\TelephoneNumber', but we + // want telephone_number (lowercased). + $entityName = Inflector::underscore(substr(strrchr(get_class($this), '\\'),1)); + + return "source_" . $entityName . "_id"; + } +} diff --git a/app/src/Lib/Traits/HistoryTrait.php b/app/src/Lib/Traits/HistoryTrait.php new file mode 100644 index 000000000..0cd903a69 --- /dev/null +++ b/app/src/Lib/Traits/HistoryTrait.php @@ -0,0 +1,192 @@ +getTableLocator()->get('Types'); + + // We want to exclude the metadata from the change string, except revision, + // which makes it easier to correlate to changelog records + $skipFields = [ + 'id', + 'created', + 'modified', + 'deleted', + 'actor_identifier' + ]; + + // Use the entity's visible field list to start from + $diffFields = array_diff($entity->getVisible(), $skipFields); + + // Remove all _id fields, except type_id + foreach($diffFields as $i => $f) { + if($f != 'type_id' && preg_match('/_id$/', $f)) { + unset($diffFields[$i]); + } + } + + // Create one string per field + $changeSet = []; + + if($entity->isNew() || $entity->deleted) { + // Generate a changeset of non-empty fields + foreach($diffFields as $field) { + if($field != 'type_id' && !is_string($field)) { + // This is a related model, skip + continue; + } + + $newValue = $entity->get($field); + + if(!empty($newValue) + // get() appears to return related entities? + && is_string($newValue)) { + if($field == 'type_id') { + $newValue = $Types->getTypeLabel((int)$newValue); + } + + $changeSet[] = $field . ": " . (string)$newValue; + } + } + } else { + // Ask the entity what changed. This will be a list of field/value pairs, + // but only where field (1) is in $diffFields and (2) changed. + $diff = $entity->extractOriginalChanged($diffFields); + + foreach(array_keys($diff) as $field) { + $oldValue = $diff[$field]; + $newValue = $entity->get($field); + + // extractOriginalChanged will return associated models, which we skip + if(is_array($oldValue) || is_array($newValue)) { + continue; + } + + if($field == 'type_id') { + $oldValue = $Types->getTypeLabel((int)$diff[$field]); + $newValue = $Types->getTypeLabel((int)$newValue); + } + + if(!empty($oldValue) || !empty($newValue)) { + $changeSet[] = $field . ": " . $oldValue . ">" . $newValue; + } + } + } + + // And finally concatenate the field strings together + return implode(';', $changeSet); + } + + /** + * Record history for an entity. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Entity to record history for + * @param string $action Action string (if null, auto-calculate from $entity) + * @param string $comment History comment (if null, auto-calculate from $entity) + * @return int HistoryRecord ID + */ + + public function recordHistory($entity, ?string $action=null, ?string $comment=null): int { + $laction = $action; + $lcomment = $comment; + + if(!$laction) { + $laction = ActionEnum::MVEAEdited; + + if($entity->isNew()) { + $laction = ActionEnum::MVEAAdded; + } elseif($entity->deleted) { + // Note this is ChangelogBehavior turning a delete to an update + $laction = ActionEnum::MVEADeleted; + } + } + + if(!$lcomment) { + $langKey = 'edited.mvea'; + + if($entity->isNew()) { + $langKey = 'added.mvea'; + } elseif($entity->deleted) { + // Note this is ChangelogBehavior turning a delete to an update + $langKey = 'deleted.mvea'; + } + + $lcomment = __d('result', + $langKey, + //Inflector::singularize($entity->getSource()), + __d('controller', $entity->getSource(), [1]), + $entity->id, + $this->changesToString($entity)); + } + + $HistoryRecords = $this->getTableLocator()->get('HistoryRecords'); + + $personId = $this->lookupPersonId($entity); + $personRoleId = $this->lookupPersonRoleId($entity); + $externalIdentityId = $this->lookupExternalIdentityId($entity); + $externalIdentityRoleId = $this->lookupExternalIdentityRoleId($entity); + $groupId = $this->lookupGroupId($entity); + + if($groupId) { + return $HistoryRecords->recordForGroup( + $groupId, + $laction, + $lcomment, + $personId + ); + } else { + return $HistoryRecords->recordForPerson( + $personId, + $laction, + $lcomment, + $personRoleId, + $externalIdentityId, + $externalIdentityRoleId + ); + } + } +} diff --git a/app/src/Lib/Traits/LabeledLogTrait.php b/app/src/Lib/Traits/LabeledLogTrait.php new file mode 100644 index 000000000..16007c6d7 --- /dev/null +++ b/app/src/Lib/Traits/LabeledLogTrait.php @@ -0,0 +1,92 @@ +llog($level, json_encode($msg, JSON_PRETTY_PRINT)); + } + + /** + * Log a message, with some standard metadata. + * + * @since COmanage Registry v5.0.0 + * @param string $level Log level + * @param string $msg Log message + */ + + public function llog(string $level, string $msg, int|string $id=null) { + $bt = debug_backtrace(0, 2); + + $m = getmypid() . " " . $bt[1]['class'] . "::" . $bt[1]['function'] + // $id, if provided, doesn't actually need to be an ID, it's just something + // that can help trace the error to a specific record or point + . (!empty($id) ? "($id)" : null) + . ": " . $msg; + + // We overload $level here, which Cake defines roughly the same way as + // syslog (alert, info, debug, notice, etc). We add two more: trace and + // rule, which we transition to scopes (defined in app.php). + + if(in_array($level, ['rule', 'trace'])) { + Log::info($m, ['scope' => [$level]]); + } else { + Log::write($level, $m); + } + } + + /** + * Print formatted cli percentage + * + * @since COmanage Registry v5.0.0 + * @param int $done Number of iterations completed + * @param string $total Total number of iterations + * @return string Formated string with line return offset + */ + + public function cliLogPercentage(int $done, int $total): void { + $perc = floor(($done / $total) * 100); + $left = 100 - $perc; + $out = sprintf("\033[0G\033[2K[%'={$perc}s>%-{$left}s] - $perc%% -- $done/$total", "", ""); + fwrite(STDOUT, $out); + } +} diff --git a/app/src/Lib/Traits/MVETrait.php b/app/src/Lib/Traits/MVETrait.php new file mode 100644 index 000000000..bf3c5b66e --- /dev/null +++ b/app/src/Lib/Traits/MVETrait.php @@ -0,0 +1,71 @@ +$sourcefk)) { + return !empty($entity->$sourcefk); + } + + return false; + } + + /** + * Generate a where clause suitable for the current entity. + * + * @since COmanage Registry v5.0.0 + * @return array Array suitable for a query's where clause + */ + + public function whereClause(): array { + if(!empty($this->person_id)) { + return [$this->getSource().'.person_id' => $this->person_id]; + } elseif(!empty($this->external_identity_id)) { + return [$this->getSource().'.external_identity_id' => $this->external_identity_id]; + } else { + throw new \InvalidArgumentException(__d('error', 'notfound.person')); + } + } +} diff --git a/app/src/Lib/Traits/PermissionsTrait.php b/app/src/Lib/Traits/PermissionsTrait.php new file mode 100644 index 000000000..32af2182c --- /dev/null +++ b/app/src/Lib/Traits/PermissionsTrait.php @@ -0,0 +1,57 @@ +permissions; + } + + /** + * Set the permissions for this model. + * + * @since COmanage Registry v5.0.0 + * @param array $vars Array of permissions + */ + + public function setPermissions(array|\Closure $perms) { + $this->permissions = $perms; + } +} diff --git a/app/src/Lib/Traits/PluggableModelTrait.php b/app/src/Lib/Traits/PluggableModelTrait.php new file mode 100644 index 000000000..5d413e979 --- /dev/null +++ b/app/src/Lib/Traits/PluggableModelTrait.php @@ -0,0 +1,177 @@ +_pluginModels + ); + } + + /** + * Instantiate a plugin model that is NOT a Cake Table model. + * + * @since COmanage Registry v5.0.0 + * @param string $pmodel Model, in Plugin.Model format + * @param string $path Path to model within Plugin, in namespace format (eg: \Lib\Jobs) + * @return object Newly instantiated object of Model class + */ + + protected function instantiatePluginModel(string $pmodel, string $path) { + $pluginName = StringUtilities::pluginPlugin($pmodel); + $pluginModel = StringUtilities::pluginModel($pmodel); + + // First check that the requested plugin is actually enabled. + // We can use Cake's check here since we would have loaded the plugin + // in Application.php already if it were enabled. + + if(!\Cake\Core\Plugin::isLoaded($pluginName)) { + throw new \InvalidArgumentException(__d('error', 'Plugins.inactive', [$pluginName])); + } + + // Next try to instantiate the model. Since instantiatePluginModel() is called for + // models that do not represent Cake Tables, we can't use the TableLocator here, + // we just use plain PHP "new". + + $pluginClassName = "\\" . $pluginName . $path . "\\" . $pluginModel; + $pClass = new $pluginClassName(); + + return $pClass; + } + + /** + * Determine if a Registry Plugin is in use, specifically if an Entry Point Model + * from the requested Plugin is in a configuration object for this Pluggable Model. + * + * @since COmanage Registry v5.0.0 + * @param string $plugin Plugin to examine + * @return ResultSet Set of configuration objects in use + */ + + public function pluginInUse(string $plugin): ResultSet { + return $this->find() + ->where(['plugin LIKE' => $plugin . ".%"]) + ->all(); + } + + /** + * Obtain the Plugin Model from an entity ID. + * + * @since COmanage Registry v5.0.0 + * @param int $id Entity ID + * @param array $options Options, as supported by get() + */ + + public function pluginModelForEntityId(int $id, array $options=[]) { + $entity = $this->get($id, $options); + $pModel = StringUtilities::pluginModel($entity->plugin); + + return $this->$pModel; + } + + /** + * Set up hasMany relations for instantiated plugin models. + * + * @since COmanage Registry v5.0.0 + */ + + protected function setPluginRelations() { + // To determine which plugin models are instantiated, we'll query the configuration + // for this pluggable model. We only need to do this once per plugin model, not + // once per instantiation. + + $models = $this->find() + ->select(['id', 'plugin']) + ->distinct(['plugin']) + ->all(); + + foreach($models as $m) { + if(empty($m->plugin) || !strstr($m->plugin, '.')) { + // This plugin is not valid. We could filter this in the find() using a + // where() clause, but checking here allows us to emit a warning. + + $this->llog('error', "Ignoring invalid plugin found in " . $this->getTable() . " record " . $m->id); + continue; + } + + // In general, a model with a "plugin" field has a 1-1 relation + // with the instantiated plugin configuration. eg: One instance + // of a Server has exactly one SqlServer associated with it. + $this->hasOne($m->plugin) + ->setDependent(true) + ->setCascadeCallbacks(true); + + // Cache the list of entry points that we found + $this->_pluginModels[] = $m->plugin; + } + + // isArtifactTable() might not be the exact right test here... + // for now, we only want to exclude Jobs (since there's nothing + // to configure) but this may change. + + if(!$this->isArtifactTable()) { + $this->setAllowLookupPrimaryLink(['configure']); + } + } +} diff --git a/app/src/Lib/Traits/PrimaryLinkTrait.php b/app/src/Lib/Traits/PrimaryLinkTrait.php new file mode 100644 index 000000000..995caca74 --- /dev/null +++ b/app/src/Lib/Traits/PrimaryLinkTrait.php @@ -0,0 +1,517 @@ + null]; + + // Accept the current CO ID? + private $acceptCoId = false; + protected $curCoId = null; + + /** + * Determine if this table accepts the CO ID via AppController. + * + * @since COmanage Registry v5.0.0 + * @return bool true if this table accepts the CO ID, false otherwise + */ + + public function acceptsCoId(): bool { + return $this->acceptCoId; + } + + /** + * Whether the primary link is permitted to be empty. + * + * @since COmanage Registry v5.0.0 + * @param boolean $allowEmpty true if the primary link is permitted to be empty + */ + + public function allowEmptyPrimaryLink() { + return $this->allowEmpty; + } + + /** + * Check to see whether the specified action allows a record ID to be passed + * in the URL, which can be used to lookup a primary link. + * + * @since COmanage Registry v5.0.0 + * @param string $action Action + * @return boolean true if permitted, false otherwise + */ + + public function allowLookupPrimaryLink(string $action) { + return in_array($action, $this->lookupActions, true); + } + + /** + * Check to see whether the specified action is allowed to assert a primary link ID + * directly (ie: not via lookup of an associated record). + * + * @since COmanage Registry v5.0.0 + * @param string $action Action + * @return boolean true if permitted, false otherwise + */ + + public function allowUnkeyedPrimaryLink(string $action) { + return in_array($action, $this->unkeyedActions, true); + } + + /** + * Determine the CO for an entity. + * + * @since COmanage Registry v5.0.0 + * @param EntityInterface $entity Entity + * @param bool $original If true, calculate based on the original value (for a dirty entity) + * @return int|null CO ID or null if not found + */ + + public function calculateCoForRecord(EntityInterface $entity, bool $original=false): ?int { + if(isset($this->primaryLinks['co_id'])) { + if(!empty($entity->co_id)) { + return ($original ? $entity->getOriginal('co_id') : $entity->get('co_id')); + } + } else { + foreach($this->primaryLinks as $linkField => $linkTable) { + $lf = $linkField; + + if(strstr($linkField, '.')) { + // Modified plugin notation ("CoreAssigners.format_assigner_id"), + // we just need the fieldname, not the full string. + + $bits = explode(".", $linkField, 2); + $lf = $bits[1]; + } + + if(!empty($entity->$lf)) { + // Use this field. Recursively ask the primaryLink until we get an answer. + $LinkTable = TableRegistry::getTableLocator()->get($linkTable); + + $linkValue = ($original ? $entity->getOriginal($lf) : $entity->get($lf)); + + return $LinkTable->findCoForRecord($linkValue); + } + } + } + + return null; + } + + /** + * Determine the CO for a record based on its ID. + * + * #since COmanage Registry v5.0.0 + * @param int $id Record ID + * @return int|null CO ID or null if not found + * @throws Cake\Datasource\Exception\RecordNotFoundException + */ + + public function findCoForRecord(int $id): ?int { + // Pull the object to examine the primary links. We might be asked to find the + // CO for a deleted object (eg: to add a history record, or to show an older + // value for an entity), so accept archived records. + return $this->calculateCoForRecord($this->get($id, ['archived' => true])); + } + + /** + * Generate an ORM Query for the Primary Link. + * + * @since COmanage Registry v5.0.0 + * @param CakeORMQuery $query Cake ORM Query + * @param array $options Cake ORM Query options + * @return CakeORMQuery Cake ORM Query + */ + + public function findFilterPrimaryLink(\Cake\ORM\Query $query, array $options) { + return $query->where([$this->getPrimaryLink() => $options[$this->primaryLink]]); + } + + /** + * Find the Primary Link associated with the requested object ID. + * + * @since COmanage Registry v5.0.0 + * @param int $id Object ID + * @param bool $archived Whether to retrieve archived (deleted) records + * @return object Primary Link information (as an object) + * @throws \InvalidArgumentException + */ + + public function findPrimaryLink(int $id, bool $archived=false): object { + $obj = $this->get($id, ['archived' => $archived]); //->firstOrFail(); + + // We might have multiple primary link keys (eg for MVEAs), but only one + // should be set. Return the first one we find. + foreach(array_keys($this->primaryLinks) as $plKey) { + if(!empty($obj->$plKey)) { + // If this Primary Link points to a plugin, add a hint for the callter + $plugin = null; + + if(strstr($this->primaryLinks[$plKey], '.')) { + $plugin = \App\Lib\Util\StringUtilities::pluginPlugin($this->primaryLinks[$plKey]); + } + + return (object)[ + 'plugin' => $plugin, + 'attr' => $plKey, + 'value' => $obj->$plKey, + 'co_id' => $this->calculateCoForRecord($obj) + ]; + } + } + + throw new \InvalidArgumentException(__d('error', 'primary_link')); + } + + /** + * Find the Primary Link for an entity. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Entity + * @return Entity Primary Link (as an Entity) + * @throws \InvalidArgumentException + */ + + public function findPrimaryLinkEntity($entity) { + foreach(array_keys($this->primaryLinks) as $plKey) { + if(!empty($entity->$plKey)) { + $LinkTable = TableRegistry::getTableLocator()->get($this->primaryLinks[$plKey]); + + return $LinkTable->findById($entity->$plKey)->firstOrFail(); + } + } + + throw new \InvalidArgumentException(__d('error', 'primary_link')); + } + + /** + * Obtain the primary link fields. + * + * @since COmanage Registry v5.0.0 + * @return array Primary link attributes + */ + + public function getPrimaryLinks(): array { + return array_keys($this->primaryLinks); + } + + /** + * Obtain the primary link's table name. + * + * @since COmanage Registry v5.0.0 + * @param string $primaryLink Primary Link field + * @return string Primary link table name + */ + + public function getPrimaryLinkTableName(string $primaryLink): string { + return $this->primaryLinks[$primaryLink]; + } + + /** + * Obtain this table's redirect goal. + * + * @since COmanage Registry v5.0.0 + * @param string $action Action + * @return string Redirect goal + */ + + public function getRedirectGoal(string $action): ?string { + return $this->redirectGoal[$action] ?? $this->redirectGoal['*']; + } + + /** + * Determine the External Identity ID associated with an entity. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Entity + * @return ?int Person ID + */ + + public function lookupExternalIdentityId($entity): ?int { + // We need to see if 'external_identity_id' exists on the $entity, but it's + // not easy. We can't use property_exists() because Cake is dynamically + // getting. The Cake Entity API documentation says we should be able to call + // $entity->__isset(), but that doesn't actually implement the documented + // behavior. (Github issue: https://github.com/cakephp/cakephp/issues/16408) + // So we have to extract the key and then use array_key_exists() (but NOT isset()). + + $a = $entity->extract(['external_identity_id']); + + if($entity->getSource() == 'ExternalIdentities') { + return $entity->id; + } elseif(array_key_exists('external_identity_id', $a)) { + // We want to return here whether or not the key is set since if it's NULL + // we're not directly pointing to an External Identity. We can't use + // property_exists because Cake is dynamically getting. + + return $entity->external_identity_id; + } else { + $linkEntity = $this->findPrimaryLinkEntity($entity); + + if(!empty($linkEntity->external_identity_id)) { + return $linkEntity->external_identity_id; + } + } + + return null; + } + + /** + * Determine the External Identity Role ID associated with an entity. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Entity + * @return int Person Role ID + */ + + public function lookupExternalIdentityRoleId($entity): ?int { + $a = $entity->extract(['external_identity_role_id']); + + if($entity->getSource() == 'ExternalIdentityRoles') { + return $entity->id; + } elseif(array_key_exists('external_identity_role_id', $a)) { + return $entity->external_identity_role_id; + } + + return null; + } + + /** + * Determine the Group ID associated with an entity. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Entity + * @return int Group ID + */ + + public function lookupGroupId($entity): ?int { + $a = $entity->extract(['group_id']); + + if($entity->getSource() == 'Groups') { + return $entity->id; + } elseif(array_key_exists('group_id', $a)) { + return $entity->group_id; + } + + return null; + } + + /** + * Determine the Person ID associated with an entity. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Entity + * @return ?int Person ID + */ + + public function lookupPersonId($entity): ?int { + $a = $entity->extract(['person_id']); + + if($entity->getSource() == 'People') { + return $entity->id; + } elseif(array_key_exists('person_id', $a) + // MVEAs can have multiple parent keys, but not all of them may be set + && !empty($entity->person_id)) { + return $entity->person_id; + } else { + $linkEntity = $this->findPrimaryLinkEntity($entity); + + if(!empty($linkEntity->person_id)) { + return $linkEntity->person_id; + } else { + // Our parent link does not directly point to Person, so try recursing + // on our parent table, though we might also not have a parent that points + // to a Person (eg Group -> Co). + + $LinkTable = TableRegistry::getTableLocator()->get($linkEntity->getSource()); + + if(method_exists($LinkTable, "lookupPersonId")) { + return $LinkTable->lookupPersonId($linkEntity); + } + } + } + + return null; + } + + /** + * Determine the Person Role ID associated with an entity. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Entity + * @return int Person Role ID + */ + + public function lookupPersonRoleId($entity): ?int { + $a = $entity->extract(['person_role_id']); + + if($entity->getSource() == 'PersonRoles') { + return $entity->id; + } elseif(array_key_exists('person_role_id', $a)) { + return $entity->person_role_id; + } + + return null; + } + + /** + * Set whether this table accepts a CO ID, set by AppController. In general, + * tables should NOT use this unless there is no other way to get the CO ID. + * In general, it is preferable to accept the CO ID as a function argument, + * or by calling findCoForRecord or calculateCoForRecord. This functionality + * is for contexts like setting validation rules, where passing in the CO ID + * normally is not possible. + * + * @since COmanage Registry v5.0.0 + * @param bool $accepts true if this table accepts the CO ID, false otherwise + */ + + public function setAcceptsCoId(bool $accepts) { + $this->acceptCoId = $accepts; + } + + /** + * Set whether the primary link is permitted to be empty. + * + * @since COmanage Registry v5.0.0 + * @param boolean $allowEmpty true if the primary link is permitted to be empty + */ + + public function setAllowEmptyPrimaryLink(bool $allowEmpty) { + $this->allowEmpty = $allowEmpty; + } + + /** + * Set whether the primary link can be resolved via the object ID in the URL. + * + * @since COmanage Registry v5.0.0 + * @param array $actions Actions where the primary link can be obtained by looking up the record ID + */ + + public function setAllowLookupPrimaryLink(array $actions) { + $this->lookupActions = array_merge($this->lookupActions, $actions); + } + + /** + * Set which actions permit a primary link to be passed as a request parameter. + * Defaults to [add, index]. + * + * @since COmanage Registry v5.0.0 + * @param array $actions Array of actions that permit unkeyed primary links. + */ + + public function setAllowUnkeyedPrimaryLink(array $actions) { + $this->unkeyedActions = array_merge($this->unkeyedActions, $actions); + } + + /** + * Set the current CO ID. Intended for use with AppController. + * + * @since COmanage Registry v5.0.0 + * @param int $coId CO ID + */ + + public function setCurCoId(int $coId) { + $this->curCoId = $coId; + } + + /** + * Set the primary link attribute. + * + * @since COmanage Registry v5.0.0 + * @param mixed $field Primary link attribute, or an array of primary links + */ + + public function setPrimaryLink($fields) { + if(is_string($fields)) { + $fields = [$fields]; + } + + foreach($fields as $field) { + $t = null; + + // Calculate the table name for future reference. This could just be + // a simple reference ("person_id" => "People") or it could be in + // plugin notation ("CoreAssigner.format_assigner_id" => "CoreAssigner.FormatAssigners"). + // Note the plugin notation isn't exactly standard (Plugin.field doesn't make sense + // except that we inflect it to something that does). + + if(preg_match('/^(.*)\.(.*?)_id$/', $field, $f)) { + // Modified plugin notation match + $t = $f[1] . "." . \Cake\Utility\Inflector::camelize(\Cake\Utility\Inflector::pluralize($f[2])); + // We need the key to be the field name, not Plugin.field + $this->primaryLinks[$f[2]."_id"] = $t; + } elseif(preg_match('/^(.*?)_id$/', $field, $f)) { + // Standard foreign key match + $t = \Cake\Utility\Inflector::camelize(\Cake\Utility\Inflector::pluralize($f[1])); + $this->primaryLinks[$field] = $t; + } else { + $this->primaryLinks[$field] = null; + } + } + } + + /** + * Set the redirect goal for this table. + * + * @since COmanage Registry v5.0.0 + * @param string $goal Redirect goal ('index', 'pluggableLink', 'primaryLink', 'self', 'special') + * @param string $action Action to set goal for ('*' for default) + * @throws InvalidArgumentException + */ + + public function setRedirectGoal(string $goal, string $action='*') { + if(!in_array($goal, ['index', 'pluggableLink', 'primaryLink', 'self', 'special'])) { + throw new \InvalidArgumentException(__d('error', 'invalid', [$goal])); + } + + $this->redirectGoal[$action] = $goal; + } +} diff --git a/app/src/Lib/Traits/ProvisionableTrait.php b/app/src/Lib/Traits/ProvisionableTrait.php new file mode 100644 index 000000000..367dfbec6 --- /dev/null +++ b/app/src/Lib/Traits/ProvisionableTrait.php @@ -0,0 +1,88 @@ +marshalProvisioningData($id); + + // Invocation of the plugins is handled by the Pluggable table + $ProvisioningTargets = TableRegistry::getTableLocator()->get('ProvisioningTargets'); + + $ProvisioningTargets->provision( + data: $data['data'], + eligibility: $data['eligibility'], + context: $context, + id: $provisioningTargetId + ); + } else { + // This is a secondary model, eg Names. We need to figure out the primary model + // and then request provisioning on that one instead. + + // We need to explicitly look at archived records here. A deleted record + // may point to a valid primary object. + + $primaryLink = $this->findPrimaryLink(id: $id, archived: true); + + $parentTableName = StringUtilities::foreignKeyToClassName($primaryLink->attr); + + $this->$parentTableName->requestProvisioning( + id: $primaryLink->value, + context: $context, + provisioningTargetId: $provisioningTargetId + ); + } + } +} \ No newline at end of file diff --git a/app/src/Lib/Traits/ProvisionerTrait.php b/app/src/Lib/Traits/ProvisionerTrait.php new file mode 100644 index 000000000..e229e679f --- /dev/null +++ b/app/src/Lib/Traits/ProvisionerTrait.php @@ -0,0 +1,69 @@ +provisonableModels; + } + + /** + * Determine if the requested Model is supported by this Provisioner. + * + * @since COmanage Registry v5.0.0 + * @param string $model Model to check + * @return bool True if $model is supported, false otherwise + */ + + public function isProvisionableModel(string $model): bool { + return in_array($model, $this->provisionableModels); + } + + /** + * Set the supported Provisionable Models. + * + * @since COmanage Registry v5.0.0 + * @param array $models Array of supported Provisionable Models + */ + + public function setProvisionableModels(array $models) { + $this->provisionableModels = $models; + } +} diff --git a/app/src/Lib/Traits/QueryModificationTrait.php b/app/src/Lib/Traits/QueryModificationTrait.php new file mode 100644 index 000000000..acdd704bb --- /dev/null +++ b/app/src/Lib/Traits/QueryModificationTrait.php @@ -0,0 +1,182 @@ +duplicateContains; + } + + /** + * Obtain the set of associated models to pull during an edit. + * + * @since COmanage Registry v5.0.0 + * @return array Array of associated models + */ + + public function getEditContains() { + return $this->editContains; + } + + /** + * Containable models for index actions. + * + * @since COmanage Registry v5.0.0 + * @param boolean $allowEmpty true if the primary link is permitted to be empty + */ + + public function getIndexContains() { + return $this->indexContains; + } + + /** + * Obtain the index filter for this model. + * + * @since COmanage Registry v5.0.0 + * @return array|Closure Array of index filters or closure that generates an array + */ + + public function getIndexFilter(): array|\Closure|null { + return $this->indexFilter; + } + + /** + * Obtain the set of associated models to save during a patch. + * + * @since COmanage Registry v5.0.0 + * @return array Array of associated models + */ + + public function getPatchAssociated() { + return $this->patchAssociated; + } + + /** + * Obtain the set of associated models to pull during a view. + * + * @since COmanage Registry v5.0.0 + * @return array Array of associated models + */ + + public function getViewContains() { + return $this->viewContains; + } + + /** + * Set the associated models to copy during a duplicate. + * + * @since COmanage Registry v5.0.0 + * @param array $c Array of associated models + */ + + public function setDuplicateContains(array $c) { + $this->duplicateContains = $c; + } + + /** + * Set the associated models to pull during an edit. + * + * @since COmanage Registry v5.0.0 + * @param array $c Array of associated models + */ + + public function setEditContains(array $c) { + $this->editContains = $c; + } + + /** + * Set containable models for index actions. + * + * @since COmanage Registry v5.0.0 + * @param array $contains Containable models + */ + + public function setIndexContains(array $contains) { + $this->indexContains = $contains; + } + + /** + * Set the index filter for this model. + * + * @since COmanage Registry v5.0.0 + * @param array|Closure $filter Array of index filters or closure that generates the array + */ + + public function setIndexFilter(array|\Closure $filter) { + $this->indexFilter = $filter; + } + + /** + * Set the associated models to save during a patch. + * + * @since COmanage Registry v5.0.0 + * @param array $a Array of associated models + */ + + public function setPatchAssociated(array $a) { + $this->patchAssociated = $a; + } + + /** + * Set the associated models to pull during a view. + * + * @since COmanage Registry v5.0.0 + * @param array $c Array of associated models + */ + + public function setViewContains(array $c) { + $this->viewContains = $c; + } +} diff --git a/app/src/Lib/Traits/ReadOnlyEntityTrait.php b/app/src/Lib/Traits/ReadOnlyEntityTrait.php new file mode 100644 index 000000000..0673af91f --- /dev/null +++ b/app/src/Lib/Traits/ReadOnlyEntityTrait.php @@ -0,0 +1,63 @@ +isMVEReadOnly()) { + return true; + } + + // Frozen attributes are treated as Read Only + if($this->frozen) { + return true; + } + + // Records flagged as deleted or with a parent foreign key are read only + + // The class name is something like `\App\Model\Entity\PersonRole', but we just + // want person_role (lowercased). + $entityName = \Cake\Utility\Inflector::underscore(substr(strrchr(get_class($this), '\\'),1)); + $parentfk = $entityName . "_id"; + + return (isset($entity->deleted) && $entity->deleted) + || !empty($entity->$parentfk); + } +} diff --git a/app/src/Lib/Traits/SearchFilterTrait.php b/app/src/Lib/Traits/SearchFilterTrait.php new file mode 100644 index 000000000..70ffd5e18 --- /dev/null +++ b/app/src/Lib/Traits/SearchFilterTrait.php @@ -0,0 +1,207 @@ +filterConfig; + } + + /** + * Obtain the set of permitted search attributes. + * + * @since COmanage Registry v5.0.0 + * @return array Array of permitted search attributes and configuration elements needed for display + */ + + public function getSearchableAttributes(string $controller, string $vv_tz=null): array { + $modelname = Inflector::classify(Inflector::underscore($controller)); + $filterConfig = $this->getFilterConfig(); + + // Gather up related models defined in the $filterConfig + // XXX For now, we'll list these first - but we should probably provide a better way to order these. + foreach ($filterConfig as $field => $f) { + if($f['type'] == 'relatedModel') { + $fieldName = Inflector::classify(Inflector::underscore($field)); + $this->searchFilters[$field] = [ + 'type' => 'string', // XXX for now - this needs to be looked up. + 'label' => \App\Lib\Util\StringUtilities::columnKey($fieldName, $field, $vv_tz, true), + 'active' => isset($f['active']) ? $f['active'] : true, + 'model' => $f['model'], + 'order' => $f['order'] + ]; + } + } + + foreach ($this->filterMetadataFields() as $column => $type) { + // If the column is an array then we are accessing the Metadata fields. Skip + if(is_array($type)) { + continue; + } + + // Set defaults + $fieldIsActive = true; + + // Gather filter configurations, if any, for local table fields. + // An active field is visible in the filter form. An inactive field is not but can be enabled. + if(!empty($filterConfig[$column])) { + if(isset($filterConfig[$column]['active'])) { + $fieldIsActive = $filterConfig[$column]['active']; + } + } + + $this->searchFilters[$column] = [ + 'type' => $type, + 'label' => \App\Lib\Util\StringUtilities::columnKey($modelname, $column, $vv_tz, true), + 'active' => $fieldIsActive, + 'order' => 99 // this is the default + ]; + + // For the date fields we search ranges + if($type === 'timestamp') { + $this->searchFilters[$column]['alias'][] = $column . '_starts_at'; + $this->searchFilters[$column]['alias'][] = $column . '_ends_at'; + } + } + + return $this->searchFilters ?? []; + } + + /** + * Set explicilty defined filter configuration defined in the table class. + * + * @since COmanage Registry v5.0.0 + */ + + public function setFilterConfig(array $filterConfig): void { + $this->filterConfig = $filterConfig; + } + + /** + * Build a query where() clause for the configured attribute. + * + * @param \Cake\ORM\Query $query Cake ORM Query object + * @param string $attribute Attribute to filter on (database name) + * @param string|array $q Value to filter on + * + * @return \Cake\ORM\Query Cake ORM Query object + * @since COmanage Registry v5.0.0 + */ + + public function whereFilter(\Cake\ORM\Query $query, string $attribute, string|array $q): object { + // not a permitted attribute + if(empty($this->searchFilters[$attribute])) { + return $query; + } + + if(isset($this->searchFilters[$attribute]['model'])) { + $changelog_fk = strtolower(Inflector::underscore($this->searchFilters[$attribute]['model'])) . '_id'; + $fk = strtolower(Inflector::underscore(Inflector::singularize($this->_alias))) . '_id'; + $mtable_name = Inflector::tableize(Inflector::pluralize($this->searchFilters[$attribute]['model'])); + $mtable_alias = Inflector::pluralize($this->searchFilters[$attribute]['model']); + $query->join([$mtable_alias => [ + 'table' => $mtable_name, + 'conditions' => [ + $mtable_alias . '.' . $fk . '=' . $this->_alias . '.id', + $mtable_alias . '.' . 'deleted IS NOT TRUE', + $mtable_alias . '.' . $changelog_fk . ' IS NULL' + ], + 'type' => 'INNER' + ]]); + } + + // Prepend the Model name to the attribute + $attributeWithModelPrefix = isset($this->searchFilters[$attribute]['model']) ? + Inflector::pluralize($this->searchFilters[$attribute]['model']) . '.' . $attribute : + $this->_alias . '.' . $attribute; + + $search = $q; + $sub = false; + // Primitive types + $search_types = ['integer', 'boolean']; + if( $this->searchFilters[$attribute]['type'] == "string") { + $search = "%" . $search . "%"; + $sub = true; + // Search type + } elseif(in_array($this->searchFilters[$attribute]['type'], $search_types, true)) { + return $query->where([$attributeWithModelPrefix => $search]); + // Date + } elseif($this->searchFilters[$attribute]['type'] == "date") { + // Parse the date string with FrozenTime to improve error handling + return $query->where([$attributeWithModelPrefix => FrozenTime::parseDate($search, 'y-M-d')]); + // Timestamp + } elseif( $this->searchFilters[$attribute]['type'] == "timestamp") { + // Date between dates + if(!empty($search[0]) + && !empty($search[1])) { + return $query->where(function (\Cake\Database\Expression\QueryExpression $exp, \Cake\ORM\Query $query) use ($attributeWithModelPrefix, $search) { + return $exp->between($attributeWithModelPrefix, "'" . $search[0] . "'", "'" . $search[1] . "'"); + }); + // The starts at is non-empty. So the data should be greater than the starts_at date + } elseif(!empty($search[0]) + && empty($search[1])) { + return $query->where(function (\Cake\Database\Expression\QueryExpression $exp, \Cake\ORM\Query $query) use ($attributeWithModelPrefix, $search) { + return $exp->gte("'" . FrozenTime::parse($search[0]) . "'", $attributeWithModelPrefix); + }); + // The ends at is non-empty. So the data should be less than the ends at date + } elseif(!empty($search[1]) + && empty($search[0])) { + return $query->where(function (\Cake\Database\Expression\QueryExpression $exp, \Cake\ORM\Query $query) use ($attributeWithModelPrefix, $search) { + return $exp->lte("'" . FrozenTime::parse($search[1]) . "'", $attributeWithModelPrefix); + }); + } else { + // We return everything + return $query; + } + + } + + // String values + return $query->where(function (\Cake\Database\Expression\QueryExpression $exp, \Cake\ORM\Query $query) use ($attributeWithModelPrefix, $search, $sub) { + $lower = $query->func()->lower([$attributeWithModelPrefix => 'identifier']); + return ($sub) ? $exp->like($lower, strtolower($search)) + : $exp->eq($lower, strtolower($search)); + }); + } +} diff --git a/app/src/Lib/Traits/TableMetaTrait.php b/app/src/Lib/Traits/TableMetaTrait.php new file mode 100644 index 000000000..e7590c0f7 --- /dev/null +++ b/app/src/Lib/Traits/TableMetaTrait.php @@ -0,0 +1,148 @@ +getSchema()->typeMap(); + $entity = $this->getEntityClass(); + $entity_namespace = explode('\\', $entity); + $modelName = end($entity_namespace); + + // Get the list of belongs_to associations and construct an exclude array + $assc_keys = []; + foreach ($this->associations() as $assc) { + if($assc->type() === "manyToOne") { + $assc_keys[] = Inflector::underscore(Inflector::classify($assc->getClassName())) . "_id"; + } + } + // Map the model (eg: Person) to the changelog key (person_id) + $mfk = Inflector::underscore($modelName) . "_id"; + + $meta_fields = [ + ...$assc_keys, + $mfk, + 'actor_identifier', + // 'provisioning_target_id', + 'created', // todo: I might need to revisit this. We might want to filter according to date in some occassions. Like petitions + 'deleted', + 'id', + 'modified', + 'revision', + 'lft', // XXX For now i skip lft.rght column for tree structures + 'rght', + // 'parent_id', // todo: We need to filter using the parent_id. This should be an enumerator and should apply for all the models that use TreeBehavior + 'api_key', + // XXX maybe replace this with a regex, source_*_id? + 'source_ad_hoc_attribute_id', + 'source_address_id', + 'source_email_address_id', + 'source_external_identity_id', + 'source_identifier_id', + 'source_name_id', + 'source_pronoun_id', + 'source_telephone_number_id', + 'source_url_id' + ]; + + $newa = array(); + foreach($coltype as $clmn => $type) { + if(in_array($clmn, $meta_fields,true)) { + // Move the value to metadata + $newa['meta'][$clmn] = $type; + } else { + // Just copy the value + $newa[$clmn] = $type; + } + } + + return $newa ?? []; + } + + /** + * Determine if this Table represents Registry artifacts. + * + * @since COmanage Registry v5.0.0 + * @return bool True if this Table represents artifact data, false otherwise + */ + + public function isArtifactTable() { + return $this->tableType === TableTypeEnum::Artifact; + } + + /** + * Determine if this Table represents Registry configuration. + * + * @since COmanage Registry v5.0.0 + * @return bool True if this Table represents Configuration data, false otherwise + */ + + public function isConfigurationTable() { + return $this->tableType === TableTypeEnum::Configuration; + } + + /** + * Set the type of this Table. + * + * @since COmanage Registry v5.0.0 + * @param TableTypeEnum $tableType Table Type + */ + + public function setTableType(string $tableType) { + $this->tableType = $tableType; + } + + /** + * Determine the source foreign key attribute for this table, for tables that + * have Pipelined attributes from External Identities to People. + * + * @since COmanage Registry v5.0.0 + * @return string Source name field (eg: source_name_id) + */ + + public function sourceForeignKey(): string { + return "source_" . Inflector::underscore(StringUtilities::tableToEntityName($this)) . "_id"; + } +} diff --git a/app/src/Lib/Traits/TypeTrait.php b/app/src/Lib/Traits/TypeTrait.php new file mode 100644 index 000000000..21906e9bc --- /dev/null +++ b/app/src/Lib/Traits/TypeTrait.php @@ -0,0 +1,86 @@ +getTableLocator()->get("Types"); + + $query = $Types->find('list', [ + 'keyField' => 'value', + 'valueField' => 'display_name' + ]) + ->where(['co_id' => $coId, + 'attribute' => $attribute, + 'status' => SuspendableStatusEnum::Active]) + ->order(['Types.display_name' => 'ASC']); + + return $query->toArray(); + } + + /** + * Obtain the default (out of the box) types for this model. + * + * @since COmanage Registry v5.0.0 + * @param string $attribute Attribute to obtain default types for + * @return array Array of default types and their default strings + * @throws InvalidArgumentException + */ + + public function defaultTypes(string $attribute) { + $ret = []; + + if(!isset($this->defaultTypes[$attribute])) { + throw new \InvalidArgumentException(__d('error', 'invalid', [$attribute])); + } + + // eg: "Name" + foreach($this->defaultTypes[$attribute] as $t) { + // Map to localized text string + $ret[$t] = __d('defaultType', $this->getAlias().'.'.$attribute.'.'.$t); + } + + return $ret; + } +} diff --git a/app/src/Lib/Traits/ValidationTrait.php b/app/src/Lib/Traits/ValidationTrait.php new file mode 100644 index 000000000..2f987761b --- /dev/null +++ b/app/src/Lib/Traits/ValidationTrait.php @@ -0,0 +1,351 @@ +add($pk, [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString($pk, null, function($context) use ($pk, $primaryKeys) { + // This primary key must be populated (and this closure returns true) + // if all other primary keys are empty + $othersEmpty = true; + + foreach(array_diff($primaryKeys, [$pk]) as $opk) { + $othersEmpty &= empty($context['data'][$opk]); + } + + return $othersEmpty; + }); + } + + return $validator; + } + + /** + * Register validation rules for the provided field, as a string. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Cake Validator + * @param TableSchemaInterface $schema Cake Schema + * @param string $field Field name + * @param bool $required Whether this field is required + * @param string $prefix Require the value to start with $prefix + * @param bool $validateInput Whether to appli the validateInput rule + * @return Validator Cake Validator + */ + + public function registerStringValidation( + Validator $validator, + TableSchemaInterface $schema, + string $field, + bool $required, + string $prefix = '', + bool $validateInput = true + ): Validator { + $rules = [ + 'size' => ['rule' => ['validateMaxLength', ['column' => $schema->getColumn($field)]], + 'provider' => 'table'] + ]; + + if($validateInput) { + $rules['filter'] = ['rule' => ['validateInput'], + 'provider' => 'table']; + } + + if(!empty($prefix)) { + $rules['prefix'] = [ + 'rule' => ['validatePrefix'], + 'pass' => [$prefix], + 'provider' => 'table' + ]; + } + + $validator->add($field, $rules); + + if($required) { + $validator->notEmptyString($field); + } else { + $validator->allowEmptyString($field); + } + + return $validator; + } + + /** + * Verify that $value is a valid + * + * @since COmanage Registry v5.0.0 + * @param string $value Value to validate + * @param array $context Validation context + * @return mixed True if $value validates, or an error string otherwise + */ + + public function validateCO($value, array $context) { + // Verify that $value is a valid record in the current CO + + // We read the CO ID as set in Configure via AppController. Alternately, we + // could create an event listener (like ChangelogEventListener) that listens + // for buildValidator and injects the CO ID into the Validator object, but + // then every Table's validationDefault() would need to parse the CO ID and + // inject it into the validation configuration, which seems like a lot of + // extra work. + + // The CO of the record we are validating + $thisCoId = null; + + // The CO of the record we are pointing to, via the field being validated + $targetCoId = null; + + if(!empty($context['data']['co_id'])) { + // Accept the co_id in the request data + $thisCoId = $context['data']['co_id']; + } elseif(!empty($context['data']['id'])) { + // We can use findCoForRecord to get the CO in context + $thisCoId = $this->findCoForRecord($context['data']['id']); + } elseif(method_exists($this, 'getPrimaryLink') + && $this->getPrimaryLink() != null + && !empty($context['data'][$this->getPrimaryLink()])) { + // This is probably a new record being added (which we could verify via + // $context['newRecord']). We can't directly use findCoForRecord, but + // we can use the primary link to get the CO. + + $LinkTable = $this->getPrimaryLinkTable(); + + $thisCoId = $LinkTable->findCoForRecord((int)$context['data'][$this->getPrimaryLink()]); + } else { + return __d('error', 'coid'); + } + + if(!$thisCoId) { + return __d('error', 'coid'); + } + + // Calculate the table name for the requested field + if(preg_match('/^(.*?)_id$/', $context['field'], $f)) { + $tableName = \Cake\Utility\Inflector::camelize(\Cake\Utility\Inflector::pluralize($f[1])); + + $Table = TableRegistry::getTableLocator()->get($tableName); + + $targetCoId = $Table->findCoForRecord((int)$value); + } + + if(!$targetCoId) { + return __d('error', 'coid'); + } + + if($thisCoId != $targetCoId) { + return __d('error', 'coid.mismatch', [$this->name, $value]); + } + + return true; + } + + /** + * Perform a conditional validation check, where if a select value matches an + * array of values, then another field must not be empty. In theory we should be + * able to do this with Cake native conditional validation, but if a field is + * not required and null, then Cake doesn't run validation on the value. + * + * To configure this validation rule, use setProvider(): + * + * $validator->setProvider('conditionalRequire', [ + * 'inArray' => [valuesThatRequireOtherField], + * 'require' => 'field_to_require', + * 'label' => 'field label, for use in error message']); + * + * Note this validation rule is applied on the value that must always be set, + * which will result in the validation error being unintuitively placed on the + * "wrong" attribute. + * + * @since COmanage Registry v5.0.0 + * @param string $value Value to validate + * @param array $context Validation context + * @return mixed True if $value validates, or an error string otherwise + */ + + public function validateConditionalRequire($value, array $context) { + if(!empty($value) + && in_array($value, $context['providers']['conditionalRequire']['inArray']) + && empty($context['data'][ $context['providers']['conditionalRequire']['require'] ])) { + return __d('error', 'input.condreq', [$context['providers']['conditionalRequire']['label']]); + } + + return true; + } + + /** + * Determine if a string submitted from a form is valid input. + * + * @since COmanage Registry v5.0.0 + * @param string $value Value to validate + * @param array $context Validation context + * @return mixed True if $value validates, or an error string otherwise + */ + + public function validateInput($value, array $context) { + // By default, we'll accept anything except < and >. Arguably, we should accept + // anything at all for input (and filter only on output), but this was agreed to + // as an extra "line of defense" against unsanitized HTML output, since there are + // currently no known cases where user-entered input should permit angle brackets. + +// XXX we previously supported 'flags' and 'invalidchars' as arguments, do we still need to? +// CFM-152 review the logic here + + if(!empty($context['filter'])) { + // We use filter_var for consistency with the views, and simply check + // that we end up with the same string we started with. + + $filtered = filter_var($value, $context['filter']); + + if($value != $filtered) { + // Mismatch, implying bad input + return __d('error', 'input.invalid'); + } + } else { + // Perform a basic string search. + + $invalid = "<>"; + + if(strlen($value) != strcspn($value, $invalid)) { + // Mismatch, implying bad input + return __d('error', 'input.invalid.2'); + } + + // We require at least one non-whitespace character (CO-1551) + if(!preg_match('/\S/', $value)) { + return __d('error', 'input.blank'); + } + } + + return true; + } + + /** + * Validate the maximum length of a field. + * + * @since COmanage Registry v5.0.0 + * @param string $value Value to validate + * @param array $context Validation context, which must include the schema definition + * @return mixed True if $value validates, or an error string otherwise + */ + + public function validateMaxLength($value, array $context) { + // We use our own so we can introspect the field's max length from the + // provided table schema object, and use our own error message (without + // having to copy it to every table definition). + + $maxLength = $context['column']['length']; + + if(!empty($value) && strlen($value) > $maxLength) { + return __d('error', 'input.length', [$maxLength]); + } + + return true; + } + + /** + * Determine if a string submitted from a form is valid SQL identifier. + * + * @since COmanage Registry v5.0.0 + * @param string $value Value to validate + * @param array $context Validation context + * @return mixed True if $value validates, or an error string otherwise + */ + + public function validateSqlIdentifier($value, array $context) { + // Valid (portable) SQL identifiers begin with a letter or underscore, and + // subsequent characters can also include digits. We'll be a little stricter + // than we need to be for now by only accepting A-Z, when in fact certain + // additional characters (like á) are also acceptable. + + if(!preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $value)) { + return __d('error', 'input.invalid'); + } + + return true; + } + + /** + * Determine if a string submitted from a form is a valid timezone. + * + * @since COmanage Registry v5.0.0 + * @param string $value Value to validate + * @param array $context Validation context + * @return mixed True if $value validates, or an error string otherwise + */ + + public function validateTimeZone($value, array $context) { + if(!in_array($value, array_values(timezone_identifiers_list()))) { + return __d('error', 'input.invalid'); + } + + return true; + } + + /** + * Determine if a string submitted from a form has a valid prefix. + * + * @since COmanage Registry v5.0.0 + * @param string $value Value to validate + * @param string $prefix Prefix value + * @param array $context Validation context + * @return mixed True if $value validates, or an error string otherwise + */ + + public function validatePrefix(string $value, string $prefix, array $context) { + $coid = $context['data']['co_id'] ?? ''; + if($prefix === "co_id") { + $prefix = !empty($coid) ? "co_" . $coid . "." : ""; + } + + if (!preg_match('/^' . $prefix . '(?:.*)/m', $value)) { + return __d('error', 'input.invalid.prefix', [$prefix]); + } + + return true; + } +} diff --git a/app/src/Lib/Util/PaginatedSqlIterator.php b/app/src/Lib/Util/PaginatedSqlIterator.php new file mode 100644 index 000000000..f39b3ad8c --- /dev/null +++ b/app/src/Lib/Util/PaginatedSqlIterator.php @@ -0,0 +1,206 @@ +table = $table; + $this->conditions = $conditions; + $this->options = $options; + + $this->position = 0; + } + + /** + * Obtain the current element of the iteration. + * + * @since COmanage Registry v3.3.0 + * @return mixed Element at the current position + */ + + public function current(): mixed { + return $this->results[$this->position]; + } + + /** + * Obtain the current count of records. + * + * @since COmanage Registry v3.3.0 + * @param bool $refresh Refresh the count rather than returning the cached count + * @return int Record count + */ + + public function count(bool $refresh=false): int { + if($this->count === null || $refresh) { + $this->loadCount(); + } + + return $this->initialCount; + } + + /** + * Obtain the current position of the iteration. + * + * @since COmanage Registry v3.3.0 + * @return int The current position + */ + + public function key(): int { + return $this->position; + } + + /** + * Obtain the count of records. + * + * @since COmanage Registry v3.3.0 + */ + + protected function loadCount(): void { + $query = $this->table->find(); + + if($this->conditions) { + $query = $query->where($this->conditions); + } + + $this->initialCount = $query->count(); + } + + /** + * Obtain the next page of results (releasing the current page). + * + * @since COmanage Registry v3.3.0 + */ + + protected function loadPage(): void { + unset($this->results); + $this->results = null; + + $this->position = 0; + + $query = $this->table->find('all', $this->options) + ->where([$this->keyField . ' >' => $this->maxid]); + + if($this->conditions) { + $query = $query->where($this->conditions); + } + + $query = $query->order([$this->keyField => 'ASC']) + ->limit($this->pageSize) + // We always request exactly one page, starting from $this->maxid. + // We don't use Cake's pagination because the resultset could + // change between calls, and the keyset technique ensures we + // always get the full set (since newer records will have + // higher IDs). + ->page(1); + + $resultSet = $query->all(); + + // Use the ResultSet to determine the maximum ID. Since we ordered by + // id we know the highest value is in the last result. + $max = $resultSet->last(); + + if($max) { + $this->maxid = $max->id; + } + // else no remaining rows. valid() will return false. + + // Convert the result set to an array for our own iterator use + $this->results = $resultSet->toArray(); + } + + /** + * Move to the next record in the iteration. + * + * @since COmanage Registry v3.3.0 + */ + + public function next(): void { + $this->position++; + + if($this->position >= count($this->results)) { + // We've reached the end of the current page, retrieve the next page of results + $this->loadPage(); + } + } + + /** + * Rewind to the first record in the iteration. (This is called by PHP on initialization.) + * + * @since COmanage Registry v3.3.0 + */ + + public function rewind(): void { + $this->maxid = 0; + + $this->loadPage(); + } + + /** + * Determine if the current position is valid. + * + * @since COmanage Registry v3.3.0 + * @return boolean True if the current position is valid, false otherwise + */ + + public function valid(): bool { + return !empty($this->results[$this->position]); + } +} diff --git a/app/src/Lib/Util/SchemaManager.php b/app/src/Lib/Util/SchemaManager.php new file mode 100644 index 000000000..dabc5d8f9 --- /dev/null +++ b/app/src/Lib/Util/SchemaManager.php @@ -0,0 +1,363 @@ +io = $io; + + // Use the ConnectionManager to get the database config to pass to DBAL. + $db = ConnectionManager::get($connection); + + // $db is a ConnectionInterface object + $cfg = $db->config(); + + $config = new \Doctrine\DBAL\Configuration(); + + $cfargs = [ + 'dbname' => $cfg['database'], + 'user' => $cfg['username'], + 'password' => $cfg['password'], + 'host' => $cfg['host'], + 'driver' => ($cfg['driver'] == 'Cake\Database\Driver\Postgres' ? "pdo_pgsql" : "mysqli") + ]; + + // For MySQL SSL + if(!empty($cfg['ssl_ca'])) { + $cfargs['ssl_ca'] = $cfg['ssl_ca']; + } + + if($this->io) $this->io->out("Connecting to database " . $cfg['database'] . " as " + . $cfg['username'] . "@" . $cfg['host']); + + $this->conn = DriverManager::getConnection($cfargs, $config); + $this->driver = $cfg['driver']; + } + + /** + * Apply a schema file. + * + * @since COmanage Registry v5.0.0 + * @param string $schemaFile Schema file to apply + * @param bool $parseOnly If true, attempt to parse the file only, but perform no other actions + * @param bool $diffOnly If true, generate a diff against the current database state, but do not apply it + * @param string $tablePrefix String to prefix to table names + */ + + public function applySchemaFile( + string $schemaFile, + bool $parseOnly=false, + bool $diffOnly=false, + string $tablePrefix="" + ) { + if(!is_readable($schemaFile)) { + throw new \RuntimeException(__d('error', 'file', [$schemaFile])); + } + + $this->llog('debug', __d('command', 'db.schema', [$schemaFile])); + + $json = file_get_contents($schemaFile); + + $schemaConfig = json_decode($json); + + if(!$schemaConfig) { + // json_last_error[_msg]() are pretty useless. If you are debugging here, + // it's most likely because of one of the following: + // - An unmatched brace { } + // - A trailing comma (permitted in PHP but not JSON) + // - Single quotes instead of double quotes + throw new \RuntimeException(__d('error', 'schema.parse', [$schemaFile])); + } + + // If there is a column library (which should only be in the main config), + // cache it since plugins may reference it + if(!empty($schemaConfig->columnLibrary)) { + $this->columnLibrary = $schemaConfig->columnLibrary; + } + + if(!$parseOnly) { + $this->processSchema(schemaConfig: $schemaConfig, diffOnly: $diffOnly); + } + } + + /** + * Apply an already parsed schema object. + * + * @since COmanage Registry v5.0.0 + * @param object $schemaObject Schema object + * @param string $tablePrefix String to prefix to table names + */ + + public function applySchemaObject(object $schemaObject, string $tablePrefix="") { + if(!$this->columnLibrary) { + // We need the column library from the core config + $this->applySchemaFile(schemaFile: ROOT . DS . 'config' . DS . 'schema' . DS . 'schema.json', + parseOnly: true); + } + + $this->processSchema(schemaConfig: $schemaObject, tablePrefix: $tablePrefix); + } + + /** + * Process a schema object. + * + * @since COmanage Registry v5.0.0 + * @param object $schemaConfig Schema object + * @param bool $diffOnly If true, generate a diff against the current database state, but do not apply it + * @param string $tablePrefix String to prefix to table names + */ + + protected function processSchema( + object $schemaConfig, + bool $diffOnly=false, + string $tablePrefix="" + ) { + $schema = new Schema(); + + // Walk through $schemaConfig and build our schema in DBAL format. + + foreach($schemaConfig->tables as $tName => $tCfg) { + $table = $schema->createTable($tablePrefix.$tName); + + foreach($tCfg->columns as $cName => $cCfg) { + // We allow "inherited" definitions from the fieldLibrary, so merge together + // the configurations (if appropriate) + + $colCfg = (object)array_merge((isset($this->columnLibrary->columns->$cName) + ? (array)$this->columnLibrary->columns->$cName + : []), + (array)$cCfg); + + if(!isset($colCfg->type)) { + throw new \RuntimeException(__d('error', 'schema.column', [$tName, $cName])); + } + + // For type definitions see https://www.doctrine-project.org/projects/doctrine-dbal/en/2.12/reference/types.html#types + $options = []; + + if(isset($colCfg->autoincrement)) { + $options['autoincrement'] = $colCfg->autoincrement; + } + + if($colCfg->type == "string") { + $options['length'] = $colCfg->size; + } + + if(isset($colCfg->notnull)) { + $options['notnull'] = $colCfg->notnull; + } else { + $options['notnull'] = false; + } + + $table->addColumn($cName, $colCfg->type, $options); + + if(isset($colCfg->primarykey) && $colCfg->primarykey) { + $table->setPrimaryKey(["id"]); + } + + if(isset($colCfg->foreignkey)) { + $table->addForeignKeyConstraint($tablePrefix.$colCfg->foreignkey->table, + [$cName], + [$colCfg->foreignkey->column], + [], + // We name our foreign keys the same way they + // were previously named by adodb + $tablePrefix.$tName . "_" . $cName . "_fkey"); + } + } + + // (For Registry) If MVEA models are specified, emit the appropriate + // columns and indexes. MVEA attributes must be added before indexes, in + // case the table has composite indexes referencing MVEA columns. + + if(!empty($tCfg->mvea)) { + $i = 1; + + foreach($tCfg->mvea as $m) { + $mColumn = $m . "_id"; + $fkTable = \Cake\Utility\Inflector::tableize($m); + + // Insert a foreign key to this model and index it + $table->addColumn($mColumn, "integer", ['notnull' => false]); + $table->addForeignKeyConstraint($tablePrefix.$fkTable, [$mColumn], ['id'], [], $tablePrefix.$tName . "_" . $mColumn . "_fkey"); + $table->addIndex([$mColumn], $tablePrefix.$tName . "_im" . $i++); + } + + // MVEA tables also support frozen flags + $table->addColumn("frozen", "boolean", ['notnull' => false]); + } + + if(isset($tCfg->indexes)) { + // We don't autogenerate names for indexes so if the definition of an index + // changes DBAL can just rebuild that index instead of recreating every index + // on the table. (This should speed up schema updates vs ADOdb.) This does + // require each index to be named in the schema file, but we had to do that + // in axmls too, even though it rebuilt every index every time through. + + foreach($tCfg->indexes as $iName => $iCfg) { + // $flags and $options as passed to Index(), but otherwise undocumented + $flags = []; + $options = []; + + if(isset($iCfg->unique) && $iCfg->unique) { + $table->addUniqueConstraint($iCfg->columns, $iName, $flags, $options); + } else { + $table->addIndex($iCfg->columns, $iName, $flags, $options); + } + } + } + + // (For Registry) If an attribute is "sourced" it is a CO Person attribute + // that is copied via a Pipeline from an External Identity that was created from + // an External Identity Source, so we need a foreign key into ourself. + + if(isset($tCfg->sourced) && $tCfg->sourced) { + $sColumn = "source_" . $tablePrefix.\Cake\Utility\Inflector::singularize($tName) . "_id"; + + // Insert a foreign key to this model and index it + $table->addColumn($sColumn, "integer", ['notnull' => false]); + $table->addForeignKeyConstraint($tablePrefix.$tName, [$sColumn], ['id'], [], $tablePrefix.$tName . "_" . $sColumn . "_fkey"); + $table->addIndex([$sColumn], $tablePrefix.$tName . "_im" . $i++); + } + + // Default is to insert timestamp and changelog fields, unless disabled + + if(!isset($tCfg->timestamps) || $tCfg->timestamps) { + // Insert Cake metadata fields + $table->addColumn("created", "datetime"); + $table->addColumn("modified", "datetime", ['notnull' => false]); + } + + if(!isset($tCfg->changelog) || $tCfg->changelog) { + // Insert ChangelogBehavior metadata fields + $clColumn = \Cake\Utility\Inflector::singularize($tName) . "_id"; + $table->addColumn($clColumn, "integer", ['notnull' => false]); + $table->addColumn("revision", "integer", ['notnull' => false]); + $table->addColumn("deleted", "boolean", ['notnull' => false]); + $table->addColumn("actor_identifier", "string", ['length' => 256, 'notnull' => false]); + + $table->addForeignKeyConstraint($table, [$clColumn], ['id'], [], $tName . "_" . $clColumn . "_fkey"); + $table->addIndex([$clColumn], $tablePrefix.$tName . "_icl", [], []); + } + } + + // This is the SQL that represents the desired state of the database + $toSql = $schema->toSql($this->conn->getDatabasePlatform()); + + // SchemaManager provides info about the database + $sm = $this->conn->createSchemaManager(); + + // The is the current database representation + $curSchema = $sm->createSchema(); + + $fromSql = $curSchema->toSql($this->conn->getDatabasePlatform()); + + try { + // We manually call compare so we can get the SchemaDiff object. We need + // this for toSaveSql(), which we use to avoid dropping undocumented tables + // (like the matchgrids, which are dynamically created and so won't be in the + // schema file). + $comparator = new Comparator(); + $schemaDiff = $comparator->compareSchemas($curSchema, $schema); + + $diffSql = $schemaDiff->toSaveSql($this->conn->getDatabasePlatform()); + + // We don't start a transaction since in general we always want to move to + // the desired state, and if we fail in flight it's probably a bug that + // needs to be fixed. + + foreach($diffSql as $sql) { + if($this->io) $this->io->out($sql); + + if($this->driver == 'Cake\Database\Driver\Postgres' + && preg_match("/^DROP SEQUENCE [a-z]*_id_seq/", $sql)) { + // Remove the DROP SEQUENCE statements in $fromSql because they're Postgres automagic + // being misinterpreted. (Note toSaveSql might mask this now.) + // XXX Maybe debug and file a PR to not emit DROP SEQUENCE on PG for autoincrementesque fields? + if($this->io) $io->out("Skipping sequence drop"); + } else { + if(!$diffOnly) { + $stmt = $this->conn->executeQuery($sql); + // $stmt just returns the query string so we don't bother examining it + } + } + } + + $this->alog('debug', $diffSql); + } + catch(\Exception $e) { + if($this->io) $this->io->out($e->getMessage()); + else throw new \RuntimeException($e->getMessage()); + } + + // We might run bin/cake schema_cache clear or + // bin/cake schema_cache build --connection default + // but so far we don't have an example indicating it's needed. + } +} \ No newline at end of file diff --git a/app/src/Lib/Util/StringUtilities.php b/app/src/Lib/Util/StringUtilities.php new file mode 100644 index 000000000..5b9249846 --- /dev/null +++ b/app/src/Lib/Util/StringUtilities.php @@ -0,0 +1,333 @@ +get($modelPath); + $msgId = "{$action}.a"; + + if(Inflector::singularize(self::entityToClassName($entity)) !== Inflector::singularize($modelsName)) { + $linkTable = TableRegistry::getTableLocator()->get(self::entityToClassName($entity)); + // if the modelPath and the action are equal then we skip the concatenation + $msgId = $modelPath === $action ? $modelPath : "{$modelPath}.{$action}"; + } + + if($action === null) { + return [__d('controller', $modelsName), '', '']; + } + + // Index view + if($action === 'index') { + // 99 is the default for plural + return [__d('controller', $modelsName, [99]), '', '']; + } + + // Add/Edit/View + if(method_exists($linkTable, 'generateDisplayField')) { + // We don't use a trait for this since each table will implement different logic + + $title = __d($domain, $msgId, $linkTable->generateDisplayField($entity)); + $supertitle = $linkTable->generateDisplayField($entity); + // Pass the display field also into subtitle for dealing with External IDs + $subtitle = $linkTable->generateDisplayField($entity); + } else { + // Default view title is edit object display field + $field = $linkTable->getDisplayField(); + + if(!empty($entity->$field)) { + $title = __d($domain, $msgId, $entity->$field); + } else { + $title = __d($domain, $msgId, __d('controller', $modelsName, [1])); + } + } + + return [$title, $supertitle, $subtitle]; + } + + /** + * Determine the class name from a foreign key (eg: report_id -> Reports). + * + * @since COmanage Registry v5.0.0 + * @param string $s Foreign Key name + * @return string Class name + */ + + public static function foreignKeyToClassName(string $s): string { + return Inflector::camelize(Inflector::pluralize(substr($s, 0, strlen($s)-3))); + } + + /** + * Localize a controller name, accounting for plugins. + * + * @since COmanage Registry v5.0.0 + * @param string $controllerName Name of controller to localize + * @param string $pluginName Plugin name, if appropriate + * @param bool $plural Whether to use plural localization + * @return string Localized text string + */ + + public static function localizeController(string $controllerName, ?string $pluginName, bool $plural=false): string { + if($pluginName) { + // Localize via plugin + return __d(Inflector::underscore($pluginName), 'controller.'.$controllerName, [$plural ? 99 : 1]); + } else { + // Standard localization + + return __d('controller', $controllerName, [$plural ? 99 : 1]); + } + } + + /** + * Determine the model component of a Plugin path. + * + * @since COmanage Registry v5.0.0 + * @param string $s Plugin path, in Plugin.Model format. + * @return string Model name + */ + + public static function pluginModel(string $s): string { + $bits = explode('.', $s, 2); + + return $bits[1]; + } + + /** + * Determine the plugin component of a Plugin path. + * + * @since COmanage Registry v5.0.0 + * @param string $s Plugin path, in Plugin.Model format. + * @return string Plugin name + */ + + public static function pluginPlugin(string $s): string { + $bits = explode('.', $s, 2); + + return $bits[0]; + } + + /** + * Determine the Entity name from a Table object. + * + * @since COmanage Registry v5.0.0 + * @param Table $table Cake Table object + * @return string Entity name (eg: Report) + */ + + public static function tableToEntityName($table): string { + $classPath = $table->getEntityClass(); + + return substr($classPath, strrpos($classPath, '\\')+1); + } + + /** + * Determine the foreign key name to point to a Cake Entity (eg: foo_id for FooTable). + * + * @since COmanage Registry v5.0.0 + * @param Table $table Table + * @return string Foreign key name + */ + + public static function tableToForeignKey($table): string { + // $classPath will be something like App\Model\Entity\Name, but we want to return "name_id" + $classPath = $table->getEntityClass(); + + return Inflector::underscore(Inflector::singularize(substr($classPath, strrpos($classPath, '\\')+1))) . "_id"; + } + + // The following two utilities provide base64 encoding and decoding for + // strings that might contain special characters that could interfere with + // URLs. base64 can generate reserved characters, so we handle those specially + // according to common (but not standardized) conventions. See CO-1667 and + // https://stackoverflow.com/questions/1374753/passing-base64-encoded-strings-in-url + // The mapping we use is the same as the YUI library. RFC 4648 base64url is + // another option, but strangely doesn't map the padding character (=). + + /** + * base64 decode a string. + * + * @since COmanage Registry v5.0.0 + * @param string $s String to decode + * @return string Decoded string + */ + + public static function urlbase64decode(string $s): string { + return !empty($s) + ? base64_decode(str_replace(array(".", "_", "-"), + array("+", "/", "="), + $s)) + : ""; + } + + /** + * base64 encode a string. + * + * @since COmanage Registry v5.0.0 + * @param string $s String to encode + * @return string Encoded string + */ + + public static function urlbase64encode(string $s): string { + return !empty($s) + ? str_replace(array("+", "/", "="), + array(".", "_", "-"), + base64_encode($s)) + : ""; + } +} diff --git a/app/src/Model/Behavior/ChangelogBehavior.php b/app/src/Model/Behavior/ChangelogBehavior.php new file mode 100644 index 000000000..be2afabee --- /dev/null +++ b/app/src/Model/Behavior/ChangelogBehavior.php @@ -0,0 +1,200 @@ +getSubject(); + $alias = $subject->getAlias(); + + LogBehavior::strace($alias, 'Changelog converting delete to update'); + + // Since we stop the delete event, we need to manually trigger cascades. + // Note Cake defaults to delete via deleteAll(), which skips callbacks, + // which means we wouldn't be called. Models need to declare "cascadeCallbacks" to + // true in association definitions. + // XXX though this will slow hard delete, which doesn't need it... + + $subject->associations()->cascadeDelete($entity, $options->getArrayCopy()); + + // Update this record as deleted + + $entity->deleted = true; + $subject->saveOrFail($entity, ['checkRules' => false, 'archive' => false]); + + // Stop the delete from actually happening + $event->stopPropagation(); + + // But return success + return true; + } + + /** + * Adjust find query conditions for changelog. + * + * @since COmanage Registry v5.0.0 + * @param Event $event The beforeFind event + * @param Query $query Query + * @param ArrayObject $options The options for the query + * @param boolean $primary Whether or not this is the root query (vs an associated query) + */ + + public function beforeFind(\Cake\Event\Event $event, \Cake\ORM\Query $query, \ArrayObject $options, bool $primary) { + if(isset($options['archived']) && $options['archived']) { + // Archived records requested (including possiblf expunge), so just return + return true; + } + + $subject = $event->getSubject(); + $table = $subject->getTable(); + $alias = $subject->getAlias(); + $parentfk = Inflector::singularize($table) . "_id"; + + LogBehavior::strace($alias, 'Changelog altering find conditions'); + + // XXX add support for archived, revision, etc + // XXX if specific id is requested, do not modify query + + // We use IS NOT TRUE to check for null || false, since pre-Changelog data + // may have null instead of false. + // (Alternately we could join two clauses for false || IS NULL.) +// Transmogrification will backfill this... do we still need it? Are there any tables +// that will be not-changelog but might become changelog? + $query->where([$alias . '.deleted IS NOT true']) + ->where([$alias . '.' . $parentfk . ' IS NULL']); + + // XXX need to also check parent key IS NULL + } + + /** + * Handle changelog archive during (before) save of object. + * + * @since COmanage Registry v5.0.0 + * @param Event $event The beforeSave event + * @param EntityInterface $entity Entity + * @param ArrayObject $options Options + */ + + public function beforeSave(\Cake\Event\Event $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options) { + // XXX prevent updates to deleted and archived records + // Cake Book suggests doing this with Application Rules... can we define those in the Behavior? + // or perhaps in beforeMarshal? https://book.cakephp.org/3.0/en/orm/saving-data.html#modifying-request-data-before-building-entities + + if(isset($options['archive']) && !$options['archive']) { + // Archiving disabled for this request, don't do anything + return; + } + + $subject = $event->getSubject(); + $table = $subject->getTable(); + $alias = $subject->getAlias(); + $parentfk = Inflector::singularize($table) . "_id"; + + $actor = ''; + + if(!empty($options['actor'])) { + // The actor information is passed via $options, which is populated by ChangelogEventListener + + if(isset($options['apiuser']) && $options['apiuser']) { + $actor = 'API:'; + } + + $actor .= $options['actor']; + + // Make sure the string fits + $actor = substr($actor, 0, 256); + } + + if($entity->isNew()) { + // This is an add, just set default metadata + + LogBehavior::strace($alias, 'Changelog setting default changelog metadata on add'); + $entity->deleted = false; + $entity->revision = 0; + $entity->actor_identifier = $actor; + + return; + } else { + // This is an edit, so copy on write + + LogBehavior::strace($alias, 'Changelog creating archive copy of record ' . $entity->id); + +// XXX start a transaction that gets finished in afterSave +// we're normally already in a transaction (if atomic), maybe we don't need to manage another one? + $class = get_class($entity); + + // We disable setters because we want an exact copy of the original record + $archive = new $class($entity->getOriginalValues(), ['useSetters' => false]); + + // Update the appropriate attributes + unset($archive->id); + $archive->$parentfk = $entity->id; + + // We actually want to update the *current* record with $actor, not the + // archived copy (which was presumably updated by the previous $actor) + $entity->actor_identifier = $actor; + + // We also increment the revision on the entity, not the archive + $entity->revision++; + + // Cake 3+ doesn't have callbacks=false, so we use the archive flag so we + // don't recurse indefinitely. We also skip validation in case (eg) validation + // rules changed since the original record was created. + $subject->saveOrFail($archive, [ + 'checkRules' => false, + 'archive' => false, + // We don't want to save associated models by default since + // it will rekey them to the new archive copy. + 'associated' => false + ]); + + return; + } + } +} \ No newline at end of file diff --git a/app/src/Model/Behavior/LogBehavior.php b/app/src/Model/Behavior/LogBehavior.php new file mode 100644 index 000000000..99df65398 --- /dev/null +++ b/app/src/Model/Behavior/LogBehavior.php @@ -0,0 +1,68 @@ +getSubject(); + $label = getmypid() . "/" . $subject->getAlias() . ": "; +// XXX can we inject IP address of requester (where available)? + + Log::debug($label . 'beforeFind: ' . $query->sql(), ['scope' => ['trace']]); + } + +// XXX don't define log() since it will collide with LogTrait? + + public function trace(string $msg) { + + } + + public static function strace(string $name, string $msg) { + $label = getmypid() . "/" . $name . ": "; + + Log::debug($label . $msg, ['scope' => ['trace']]); + } +} \ No newline at end of file diff --git a/app/src/Model/Behavior/OrderableBehavior.php b/app/src/Model/Behavior/OrderableBehavior.php new file mode 100644 index 000000000..917bec5bb --- /dev/null +++ b/app/src/Model/Behavior/OrderableBehavior.php @@ -0,0 +1,66 @@ +getSubject(); + + $query = $Table->find(); + $query->select(['maxorder' => $query->func()->max('ordr', ['ordr'])]); + + $row = $query->first(); + + if(!empty($row->maxorder)) { + $data['ordr'] = $row->maxorder+1; + } else { + $data['ordr'] = 1; + } + } + } +} \ No newline at end of file diff --git a/app/src/Model/Behavior/TimezoneBehavior.php b/app/src/Model/Behavior/TimezoneBehavior.php new file mode 100644 index 000000000..17f9306ad --- /dev/null +++ b/app/src/Model/Behavior/TimezoneBehavior.php @@ -0,0 +1,87 @@ +tz + * is set. (The API is expected to provide times in UTC.) For rendering, + * FieldHelper::control() and Standard index.php will adjust back to the local + * timezone. + * + * @since COmanage Registry v5.0.0 + * @param Event $event beforeMarshal event + * @param ArrayObject $data Entity data + * @param ArrayObject $options Callback options + */ + + public function beforeMarshal(Event $event, ArrayObject $data, ArrayObject $options) { + if($this->tz && $this->tz != 'UTC') { + // There's some Cake support for doing timezone conversion, but it's not really + // well documented, so we use PHP calls directly. + + $localTZ = new \DateTimeZone($this->tz); + + foreach($this->fields as $f) { + if(!empty($data[$f])) { + // This returns a DateTime object adjusting for localTZ + $offsetDT = new \DateTime($data[$f], $localTZ); + + // strftime converts a timestamp according to server localtime (which should be UTC) + $data[$f] = strftime("%F %T", $offsetDT->getTimestamp()); + } + } + } + } + + /** + * Set the current timezone. + * + * @since COmanage Registry v5.0.0 + * @param string $tz Timezone, eg as determined by AppController::beforeFilter + */ + + public function setTimeZone(string $tz) { + $this->tz = $tz; + } +} \ No newline at end of file diff --git a/app/src/Model/Entity/AdHocAttribute.php b/app/src/Model/Entity/AdHocAttribute.php new file mode 100644 index 000000000..66c1922b2 --- /dev/null +++ b/app/src/Model/Entity/AdHocAttribute.php @@ -0,0 +1,44 @@ + true, + 'id' => false, + 'slug' => false, + ]; +} \ No newline at end of file diff --git a/app/src/Model/Entity/Address.php b/app/src/Model/Entity/Address.php new file mode 100644 index 000000000..f58bc0d80 --- /dev/null +++ b/app/src/Model/Entity/Address.php @@ -0,0 +1,44 @@ + true, + 'id' => false, + 'slug' => false, + ]; +} \ No newline at end of file diff --git a/app/src/Model/Entity/ApiUser.php b/app/src/Model/Entity/ApiUser.php new file mode 100644 index 000000000..63abe5ab3 --- /dev/null +++ b/app/src/Model/Entity/ApiUser.php @@ -0,0 +1,66 @@ + true, + 'id' => false, + 'slug' => false, + // By default, we don't want to allow api_key to be set directly over the API + // (or via the UI, but we control that by not exposing a field). Only generate() + // can set api_key. (AR-ApiUser-4) + 'api_key' => false + ]; + + /** + * Hash (bcrypt) an API Key on save. + * + * @since COmanage Registry v5.0.0 + * @param string $apiKey Unhashed API Key + * @return string Hashed API Key + */ + + protected function _setApiKey($apiKey) { + // Note setters are disabled by ChangelogBehavior in order to prevent (eg) + // rehashing the hash on archive. + + if(!empty($apiKey)) { + return (new DefaultPasswordHasher)->hash($apiKey); + } + + return false; + } +} \ No newline at end of file diff --git a/app/src/Model/Entity/AuthenticationEvent.php b/app/src/Model/Entity/AuthenticationEvent.php new file mode 100644 index 000000000..ea77fe225 --- /dev/null +++ b/app/src/Model/Entity/AuthenticationEvent.php @@ -0,0 +1,53 @@ + true, + 'id' => false, + 'slug' => false + ]; + + /** + * Determine if this entity is Read Only. + * + * @since COmanage Registry v5.0.0 + * @return boolean True if the entity is read only, false otherwise + */ + + public function isReadOnly(): bool { + // All Authentication Events are read only. + + return true; + } +} \ No newline at end of file diff --git a/app/src/Model/Entity/Co.php b/app/src/Model/Entity/Co.php new file mode 100644 index 000000000..20ffd8933 --- /dev/null +++ b/app/src/Model/Entity/Co.php @@ -0,0 +1,78 @@ + true, + 'id' => false, + 'slug' => false, + ]; + + /** + * Determine if this CO is Active. + * + * @since COmanage Registry v5.0.0 + * @return bool true if the CO is Active, false otherwise + */ + + public function isActive(): bool { + return $this->status == SuspendableStatusEnum::Active; + } + + /** + * Determine if this entity is the COmanage CO. + * + * @since COmanage Registry v5.0.0 + * @return bool true if this entity is the COmanage CO, false otherwise + */ + + public function isCOmanageCO(): bool { + return (strtolower($this->name) == 'comanage'); + } + + /** + * Determine if this entity is Read Only. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Cake Entity + * @return boolean true if the entity is read only, false otherwise + */ + + public function isReadOnly(): bool { + // The COmanage CO is read only + + return $this->isCOmanageCO(); + } +} \ No newline at end of file diff --git a/app/src/Model/Entity/CoSetting.php b/app/src/Model/Entity/CoSetting.php new file mode 100644 index 000000000..7b1d08a05 --- /dev/null +++ b/app/src/Model/Entity/CoSetting.php @@ -0,0 +1,89 @@ + true, + 'id' => false, + 'slug' => false, + ]; + + /** + * Obtain the set of fields required for addresses, as an array. + * + * @since COmanage Registry v5.0.0 + * @return array Array of required addresses fields + */ + + public function address_required_fields_array(): array { + return explode(",", $this->required_fields_address); + } + + /** + * Obtain the set of fields permitted for names, as an array. + * + * @since COmanage Registry v5.0.0 + * @return array Array of permitted name fields + */ + + public function name_permitted_fields_array(): array { + return explode(",", $this->permitted_fields_name); + } + + /** + * Obtain the set of fields required for names, as an array. + * + * @since COmanage Registry v5.0.0 + * @return array Array of required name fields + */ + + public function name_required_fields_array(): array { + return explode(",", $this->required_fields_name); + } + + /** + * Obtain the set of fields permitted for telephone numbers, as an array. + * + * @since COmanage Registry v5.0.0 + * @return array Array of permitted telephone number fields + */ + + public function telephone_number_permitted_fields_array(): array { + return explode(",", $this->permitted_fields_telephone_number); + } +} \ No newline at end of file diff --git a/app/src/Model/Entity/Cou.php b/app/src/Model/Entity/Cou.php new file mode 100644 index 000000000..a494edb1f --- /dev/null +++ b/app/src/Model/Entity/Cou.php @@ -0,0 +1,42 @@ + true, + 'id' => false, + 'slug' => false, + ]; +} \ No newline at end of file diff --git a/app/src/Model/Entity/Dashboard.php b/app/src/Model/Entity/Dashboard.php new file mode 100644 index 000000000..1f68e2dc7 --- /dev/null +++ b/app/src/Model/Entity/Dashboard.php @@ -0,0 +1,42 @@ + true, + 'id' => false, + 'slug' => false, + ]; +} \ No newline at end of file diff --git a/app/src/Model/Entity/EmailAddress.php b/app/src/Model/Entity/EmailAddress.php new file mode 100644 index 000000000..99640e435 --- /dev/null +++ b/app/src/Model/Entity/EmailAddress.php @@ -0,0 +1,44 @@ + true, + 'id' => false, + 'slug' => false, + ]; +} \ No newline at end of file diff --git a/app/src/Model/Entity/ExtIdentitySourceRecord.php b/app/src/Model/Entity/ExtIdentitySourceRecord.php new file mode 100644 index 000000000..85dfc7783 --- /dev/null +++ b/app/src/Model/Entity/ExtIdentitySourceRecord.php @@ -0,0 +1,44 @@ + true, + 'id' => false, + 'slug' => false, + ]; +} \ No newline at end of file diff --git a/app/src/Model/Entity/ExternalIdentity.php b/app/src/Model/Entity/ExternalIdentity.php new file mode 100644 index 000000000..b9b01a340 --- /dev/null +++ b/app/src/Model/Entity/ExternalIdentity.php @@ -0,0 +1,42 @@ + true, + 'id' => false, + 'slug' => false, + ]; +} \ No newline at end of file diff --git a/app/src/Model/Entity/ExternalIdentityRole.php b/app/src/Model/Entity/ExternalIdentityRole.php new file mode 100644 index 000000000..aeafe661e --- /dev/null +++ b/app/src/Model/Entity/ExternalIdentityRole.php @@ -0,0 +1,42 @@ + true, + 'id' => false, + 'slug' => false, + ]; +} \ No newline at end of file diff --git a/app/src/Model/Entity/ExternalIdentitySource.php b/app/src/Model/Entity/ExternalIdentitySource.php new file mode 100644 index 000000000..13ac3780a --- /dev/null +++ b/app/src/Model/Entity/ExternalIdentitySource.php @@ -0,0 +1,42 @@ + true, + 'id' => false, + 'slug' => false, + ]; +} \ No newline at end of file diff --git a/app/src/Model/Entity/Group.php b/app/src/Model/Entity/Group.php new file mode 100644 index 000000000..b960892c3 --- /dev/null +++ b/app/src/Model/Entity/Group.php @@ -0,0 +1,127 @@ + true, + 'id' => false, + 'slug' => false, + ]; + + /** + * Determine if this entity record can be deleted. + * + * @since COmanage Registry v5.0.0 + * @return bool True if the record can be deleted, false otherwise + */ + + public function canDelete(): bool { + return !$this->isSystem(); + } + + /** + * Determine if this is the All Members group. + * + * @since COmanage Registry v5.0.0 + * @return bool true if this is the All Members group, false otherwise. + */ + + public function isAllMembers(): bool { + return $this->group_type == GroupTypeEnum::AllMembers; + } + + /** + * Determine if this is an automatic group. + * + * @since COmanage Registry v5.0.0 + * @return bool true if this is an automatic group, false otherwise. + */ + + public function isAutomatic(): bool { + return in_array($this->group_type, [GroupTypeEnum::ActiveMembers, GroupTypeEnum::AllMembers]); + } + + /** + * Determine if this is an owners group. + * + * @since COmanage Registry v5.0.0 + * @return bool true if this is an owners group, false otherwise. + */ + + public function isOwners(): bool { + return $this->group_type == GroupTypeEnum::Owners; + } + + /** + * Determine if this entity is a system group. + * + * @since COmanage Registry v5.0.0 + * @return bool true if this entity is automatically managed, false otherwise + */ + + public function isSystem(): bool { + return in_array($this->group_type, + [ + GroupTypeEnum::ActiveMembers, + GroupTypeEnum::Admins, + GroupTypeEnum::AllMembers, + GroupTypeEnum::Owners + ]); + } + + /** + * Determine if this entity is Read Only. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Cake Entity + * @return boolean true if the entity is read only, false otherwise + */ + + public function isReadOnly(): bool { + // Automatic groups are read-only + + return $this->isAutomatic(); + } + + /** + * Determine if this is not an automatic group. + * + * @since COmanage Registry v5.0.0 + * @return bool true if this is not an automatic group, false otherwise. + */ + + public function notAutomatic(): bool { + return !$this->isAutomatic(); + } +} \ No newline at end of file diff --git a/app/src/Model/Entity/GroupMember.php b/app/src/Model/Entity/GroupMember.php new file mode 100644 index 000000000..21471edf6 --- /dev/null +++ b/app/src/Model/Entity/GroupMember.php @@ -0,0 +1,50 @@ + true, + 'id' => false, + 'slug' => false, + ]; + + /** + * Determine if this Group Membership is valid, meaning it has validity dates + * that are current. + */ + + public function isValid(): bool { + return (!$this->valid_from || $this->valid_from->isPast()) + && (!$this->valid_through || $this->valid_through->isFuture()); + } +} \ No newline at end of file diff --git a/app/src/Model/Entity/GroupNesting.php b/app/src/Model/Entity/GroupNesting.php new file mode 100644 index 000000000..5034d0c10 --- /dev/null +++ b/app/src/Model/Entity/GroupNesting.php @@ -0,0 +1,40 @@ + true, + 'id' => false, + 'slug' => false, + ]; +} \ No newline at end of file diff --git a/app/src/Model/Entity/HistoryRecord.php b/app/src/Model/Entity/HistoryRecord.php new file mode 100644 index 000000000..71df1779f --- /dev/null +++ b/app/src/Model/Entity/HistoryRecord.php @@ -0,0 +1,42 @@ + true, + 'id' => false, + 'slug' => false, + ]; +} \ No newline at end of file diff --git a/app/src/Model/Entity/Identifier.php b/app/src/Model/Entity/Identifier.php new file mode 100644 index 000000000..bc231646d --- /dev/null +++ b/app/src/Model/Entity/Identifier.php @@ -0,0 +1,55 @@ + true, + 'id' => false, + 'slug' => false, + ]; + + /** + * Determine if this Identifier is flagged as a login identifier. + * + * @since COmanage Registry v5.0.0 + * @return bool true if this is a login identifier, false otherwise + */ + + public function isLogin(): bool { + return $this->login ?? false; + } +} \ No newline at end of file diff --git a/app/src/Model/Entity/IdentifierAssignment.php b/app/src/Model/Entity/IdentifierAssignment.php new file mode 100644 index 000000000..aef39cae9 --- /dev/null +++ b/app/src/Model/Entity/IdentifierAssignment.php @@ -0,0 +1,42 @@ + true, + 'id' => false, + 'slug' => false, + ]; +} \ No newline at end of file diff --git a/app/src/Model/Entity/Job.php b/app/src/Model/Entity/Job.php new file mode 100644 index 000000000..7441775ca --- /dev/null +++ b/app/src/Model/Entity/Job.php @@ -0,0 +1,67 @@ + true, + 'id' => false, + 'slug' => false, + ]; + + /** + * Determine if this entity can be canceled. + * + * @since COmanage Registry v5.0.0 + * @return bool true if the entity can be canceled, false otherwise + */ + + public function canCancel(): bool { + return in_array($this->status, [JobStatusEnum::Assigned, + JobStatusEnum::InProgress, + JobStatusEnum::Queued]); + } + + /** + * Determine if this entity is Read Only. + * + * @since COmanage Registry v5.0.0 + * @return bool true if the entity is read only, false otherwise + */ + + public function isReadOnly(): bool { + // As far as the UI is concerned, all Job records are read only + + return true; + } +} \ No newline at end of file diff --git a/app/src/Model/Entity/JobHistoryRecord.php b/app/src/Model/Entity/JobHistoryRecord.php new file mode 100644 index 000000000..5df5e3f2f --- /dev/null +++ b/app/src/Model/Entity/JobHistoryRecord.php @@ -0,0 +1,54 @@ + true, + 'id' => false, + 'slug' => false, + ]; + + /** + * Determine if this entity is Read Only. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Cake Entity + * @return boolean true if the entity is read only, false otherwise + */ + + public function isReadOnly(): bool { + // History records can't be altered once created + + return true; + } +} \ No newline at end of file diff --git a/app/src/Model/Entity/Meta.php b/app/src/Model/Entity/Meta.php new file mode 100644 index 000000000..9564e4c50 --- /dev/null +++ b/app/src/Model/Entity/Meta.php @@ -0,0 +1,40 @@ + true, + 'id' => false, + 'slug' => false, + ]; +} \ No newline at end of file diff --git a/app/src/Model/Entity/Name.php b/app/src/Model/Entity/Name.php new file mode 100644 index 000000000..c08e31093 --- /dev/null +++ b/app/src/Model/Entity/Name.php @@ -0,0 +1,140 @@ + true, + 'id' => false, + 'slug' => false, + ]; + + // Make full name available to the API v2 JSON response + protected $_virtual = [ + 'full_name' + ]; + + /** + * Generate a full (common) name. + * + * @since COmanage Registry v5.0.0 + * @param bool $showHonorific If true, return honorific as part of name + * @return string Formatted name + */ + + protected function _getFullName($showHonorific = false) { + // AR-Name-2 If there is a display name set, use it as the full name. + if(!empty($this->display_name)) { + return $this->display_name; + } + + // AR-Name-3 Name order is a bit tricky. We'll use the language encoding as + // our hint, although it isn't perfect. This could be replaced with a more + // sophisticatedtest as requirements evolve. + + $cn = ""; + + if(empty($this->language) + || !in_array($this->language, ['hu', 'ja', 'ko', 'za-Hans', 'za-Hant'])) { + // Western order. Do not show honorific by default. + + if($showHonorific && !empty($this->honorific)) { + $cn .= ($cn != "" ? ' ' : '') . $this->honorific; + } + + if(!empty($this->given)) { + $cn .= ($cn != "" ? ' ' : '') . $this->given; + } + + if(!empty($this->middle)) { + $cn .= ($cn != "" ? ' ' : '') . $this->middle; + } + + if(!empty($this->family)) { + $cn .= ($cn != "" ? ' ' : '') . $this->family; + } + + if(!empty($this->suffix)) { + $cn .= ($cn != "" ? ' ' : '') . $this->suffix; + } + } else { + // Switch to Eastern order. It's not clear what to do with some components. + + if(!empty($this->family)) { + $cn .= ($cn != "" ? ' ' : '') . $this->family; + } + + if(!empty($this->given)) { + $cn .= ($cn != "" ? ' ' : '') . $this->given; + } + } + + return $cn; + } + + /** + * Determine if this entity record can be deleted. + * + * @since COmanage Registry v5.0.0 + * @return bool True if the record can be deleted, false otherwise + */ + + public function canDelete(): bool { + return $this->notPrimary(); + } + + /** + * Determine if this is not a Primary Name. + * + * @since COmanage Registry v5.0.0 + * @return bool true if this is not a Primary Name, false otherwise. + */ + + public function notPrimary(): bool { + return !$this->primary_name; + } + + /** + * Generate a suitable label for rendering if this is a Primary Name. + * + * @since COmanage Registry v5.0.0 + * @return string Display label + */ + + public function primaryLabel(): string { + return ($this->primary_name ? __d('field', 'primary_name') : ""); + } +} \ No newline at end of file diff --git a/app/src/Model/Entity/Person.php b/app/src/Model/Entity/Person.php new file mode 100644 index 000000000..b81d904a0 --- /dev/null +++ b/app/src/Model/Entity/Person.php @@ -0,0 +1,65 @@ + true, + 'id' => false, + 'slug' => false, + ]; + + /** + * Determine if this Person is Active (includes GracePeriod). + * + * @since COmanage Registry v5.0.0 + * @return bool true if Person is Active or GracePeriod, false otherwise + */ + + public function isActive(): bool { + return in_array($this->status, [StatusEnum::Active, StatusEnum::GracePeriod]); + } + + /** + * Determine if this Person is Locked). + * + * @since COmanage Registry v5.0.0 + * @return bool true if Person is Active or GracePeriod, false otherwise + */ + + public function isLocked(): bool { + return $this->status == StatusEnum::Locked; + } +} \ No newline at end of file diff --git a/app/src/Model/Entity/PersonRole.php b/app/src/Model/Entity/PersonRole.php new file mode 100644 index 000000000..6662d037e --- /dev/null +++ b/app/src/Model/Entity/PersonRole.php @@ -0,0 +1,72 @@ + true, + 'id' => false, + 'slug' => false, + ]; + + /** + * Determine if this Person Role is Active (includes GracePeriod). + * + * @since COmanage Registry v5.0.0 + * @return bool true if the Person Role is Active or GracePeriod, false otherwise + */ + + public function isActive(): bool { + return in_array($this->status, [StatusEnum::Active, StatusEnum::GracePeriod]); + } + + /** + * Determine if this Person Role is valid. A valid record isActive() AND also + * has validity dates that are current. + * + * @since COmange Registry v5.0.0 + * @return bool true if the Person Role is valid, false otherwise + */ + + public function isValid(): bool { + // AR-PersonRole-3 A Person Role is considered valid (and provisionable) if + // (1) the Person Role is in Active or Grace Period status, + // (2) the valid from date is unspecified or in the past, and + // (3) the valid through date is unspecified or in the future. + return $this->isActive() + && (!$this->valid_from || $this->valid_from->isPast()) + && (!$this->valid_through || $this->valid_through->isFuture()); + } +} \ No newline at end of file diff --git a/app/src/Model/Entity/Pipeline.php b/app/src/Model/Entity/Pipeline.php new file mode 100644 index 000000000..8df0fb96b --- /dev/null +++ b/app/src/Model/Entity/Pipeline.php @@ -0,0 +1,42 @@ + true, + 'id' => false, + 'slug' => false, + ]; +} \ No newline at end of file diff --git a/app/src/Model/Entity/Plugin.php b/app/src/Model/Entity/Plugin.php new file mode 100644 index 000000000..f91bc25e3 --- /dev/null +++ b/app/src/Model/Entity/Plugin.php @@ -0,0 +1,80 @@ + true, + 'id' => false, + 'slug' => false, + ]; + + /** + * Determine if this Plugin can be activated. + * + * @since COmanage Registry v5.0.0 + * @return bool True if this plugin can be activated, false otherwise + */ + + public function canActivate(): bool { + // Any Suspended plugin can be activated + + return $this->status == SuspendableStatusEnum::Suspended; + } + + /** + * Determine if this Plugin can be deactivated. + * + * @since COmanage Registry v5.0.0 + * @return bool True if this plugin can be deactivated, false otherwise + */ + + public function canDeactivate(): bool { + // Only non-core Active plugins can be deactivated + + return ($this->status == SuspendableStatusEnum::Active && !$this->isReadOnly()); + } + + /** + * Determine if this entity is Read Only. + * + * @since COmanage Registry v5.0.0 + * @return bool True if the entity is read only, false otherwise + */ + + public function isReadOnly(): bool { + // Local plugins are read only + + return $this->location == 'core'; + } +} \ No newline at end of file diff --git a/app/src/Model/Entity/Pronoun.php b/app/src/Model/Entity/Pronoun.php new file mode 100644 index 000000000..872be9521 --- /dev/null +++ b/app/src/Model/Entity/Pronoun.php @@ -0,0 +1,45 @@ + true, + 'id' => false, + 'slug' => false, + ]; +} \ No newline at end of file diff --git a/app/src/Model/Entity/ProvisioningHistoryRecord.php b/app/src/Model/Entity/ProvisioningHistoryRecord.php new file mode 100644 index 000000000..946439233 --- /dev/null +++ b/app/src/Model/Entity/ProvisioningHistoryRecord.php @@ -0,0 +1,54 @@ + true, + 'id' => false, + 'slug' => false, + ]; + + /** + * Determine if this entity is Read Only. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Cake Entity + * @return boolean true if the entity is read only, false otherwise + */ + + public function isReadOnly(): bool { + // History records can't be altered once created + + return true; + } +} \ No newline at end of file diff --git a/app/src/Model/Entity/ProvisioningTarget.php b/app/src/Model/Entity/ProvisioningTarget.php new file mode 100644 index 000000000..09dae98f7 --- /dev/null +++ b/app/src/Model/Entity/ProvisioningTarget.php @@ -0,0 +1,42 @@ + true, + 'id' => false, + 'slug' => false, + ]; +} \ No newline at end of file diff --git a/app/src/Model/Entity/Server.php b/app/src/Model/Entity/Server.php new file mode 100644 index 000000000..30a1b3fa4 --- /dev/null +++ b/app/src/Model/Entity/Server.php @@ -0,0 +1,42 @@ + true, + 'id' => false, + 'slug' => false, + ]; +} \ No newline at end of file diff --git a/app/src/Model/Entity/TelephoneNumber.php b/app/src/Model/Entity/TelephoneNumber.php new file mode 100644 index 000000000..305cf34b6 --- /dev/null +++ b/app/src/Model/Entity/TelephoneNumber.php @@ -0,0 +1,74 @@ + true, + 'id' => false, + 'slug' => false, + ]; + + /** + * Generate a formatted number + * + * @since COmanage Registry v5.0.0 + * @return string Formatted telephone number + */ + + protected function _getFormattedNumber() { + // Start with number since it's always required, then prepend and/or append + $n = $this->number; + + // Prepend the area code, if set + if(!empty($this->area_code)) { + $n = $this->area_code . " " . $n; + } + + // Prepend the country code if set + if(!empty($this->country_code)) { + // We'll only output + style if a country code was provided + $n = "+" . $this->country_code . " " . $n; + } + + // Append the extension, if set + if(!empty($this->extension)) { + $n .= " " . __d('field', 'TelephoneNumbers.number.ext') . $this->extension; + } + + return $n; + } +} \ No newline at end of file diff --git a/app/src/Model/Entity/Type.php b/app/src/Model/Entity/Type.php new file mode 100644 index 000000000..1cee64e8b --- /dev/null +++ b/app/src/Model/Entity/Type.php @@ -0,0 +1,42 @@ + true, + 'id' => false, + 'slug' => false, + ]; +} \ No newline at end of file diff --git a/app/src/Model/Entity/Url.php b/app/src/Model/Entity/Url.php new file mode 100644 index 000000000..d92eda483 --- /dev/null +++ b/app/src/Model/Entity/Url.php @@ -0,0 +1,44 @@ + true, + 'id' => false, + 'slug' => false, + ]; +} \ No newline at end of file diff --git a/app/src/Model/Table/AdHocAttributesTable.php b/app/src/Model/Table/AdHocAttributesTable.php new file mode 100644 index 000000000..0e02e5e35 --- /dev/null +++ b/app/src/Model/Table/AdHocAttributesTable.php @@ -0,0 +1,149 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Secondary); + + // Define associations + $this->belongsTo('People'); + $this->belongsTo('PersonRoles'); + $this->belongsTo('ExternalIdentities'); + $this->belongsTo('ExternalIdentityRoles'); + + $this->setDisplayField('tag'); + + $this->setPrimaryLink(['external_identity_id', 'external_identity_role_id', 'person_id', 'person_role_id']); + $this->setRequiresCO(true); + $this->setRedirectGoal('self'); + $this->setAllowLookupPrimaryLink(['unfreeze']); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'unfreeze' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that are permitted on readonly entities (besides view) + 'readOnly' => ['unfreeze'], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Callback after model save. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Event + * @param EntityInterface $entity Entity (ie: Co) + * @param ArrayObject $options Save options + * @return bool True on success + */ + + public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { + $this->recordHistory($entity); + + return true; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + * @throws InvalidArgumentException + * @throws RecordNotFoundException + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $this->registerPrimaryKeyValidation($validator, $this->getPrimaryLinks()); + + $this->registerStringValidation($validator, $schema, 'tag', true); + + $this->registerStringValidation($validator, $schema, 'value', false); + + $validator->add('frozen', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('frozen'); + + $validator->add('source_ad_hoc_attribute_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('source_ad_hoc_attribute_id'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/AddressesTable.php b/app/src/Model/Table/AddressesTable.php new file mode 100644 index 000000000..f42adb038 --- /dev/null +++ b/app/src/Model/Table/AddressesTable.php @@ -0,0 +1,268 @@ + [ + 'campus', + 'home', + 'office', + 'postal' + ] + ]; + + /** + * Provide the default layout + * + * @since COmanage Registry v5.0.0 + * @return string Type of redirect + */ + public function getLayout(): string { + return "iframe"; + } + + /** + * Perform Cake Model initialization. + * + * @since COmanage Registry v5.0.0 + * @param array $config Configuration options passed to constructor + */ + + public function initialize(array $config): void { + // Timestamp behavior handles created/modified updates + $this->addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Secondary); + + // Define associations + $this->belongsTo('People'); + $this->belongsTo('PersonRoles'); + $this->belongsTo('ExternalIdentities'); + $this->belongsTo('ExternalIdentityRoles'); + $this->belongsTo('Types'); + + $this->setDisplayField('street'); + + $this->setPrimaryLink(['external_identity_id', 'external_identity_role_id', 'person_id', 'person_role_id']); + $this->setRequiresCO(true); + // Models that AcceptCoId should be expicitly added to StandardApiController::initialize() + $this->setAcceptsCoId(true); + $this->setRedirectGoal('self'); + $this->setAllowLookupPrimaryLink(['unfreeze']); + + $this->setAutoViewVars([ + 'languages' => [ + 'type' => 'enum', + 'class' => 'LanguageEnum' + ], + 'types' => [ + 'type' => 'type', + 'attribute' => 'Addresses.type' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'unfreeze' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that are permitted on readonly entities (besides view) + 'readOnly' => ['unfreeze'], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Callback after model save. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Event + * @param EntityInterface $entity Entity (ie: Co) + * @param ArrayObject $options Save options + * @return bool True on success + */ + + public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { + $this->recordHistory($entity); + + return true; + } + + /** + * Perform a keyword search. + * + * @since COmanage Registry v5.0.0 + * @param int $coId CO ID to constrain search to + * @param string $q String to search for + * @param int $limit Search limit + * @return Array Array of search results, as from find('all) + */ + + public function search(int $coId, string $q, int $limit) { + // Tokenize $q on spaces + $tokens = explode(" ", $q); + + // We take two loops through, the first time we only do a prefix search + // (foo%). If that doesn't reach the search limit, we'll do an infix search + // the second time around. + + $whereClause = []; + + foreach($tokens as $t) { + $whereClause['AND'][] = [ + 'OR' => [ + 'LOWER(Addresses.street) LIKE' => '%' . strtolower($t) . '%' + ] + ]; + } + + return $this->find() + ->where($whereClause) + ->andWhere(['People.co_id' => $coId]) + ->order(['Addresses.street']) + ->limit($limit) + ->contain([ + 'People' => 'PrimaryName', + 'PersonRoles' => [ + 'People' => 'PrimaryName' + ] + ]) + ->all(); + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + * @throws InvalidArgumentException + * @throws RecordNotFoundException + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + // We need the current CO ID to dynamically set validation rules according + // to CoSettings. + + if(!$this->curCoId) { + throw new \InvalidArgumentException(__d('error', 'coid')); + } + + $CoSettings = TableRegistry::getTableLocator()->get('CoSettings'); + + $settings = $CoSettings->find()->where(['co_id' => $this->curCoId])->firstOrFail(); + + $requiredFields = $settings->address_required_fields_array(); + + $this->registerPrimaryKeyValidation($validator, $this->getPrimaryLinks()); + + // CO Settings determines if these fields are required + $validator->add('street', [ + 'filter' => ['rule' => ['validateInput'], + 'provider' => 'table'] + ]); + if(in_array('street', $requiredFields)) { + $validator->notEmptyString('street'); + } else { + $validator->allowEmptyString('street'); + } + + foreach(['locality', 'state', 'postal_code', 'country'] as $f) { + $validator->add($f, [ + 'size' => ['rule' => ['validateMaxLength', ['column' => $schema->getColumn($f)]], + 'provider' => 'table'], + 'filter' => ['rule' => ['validateInput'], + 'provider' => 'table'] + ]); + if(in_array($f, $requiredFields)) { + $validator->notEmptyString($f); + } else { + $validator->allowEmptyString($f); + } + } + + $this->registerStringValidation($validator, $schema, 'room', false); + + $this->registerStringValidation($validator, $schema, 'description', false); + + $validator->add('language', [ + 'content' => ['rule' => ['inList', LanguageEnum::getConstValues()]] + ]); + $validator->allowEmptyString('language'); + + $validator->add('type_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('type_id'); + + $validator->add('frozen', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('frozen'); + + $validator->add('source_address_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('source_address_id'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/ApiUsersTable.php b/app/src/Model/Table/ApiUsersTable.php new file mode 100644 index 000000000..169a5b17b --- /dev/null +++ b/app/src/Model/Table/ApiUsersTable.php @@ -0,0 +1,319 @@ +addBehavior('Changelog'); + $this->addBehavior('Timestamp'); + $this->addBehavior('Timezone'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('Cos'); + + $this->setDisplayField('username'); + + $this->setPrimaryLink('co_id'); + $this->setAllowLookupPrimaryLink(['generate', 'generateApiKey']); + $this->setRequiresCO(true); + + $this->setAutoViewVars([ + 'statuses' => [ + 'type' => 'enum', + 'class' => 'SuspendableStatusEnum' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'generate' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Add namespace prefix to username + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event beforeMarshal event + * @param ArrayObject $data Entity data + * @param ArrayObject $options Callback options + */ + + public function beforeMarshal(EventInterface $event, ArrayObject $data, ArrayObject $options) + { + if (isset($data['username'])) { + $data['username'] = "co_" . $data['co_id'] . "." . $data['username']; + } + } + + /** + * Define business rules. + * + * @since COmanage Registry v5.0.0 + * @param RulesChecker $rules RulesChecker object + * @return RulesChecker + */ + + public function buildRules(RulesChecker $rules): RulesChecker { + // We don't want to perform the uniqueness check until after then namespacing + // check in order to avoid information leakage. This requires more complicated + // rule building. + + $rules->add(function($entity, $options) use($rules) { + + // AR-ApiUser-3 API usernames must be unique across the entire platform. + $rule = $rules->isUnique(['username'], __d('error', 'exists', [__d('controller', 'ApiUsers', [1])])); + + return $rule($entity, $options); + }, + 'isUsernameValid', + ['errorField' => 'username']); + + return $rules; + } + + /** + * Generate (and save) an API Key for the specified API User. + * + * @since COmanage Registry v5.0.0 + * @param int $id API User ID + * @return string API Key + */ + + public function generateKey(int $id) { + $token = RandomString::generateAppKey(); + + // Note hashing happens in the entity (ApiUser.php) + $apiUser = $this->get($id); + $apiUser->api_key = $token; + + $this->save($apiUser); + + return $token; + } + + /** + * Obtain an API User's priviledged status. Note this function will not validate + * any aspects of the record (status, valid_from, etc) -- use validateKey for that. + * + * @since COmanage Registry v5.0.0 + * @param string $username API Username + * @return mixed true if $username is a platform API user, an integer (the CO ID) if the user is a privileged API user within that CO, or false otherwise + * @throws InvalidArgumentException + */ + +// public function getUserPrivilege(string $username): mixed { +// mixed requires PHP 8 + public function getUserPrivilege(string $username) { + $apiUser = $this->find()->where(['username' => $username])->contain('Cos')->first(); + + if(empty($apiUser)) { + throw new \InvalidArgumentException(__d('error', 'auth.api.unknown', [$username])); + } + + if($apiUser->co->isCOmanageCO()) { + return true; + } elseif($apiUser->privileged) { + return $apiUser->co_id; + } + + return false; + } + + /** + * Validate an API Key. + * + * @since COmanage Registry v5.0.0 + * @param string $username API Username + * @param string $apiKey API Key to validate + * @param string $remoteIp IP Address of request + * @return boolean true if the API Key validates + * @throws InvalidArgumentException + */ + + public function validateKey(string $username, string $apiKey, string $remoteIp) { + // First pull the ApiUser record for $username. Note we don't know which + // CO we're querying for, so $username requires the CO name as a prefix + // (except for legacy usernames, which are assumed to be part of the + // COmanage CO). + + // We could add where clauses to filter on status, etc, but by manually + // examining the record we can provide better error information. + $apiUser = $this->find()->where(['username' => $username])->first(); + + if(empty($apiUser)) { + throw new \InvalidArgumentException(__d('error', 'auth.api.unknown', [$username])); + } + + // First validate the key. We use the FallbackPasswordHasher because API Users + // that were created in version prior to 5.0.0 use Cake 2's SHA-1 hashing. + // We can detect that here and rehash the password, but only when the apiuser + // authenticates. + + $Hasher = new FallbackPasswordHasher([ + 'hashers' => [ + 'Default' => [], + 'Weak' => ['hashType' => 'sha1'] + ] + ]); + + if(!$Hasher->check($apiKey, $apiUser->api_key)) { + throw new \InvalidArgumentException(__d('error', 'auth.api.key', [$username])); + } + + if($Hasher->needsRehash($apiUser->api_key)) { + // We'll rehash passwords even if subsequent eligibility checks fail + \Cake\Log\Log::write('debug', "Rehashing password for API User \"" . $username . "\""); + + $apiUser->api_key = $apiKey; + // We disable rules checking to permit legacy usernames (those not prefixed + // with the CO name to remain) + $this->save($apiUser, ['checkRules' => false]); + } + + // Is the ApiUser active? + if($apiUser->status != SuspendableStatusEnum::Active) { + throw new \InvalidArgumentException(__d('error', 'auth.api.status', [$username])); + } + + // Are we within the validity window, if applicable? + $now = Chronos::now(); + + if($apiUser->valid_from + && $now->lt($apiUser->valid_from)) { + throw new \InvalidArgumentException(__d('error', 'auth.api.toosoon', [$username])); + } + + if($apiUser->valid_through + && $now->gt($apiUser->valid_through)) { + throw new \InvalidArgumentException(__d('error', 'auth.api.expired', [$username])); + } + + // Perform the IP Address check + if($apiUser->remote_ip + && !preg_match($apiUser->remote_ip, $remoteIp)) { + throw new \InvalidArgumentException(__d('error', 'auth.api.ip', [$remoteIp, $username])); + } + + return true; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return $validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('co_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('co_id'); + + $this->registerStringValidation($validator, $schema, 'username', true, 'co_id'); + + $validator->add('api_key', [ + 'length' => ['rule' => ['validateMaxLength', ['column' => $schema->getColumn('api_key')]], + 'provider' => 'table'], + ]); + $validator->allowEmptyString('api_key'); + + $validator->add('status', [ + 'content' => ['rule' => ['inList', SuspendableStatusEnum::getConstValues()]] + ]); + $validator->notEmptyString('status'); + + $validator->add('privileged', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('privileged'); + + $validator->add('valid_from', [ + 'content' => ['rule' => ['datetime']] + ]); + $validator->allowEmptyString('valid_from'); + + $validator->add('valid_through', [ + 'content' => ['rule' => ['datetime']] + ]); + $validator->allowEmptyString('valid_through'); + + $validator->add('remote_ip', [ + 'length' => ['rule' => ['validateMaxLength', ['column' => $schema->getColumn('remote_ip')]], + 'provider' => 'table'], + ]); + $validator->allowEmptyString('remote_ip'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/AuthenticationEventsTable.php b/app/src/Model/Table/AuthenticationEventsTable.php new file mode 100644 index 000000000..f74a64cc6 --- /dev/null +++ b/app/src/Model/Table/AuthenticationEventsTable.php @@ -0,0 +1,180 @@ +addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Artifact); + + // Define associations + // Technically, Authentication Events do not directly foreign key since + // we store the actual identifier, not the target object. This implies + // authentication events will not be purged if a CO is deleted, because they + // are not technically part of the CO's tree of data. (AR-AuthenticationEvent-4) + + $this->setDisplayField('authenticated_identifier'); + + $this->setRequiresCO(false); + + $this->setAutoViewVars([ + 'authentication_events' => [ + 'type' => 'enum', + 'class' => 'AuthenticationEventEnum' + ] + ]); + + $this->setPermissions(function (\Cake\Http\ServerRequest $r, \App\Controller\Component\RegistryAuthComponent $auth, ?int $id): array { + // We're going to be called a bunch of times (once per row on the index view) + // so we want to cache the result of the permission check on the requested identifier + // (since all rows will be for the same identifier, at least for now). + + $targetIdentifier = $r->getQuery('authenticated_identifier'); + $manages = false; + + if($targetIdentifier) { + $targetIdentifier = \App\Lib\Util\StringUtilities::urlbase64decode($targetIdentifier); + + $manages = $auth->isPlatformAdmin() || $auth->isAdminForIdentifier($targetIdentifier); + } else { + // We set $manages = true here because we need to return index + // permission for related models calculation in identifiers?person_id=X + // (so the link to Authentication Events renders). However, in + // setIndexFilter below we will reject requests without a $targetIdentifier + // which effectively denies such requests. + + $manages = true; + } + + return [ + 'entity' => [ + 'delete' => false, + 'edit' => false, + 'view' => false + ], + 'table' => [ + 'add' => false, + 'index' => $manages + ] + ]; + }); + + $this->setIndexFilter(function (\Cake\Http\ServerRequest $r): array { + // This will be checked for authz in RegistryAuthComponent + $targetIdentifier = $r->getQuery('authenticated_identifier'); + + // Note that in setPermissions above we permit index operations when no + // targetIdentifier is specified. We reject that here though since index + // views require a targetIdentifier. + if(!$targetIdentifier) { + throw new \InvalidArgumentException(__d('error', 'input.notprov', 'authenticated_identifier')); + } + + return ['authenticated_identifier' => StringUtilities::urlbase64decode($targetIdentifier)]; + }); + } + + /** + * Record an authentication event. + * + * @since COmanage Registry v5.0.0 + * @param string $identifier Authenticated identifier + * @param AuthenticationEventEnum $eventType AuthenticationEventEnum + * @param string $remoteIp Remote IP address, if known + * @return int Authentication Event Record ID + */ + + public function record(string $identifier, string $eventType, ?string $remoteIp=null): int { + $record = [ + 'authenticated_identifier' => $identifier, + 'authentication_event' => $eventType, + 'remote_ip' => $remoteIp + ]; + + $obj = $this->newEntity($record); + + $this->saveOrFail($obj); + + return $obj->id; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + * @throws InvalidArgumentException + * @throws RecordNotFoundException + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $this->registerStringValidation($validator, $schema, 'authenticated_identifier', true); + + $validator->add('authentication_event', [ + 'content' => ['rule' => ['inList', AuthenticationEventEnum::getConstValues()]] + ]); + $validator->notEmptyString('authentication_event'); + + $this->registerStringValidation($validator, $schema, 'remote_ip', false); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/CoSettingsTable.php b/app/src/Model/Table/CoSettingsTable.php new file mode 100644 index 000000000..d66fea2f5 --- /dev/null +++ b/app/src/Model/Table/CoSettingsTable.php @@ -0,0 +1,352 @@ +addBehavior('Changelog'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('Cos'); + $this->belongsTo('DefaultAddressTypes') + ->setClassName('Types') + ->setForeignKey('default_address_type_id') + // Property is set so ruleValidateCO can find it. We don't use the + // _id suffix to match Cake's default pattern. + ->setProperty('default_address_type'); + $this->belongsTo('DefaultEmailAddressTypes') + ->setClassName('Types') + ->setForeignKey('default_email_address_type_id') + ->setProperty('default_email_address_type'); + $this->belongsTo('DefaultIdentifierTypes') + ->setClassName('Types') + ->setForeignKey('default_identifier_type_id') + ->setProperty('default_identifier_type'); + $this->belongsTo('DefaultNameTypes') + ->setClassName('Types') + ->setForeignKey('default_name_type_id') + ->setProperty('default_name_type'); + $this->belongsTo('DefaultPronounTypes') + ->setClassName('Types') + ->setForeignKey('default_pronoun_type_id') + ->setProperty('default_pronoun_type'); + $this->belongsTo('DefaultTelephoneNumberTypes') + ->setClassName('Types') + ->setForeignKey('default_telephone_number_type_id') + ->setProperty('default_telephone_number_type'); + $this->belongsTo('DefaultUrlTypes') + ->setClassName('Types') + ->setForeignKey('default_url_type_id') + ->setProperty('default_url_type'); + + $this->setDisplayField('co_id'); + + $this->setPrimaryLink('co_id'); + $this->setRequiresCO(true); + $this->setAllowUnkeyedPrimaryCO(['manage']); + $this->setRedirectGoal('self'); + + $this->setAutoViewVars([ + 'defaultAddressTypes' => [ + 'type' => 'type', + 'attribute' => 'Addresses.type' + ], + 'defaultEmailAddressTypes' => [ + 'type' => 'type', + 'attribute' => 'EmailAddresses.type' + ], + 'defaultIdentifierTypes' => [ + 'type' => 'type', + 'attribute' => 'Identifiers.type' + ], + 'defaultNameTypes' => [ + 'type' => 'type', + 'attribute' => 'Names.type' + ], + 'defaultPronounTypes' => [ + 'type' => 'type', + 'attribute' => 'Pronouns.type' + ], + 'defaultTelephoneNumberTypes' => [ + 'type' => 'type', + 'attribute' => 'TelephoneNumbers.type' + ], + 'defaultUrlTypes' => [ + 'type' => 'type', + 'attribute' => 'Urls.type' + ], + 'permittedFieldsNames' => [ + 'type' => 'enum', + 'class' => 'PermittedNameFieldsEnum' + ], + 'permittedFieldsTelephoneNumbers' => [ + 'type' => 'enum', + 'class' => 'PermittedTelephoneNumberFieldsEnum' + ], + 'requiredFieldsAddresses' => [ + 'type' => 'enum', + 'class' => 'RequiredAddressFieldsEnum' + ], + 'requiredFieldsNames' => [ + 'type' => 'enum', + 'class' => 'RequiredNameFieldsEnum' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id). Since each CO's + // CoSetting is created during CO Setup, admins can only edit. + 'entity' => [ + 'delete' => false, + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] // Required for REST API + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, + 'index' => ['platformAdmin', 'coAdmin'], // Required for REST API + 'manage' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Add default settings to a CO. Intended for use at CO Setup. + * + * @since COmanage Registry v5.0.0 + * @param int $coId CO ID + * @return int CoSettings ID + * @throws ConflictException when default values exist + */ + + public function addDefaults(int $coId): int { + // Default values for each setting + + $defaultSettings = [ + 'co_id' => $coId, + 'default_address_type_id' => null, + 'default_email_address_type_id' => null, + 'default_identifier_type_id' => null, + 'default_name_type_id' => null, + 'default_pronoun_type_id' => null, + 'default_telephone_number_type_id' => null, + 'default_url_type_id' => null, + 'permitted_fields_name' => PermittedNameFieldsEnum::HGMFS, + 'permitted_fields_telephone_number' => PermittedTelephoneNumberFieldsEnum::CANE, + 'required_fields_address' => RequiredAddressFieldsEnum::Street, + 'required_fields_name' => RequiredNameFieldsEnum::Given, + 'search_global_limit' => DEF_GLOBAL_SEARCH_LIMIT, + 'search_limited_models' => false +// XXX to add new settings, set a default here, then add a validation rule below +// also update data model documentation + // 'disable_expiration' => false, + // 'disable_ois_sync' => false, + // 'enable_normalization' => true, + // 'enable_nsf_demo' => false, + // 'group_validity_sync_window' => DEF_GROUP_SYNC_WINDOW, + // 'invitation_validity' => DEF_INV_VALIDITY, + // 'garbage_collection_interval' => DEF_GARBAGE_COLLECT_INTERVAL, + // 'permitted_fields_name' => PermittedNameFieldsEnum::HGMFS, + // 'required_fields_addr' => RequiredAddressFieldsEnum::Street, + // 'required_fields_name' => RequiredNameFieldsEnum::Given, + // 'sponsor_co_group_id' => null, + // 'sponsor_eligibility' => SponsorEligibilityEnum::CoOrCouAdmin, + // 't_and_c_login_mode' => TAndCLoginModeEnum::NotEnforced, + // 'enable_empty_cou' => false, + // 'theme_stacking' => SuspendableStatusEnum::Suspended, + // 'co_theme_id' => null, + ]; + + // Check if we already have Settings for this CO + $settings = $this->find()->where([ 'co_id' => $defaultSettings['co_id'] ])->first(); + // If the record already exists throw an exception + if(!empty($settings->{'id'})) { + throw new \ConflictException(__d('error', 'default.conflict')); + } + + $obj = $this->newEntity($defaultSettings); + + $this->save($obj); + + return $obj->id; + } + + /** + * Table specific logic to generate a display field. + * + * @since COmanage Registry v5.0.0 + * @param CoSetting $entity Entity to generate display field for + * @return string Display field + */ + + public function generateDisplayField(\App\Model\Entity\CoSetting $entity): string { + return __d('controller', 'CoSettings', [99]); + } + + /** + * Determine if a requested Type is in use as a default via CoSettings. + * + * @since COmanage Registry v5.0.0 + * @param int $id Type ID + * @return bool true if the type is in use as a default, false otherwise + */ + + public function typeIsDefault(int $id): bool { + // We actually don't need to care which type we're being asked about, since + // $id can only resolve to a single type (as the primary key for the types + // table). We simply see if $id is in any _default_type_id field. + + $orclause = []; + + foreach($this->getSchema()->columns() as $col) { + if(preg_match('/^default_[a-z]+_type_id$/', $col)) { + $orclause[] = [$col => $id]; + } + } + + $count = $this->find('all')->where(['OR' => $orclause])->count(); + + return (bool)$count; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $validator->add('default_address_type_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('default_address_type_id'); + + $validator->add('default_email_address_type_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('default_email_address_type_id'); + + $validator->add('default_identifier_type_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('default_identifier_type_id'); + + $validator->add('default_name_type_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('default_name_type_id'); + + $validator->add('default_pronoun_type_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('default_pronoun_type_id'); + + $validator->add('default_telephone_number_type_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('default_telephone_number_type_id'); + + $validator->add('default_url_type_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('default_url_type_id'); + + $validator->add('permitted_name_fields', [ + 'content' => ['rule' => ['inList', PermittedNameFieldsEnum::getConstValues()]] + ]); + $validator->notEmptyString('permitted_name_fields'); + + $validator->add('permitted_telephone_number_fields', [ + 'content' => ['rule' => ['inList', PermittedTelephoneNumberFieldsEnum::getConstValues()]] + ]); + $validator->notEmptyString('permitted_fields_telephone_number'); + + $validator->add('required_fields_address', [ + 'content' => ['rule' => ['inList', RequiredAddressFieldsEnum::getConstValues()]] + ]); + $validator->notEmptyString('required_fields_address'); + + $validator->add('required_fields_name', [ + 'content' => ['rule' => ['inList', RequiredNameFieldsEnum::getConstValues()]] + ]); + $validator->notEmptyString('required_name_fields'); + + $validator->add('search_global_limited_models', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('search_global_limited_models'); + + $validator->add('search_global_limit', [ + 'content' => ['rule' => ['comparison', '>', 0]] + ]); + $validator->notEmptyString('search_global_limit'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/CosTable.php b/app/src/Model/Table/CosTable.php new file mode 100644 index 000000000..76b31bc57 --- /dev/null +++ b/app/src/Model/Table/CosTable.php @@ -0,0 +1,489 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + // Timestamp behavior handles created/modified updates + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + + $this->hasMany('ApiUsers') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('Cous') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('Dashboards') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('Groups') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('IdentifierAssignments') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('Jobs') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('People') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('Pipelines') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('Reports') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('Types') + ->setDependent(true) + ->setCascadeCallbacks(true); + + $this->hasOne('CoSettings') + ->setDependent(true) + ->setCascadeCallbacks(true); + + $this->setDisplayField('name'); + + $this->setAutoViewVars([ + 'statuses' => [ + 'type' => 'enum', + 'class' => 'TemplateableStatusEnum' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => ['platformAdmin'], + 'duplicate' => ['platformAdmin'], + 'edit' => ['platformAdmin'], + 'switch' => ['platformAdmin'], + 'view' => ['platformAdmin'] + ], + // Actions that are permitted on readonly entities (besides view) + 'readOnly' => ['duplicate'], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin'], + 'index' => ['platformAdmin'], + 'select' => ['authenticatedUser'] + ], + // Related models whose permissions we'll need, typically for table views + 'related' => [ + 'Dashboards' + ] + ]); + } + + /** + * Define business rules. + * + * @since COmanage Registry v5.0.0 + * @param RulesChecker $rules RulesChecker object + * @return RulesChecker + */ + + public function buildRules(RulesChecker $rules): RulesChecker { + // AR-CO-2 The COmanage CO cannot be renamed or deleted + // Note add() sets the rule for create+update, where addDelete() sets the rule additionally for delete + $rules->add([$this, 'ruleIsCOmanageCO'], + 'isCOmanageCO', + ['errorField' => 'name']); + + $rules->addDelete([$this, 'ruleIsCOmanageCO'], + 'isCOmanageCO', + ['errorField' => 'name']); + + // AR-CO-3 Two COs cannot share the same name +// XXX CO-1736 In general, these checks should be case insensitive +// (ie: I shouldn't be able to create a CO called "comanage", similarly COUs etc) +// Also, with CO-1845 maybe unique ignores non-alphanumeric + $rules->add($rules->isUnique(['name'], __d('error', 'exists', [__d('controller', 'Cos', [1])]))); + + // AR-CO-5 A CO cannot be deleted if it is in Active status + // This basically requires two steps to delete a CO (set to Suspended), + // reducing the likelihood of accidentally deleting a CO. + $rules->addDelete([$this, 'ruleIsActive'], + 'isActive', + ['errorField' => 'status']); + + return $rules; + } + + /** + * Delete a CO. + * + * @since COmanage Registry v5.0.0 + * @param EntityInterface $entity CO to be deleted + * @param array $options Delete options (as per Cake) + * @return boolean true on success + * @throws Cake\ORM\Exception\PersistenceFailedException + */ + + public function deleteOrFail(EntityInterface $entity, $options = []): bool { + // Completely wiping a CO requires special handling. Because of the complex + // dependency paths, we can't simply rely on Cake's dependency propagation + // on delete. + + // We ignore $options['useHardDelete'] because COs can _only_ be hard deleted. + + // We'll start by obtaining the set of models directly associated with the CO model. + $associations = $this->associations(); + + // We need to sort the associations into several buckets: + // (1) Pluggable Models, + // (2) Configuration Models that belong to a model other than Cos, + // (3) Primary Models, + // (4) Configuration Models that do not belong to another model + // We don't need to identify Secondary Models because Primary Model deletes + // will cascade to them, and generally Cake's cascade delete will be sufficient. + // See also: https://spaces.at.internet2.edu/display/COmanage/Registry+PE+Data+Model#RegistryPEDataModel-Tables + + // These will be keyed on the association target class name, with values being the Table objects + $pluggable = []; + $configFirst = []; + $primary = []; + $configLast = []; + + foreach($associations->getByType(['HasOne', 'HasMany']) as $a) { + $targetTable = $a->getTarget(); + + if(method_exists($targetTable, "getPluggableModelType")) { + $pluggable[ $a->getClassName() ] = $targetTable; + } elseif($targetTable->getIsConfigurationTable()) { + // eg: CoSettings + $targetAssociations = $a->associations(); + + // Did we find an association to something other than Cos? + $found = false; + + foreach($targetAssociations->getByType(['belongsTo', 'belongsToMany']) as $ta) { + // eg: Types (CoSettings belongsTo Types) + // We also skip associations into the same model (eg: Cous, for TreeBehavior) + + if($ta->getClassName() != 'Cos' + && $ta->getClassName() != $a->getClassName()) { + $found = true; + break; + } + } + + if($found) { + $configFirst[ $a->getClassName() ] = $targetTable; + } else { + $configLast[ $a->getClassName() ] = $targetTable; + } + } else { + // This is by definition a Primary Object since it belongsTo CO, eg: People + $primary[ $a->getClassName() ] = $targetTable; + } + } + + // First, delete plugin related models + // XXX unclear that we need to do anything here... PluggableModelTrait will + // automatically bind instantiated Entry Point Models when a Pluggable Table object + // is initialized, so plugin related models should be automatically deleted when + // the Pluggable Model is deleted. + + // Delete any Configuration Object that references a Primary Object or other + // Configuration Objects (such as Types) + + $this->paginatedDelete($entity->id, $configFirst); + + // Delete Primary Objects, which should cascade and take Secondary Objects with them. + + $this->paginatedDelete($entity->id, $primary); + + // Delete any remaining Configuration Objects, (including Types) after all + // models that might reference them + + $this->paginatedDelete($entity->id, $configLast); + + // Delete any Changelog records for this CO. We can use deleteAll because we + // don't need any callbacks to fire. + $this->deleteAll(['Cos.co_id' => $entity->id]); + + // Finally, delete the CO itself + parent::deleteOrFail($entity, ['useHardDelete' => true, 'checkRules' => false]); + + return true; + } + + /* + public function duplicate($id) { + // XXX document AR-CO-4, use TableMetaTrait to determine which tables are configuration + }*/ + + /** + * Find the COmanage CO. + * + * @since COmanage Registry v5.0.0 + * @param \Cake\ORM\Query $query Query + * @return \Cake\ORM\Query Query + */ + + public function findCOmanageCO(Query $query): Query { + return $query->where(['lower(name)' => 'comanage']); + } + + /** + * Obtain the set of COs for the specified Identifier. The Identifier must + * be a login identifier, Active, and attached to an Active or Grace Period + * Person in an Active CO. If the Identifier belongs to a Platform Admin, all + * Active COs will be returned. + * + * @since COmanage Registry v5.0.0 + * @param string $loginIdentifier Login Identifier + * @return array Array of COs + */ + + public function getCosForIdentifier(string $loginIdentifier): array { + // Start by pulling the active Identifier records where $loginIdentifier is + // flagged for login and attached to a Person (not an External Identity). + + $identifiers = $this->People + ->Identifiers + ->find('all') + ->where([ + 'Identifiers.identifier' => $loginIdentifier, + 'Identifiers.status' => SuspendableStatusEnum::Active, + 'Identifiers.login' => true, + 'Identifiers.person_id IS NOT NULL' + ]) + ->contain(['People' => 'Cos']) + ->all(); + + $cos = []; + + // Did we find an Identifier attached to a Person in the COmanage CO? + + foreach($identifiers as $i) { + // Both the Person and the CO must be active. Note that there may be an + // Active Identifier pointing to an Archived Person (for certain edge cases), + // in which case $i->person is null even though person_id is not. + + if($i->person && $i->person->isActive() + && $i->person->co->status == TemplateableStatusEnum::Active) { + // Keying on co_id should eliminate duplicates + $cos[ $i->person->co_id ] = $i->person->co; + } + } + + return $cos; + } + + /** + * Callback after model save. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Event + * @param EntityInterface $entity Entity (ie: Co) + * @param ArrayObject $options Save options + * @return bool True on success + */ + + public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options) { + if(!empty($entity->id)) { + if($entity->isNew()) { + // Run setup for new CO + + $this->setup($entity->id); + } elseif($entity->getOriginal('name') != $entity->get('name')) { + // AR-CO-7 The name was changed, so we may need to update the system groups + + $this->Groups->addDefaults(coId: $entity->id, rename: true); + } + } + + return true; + } + + /** + * Perform a paginated delete over a large set of objects within a CO. + * + * @since COmanage Registry v5.0.0 + * @param int $coId CO ID + * @param array $tableSet Set of tables to operate over. + * @todo This could probably be generalized, if it were useful somewhere else + */ + + protected function paginatedDelete(int $coId, array $tableSet) { + foreach($tableSet as $tableName => $table) { + $iterator = new PaginatedSqlIterator(table: $table, + conditions: ['co_id' => $coId], + options: ['archived' => true]); + + foreach($iterator as $k => $tentity) { + // We call delete on each entity individually so that callbacks fire, + // in particular the unsetting of foreign keys that might be set. + + // We disable checkRules since we're hard deleting all objects in the CO. + $table->deleteOrFail($tentity, ['useHardDelete' => true, 'checkRules' => false]); + } + } + } + + /** + * Application Rule to determine if the current entity is the COmanage CO. + * + * @param Entity $entity Entity to be validated + * @param array $options Application rule options + * + * @return string|bool true if the Rule check passes, false otherwise + * @since COmanage Registry v5.0.0 + */ + + public function ruleIsCOmanageCO($entity, array $options): string|bool { + // We want negative logic since we want to fail if we're editing the COmanage CO + if($entity->isCOmanageCO()) { + return __d('error', 'edit.comanage'); + } + + return true; + } + + /** + * Application Rule to determine if the current entity is not Active. + * + * @param Entity $entity Entity to be validated + * @param array $options Application rule options + * + * @return bool|string true if the Rule check passes, false otherwise + * @since COmanage Registry v5.0.0 + */ + + public function ruleIsActive($entity, array $options): bool|string { + // We want negative logic since we want to fail if the record is Active + if($entity->status === TemplateableStatusEnum::Active) { + return __d('error', 'delete.active'); + } + + return true; + } + + /** + * Perform initial setup for a CO. + * + * @since COmanage Registry v5.0.0 + * @param int $id CO ID + * @return bool True on success + */ + + public function setup(int $id): bool { + // AR-Type-1 Set up the default values for extended types + $this->Types->addDefaults($id); + + // AR-CO-6 Create the default groups + $this->Groups->addDefaults($id); + + // Set up the default settings + $this->CoSettings->addDefaults($id); + + return true; + } + + /** + * Perform initial setup for COmanage CO + * + * @since COmanage Registry v5.0.0 + * @return null|int null or the id of the COmanage CO + */ + + public function setupCOmanageCO(): int|null { + $comanage_co = $this->newEmptyEntity(); + $comanage_co->name = __d('command', 'product.comanage'); + $comanage_co->description = __d('command', 'registry.co.desc'); + $comanage_co->status = StatusEnum::Active; + + $co_id = null; + if ($this->save($comanage_co, ['checkRules' => false])) { + $co_id = $comanage_co->id; + } + + return $co_id; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return $validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $this->registerStringValidation($validator, $schema, 'name', true); + + $this->registerStringValidation($validator, $schema, 'description', false); + + $validator->add('status', [ + 'content' => ['rule' => ['inList', TemplateableStatusEnum::getConstValues()]] + ]); + $validator->notEmptyString('status'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/CousTable.php b/app/src/Model/Table/CousTable.php new file mode 100644 index 000000000..b340696a5 --- /dev/null +++ b/app/src/Model/Table/CousTable.php @@ -0,0 +1,307 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + $this->addBehavior('Tree'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('Cos'); + // AR-COU-2 A COU may not be deleted if it has any children. + $this->belongsTo('Cous') + ->setForeignKey('parent_id') + // Property is set so ruleValidateCO can find it. We don't use the + // _id suffix to match Cake's default pattern. + ->setProperty('parent'); + + // AR-COU-6 If a COU is deleted, the special groups associated with the COU will also be deleted. + $this->hasMany('Groups') + ->setDependent(true) + ->setCascadeCallbacks(true); + // AR-COU-1 A COU may not be deleted if it has any members. + $this->hasMany('PersonRoles'); + $this->hasMany('SyncCouPipelines') + ->setClassName('Pipelines') + ->setForeignKey('sync_cou_id'); + $this->hasMany('SyncReplaceCouPipelines') + ->setClassName('Pipelines') + ->setForeignKey('sync_replace_cou_id'); + + $this->setDisplayField('name'); + + $this->setPrimaryLink('co_id'); + $this->setRequiresCO(true); + + $this->setAutoViewVars([ + 'parent_ids' => [ + 'type' => 'parent' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Define business rules. + * + * @since COmanage Registry v5.0.0 + * @param RulesChecker $rules RulesChecker object + * @return RulesChecker + */ + + public function buildRules(RulesChecker $rules): RulesChecker { + // AR-CO-3 Two COUs within the same CO cannot share the same name + $rules->add($rules->isUnique(['name', 'co_id'], __d('error', 'exists', [__d('controller', 'Cous', [1])]))); + + // This is not an Application Rule per se, but the parent_id must be a valid + // potential parent + $rules->add([$this, 'rulePotentialParent'], + 'potentialParent', + ['errorField' => 'parent_id']); + + return $rules; + } + + /** + * Callback after model save. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Event + * @param EntityInterface $entity Entity (ie: Co) + * @param ArrayObject $options Save options + * @return bool True on success + */ + + public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options) { + if(!empty($entity->id)) { + if($entity->isNew()) { + // Run setup for new COU + + $this->setup(id: $entity->id, coId: $entity->co_id); + } elseif($entity->getOriginal('name') != $entity->get('name')) { + // AR-COU-5 The name was changed, so we may need to update the system groups + + $this->Groups->addDefaults(coId: $entity->co_id, couId: $entity->id, rename: true); + } + } + + if($entity->isNew() && !empty($entity->id)) { + // Run setup for new COU + + $this->setup(id: $entity->id, coId: $entity->co_id); + } + + return true; + } + + /** + * Marshal object data for provisioning. + * + * @since COmanage Registry v5.0.0 + * @param int $id Entity ID + * @return array An array of provisionable data and eligibility + */ + + public function marshalProvisioningData(int $id): array { + $ret = []; + // We need the archived record on delete to properly deprovision + $ret['data'] = $this->get($id, ['archived' => true]); + + // Provisioning Eligibility is + // - Deleted if the changelog deleted flag is true + // - Eligible otherwise (COUs don't currently have a suspended status) + + $ret['eligibility'] = ProvisioningEligibilityEnum::Eligible; + + if($ret['data']->deleted) { + $ret['eligibility'] = ProvisioningEligibilityEnum::Deleted; + } + + return $ret; + } + + /** + * Assemble the set of potential parent COUs. + * + * @since COmanage Registry v5.0.0 + * @param int $coId CO ID + * @param int $id COU ID to determine potential parents of, or null for any (or a new) COU + * @param bool $hierarchy Render the hierarchy in the name + * @return Array Array of COU IDs and COU Names + * @todo Make a TreeTrait and move the function there + */ + + public function potentialParents(int $coId, int $id=null, bool $hierarchy=false) { + // Note prior to v5 we filtered child COUs, meaning a COU couldn't be reassigned + // to be a child of a current child. It's not clear why we imposed that restriction. + + $query = null; + + if($hierarchy) { + $query = $this->find('treeList', ['spacer' => '-']); + } else { + $query = $this->find('list'); + } + + $query = $query->where(['co_id' => $coId]) + // true overrides the default Cake order for treeList so we get + // our items sorted alphabetically instead of by tree ID + ->order(['name' => 'ASC'], true); + + if($id) { + $query = $query->where(['id <>' => $id]); + } + + return $query->toArray(); + } + + /** + * Application Rule to determine if the parent ID is a potential parent. + * + * @since COmanage Registyr v5.0.0 + * @param Entity $entity Entity to be validated + * @param array $options Application rule options + * @return boolean true if the Rule check passes, false otherwise + */ + + public function rulePotentialParent($entity, $options) { + // We want negative logic since we want to fail if we're editing the COmanage CO + if(!empty($entity->parent_id)) { + $potentialParents = $this->potentialParents( + coId: $entity->co_id, + id: (!empty($entity->id) ? $entity->id : null) + ); + + if(!isset($potentialParents[$entity->parent_id])) { + return __d('error', 'cou.parent'); + } + } + + return true; + } + + /** + * Perform initial setup for a COU. + * + * @since COmanage Registry v5.0.0 + * @param int $id COU ID + * @param int $coId CO ID + * @return bool True on success + */ + + public function setup(int $id, int $coId): bool { + // AR-COU-4 Create the default groups + $this->Groups->addDefaults(coId: $coId, couId: $id); + + return true; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('co_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('co_id'); + + $this->registerStringValidation($validator, $schema, 'name', true); + + $this->registerStringValidation($validator, $schema, 'description', false); + + $validator->add('parent_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('parent_id'); + + $validator->add('lft', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('lft'); + + $validator->add('rght', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('rght'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/DashboardsTable.php b/app/src/Model/Table/DashboardsTable.php new file mode 100644 index 000000000..8a10f22f2 --- /dev/null +++ b/app/src/Model/Table/DashboardsTable.php @@ -0,0 +1,91 @@ +addBehavior('Changelog'); + $this->addBehavior('Timestamp'); + $this->addBehavior('Timezone'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('Cos'); + + $this->setDisplayField('name'); + + $this->setPrimaryLink('co_id'); + $this->setRequiresCO(true); + $this->setAllowUnkeyedPrimaryCO([ + 'artifacts', + 'configuration', + 'dashboard', + 'registries', + 'search'] + ); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + /* + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin']*/ + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'artifacts' => ['platformAdmin', 'coAdmin'], + 'configuration' => ['platformAdmin', 'coAdmin'], + // XXX CFM-230 This needs to be updated for actual Dashboard permissions + 'dashboard' => ['platformAdmin', 'coAdmin', 'coMember'], + 'registries' => ['platformAdmin', 'coAdmin'], + 'search' => ['platformAdmin', 'coAdmin'] + /* 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin']*/ + ] + ]); + } +} \ No newline at end of file diff --git a/app/src/Model/Table/EmailAddressesTable.php b/app/src/Model/Table/EmailAddressesTable.php new file mode 100644 index 000000000..e1a97431f --- /dev/null +++ b/app/src/Model/Table/EmailAddressesTable.php @@ -0,0 +1,233 @@ + [ + 'delivery', + 'forwarding', + 'list', + 'official', + 'personal', + 'preferred', + 'recovery' + ] + ]; + + /** + * Provide the default layout + * + * @since COmanage Registry v5.0.0 + * @return string Type of redirect + */ + public function getLayout(): string { + return "iframe"; + } + + /** + * Perform Cake Model initialization. + * + * @since COmanage Registry v5.0.0 + * @param array $config Configuration options passed to constructor + */ + + public function initialize(array $config): void { + // Timestamp behavior handles created/modified updates + $this->addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Secondary); + + // Define associations + $this->belongsTo('People'); + $this->belongsTo('ExternalIdentities'); + $this->belongsTo('Types'); + + $this->setDisplayField('mail'); + + $this->setPrimaryLink(['external_identity_id', 'person_id']); + $this->setAllowLookupPrimaryLink(['primary']); + $this->setRequiresCO(true); + $this->setRedirectGoal('self'); + $this->setAllowLookupPrimaryLink(['unfreeze']); + + $this->setAutoViewVars([ + 'types' => [ + 'type' => 'type', + 'attribute' => 'EmailAddresses.type' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'unfreeze' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that are permitted on readonly entities (besides view) + 'readOnly' => ['unfreeze'], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Callback after model save. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Event + * @param EntityInterface $entity Entity (ie: Co) + * @param ArrayObject $options Save options + * @return bool True on success + */ + + public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { + $this->recordHistory($entity); + + return true; + } + + /** + * Look up a Person ID from an email address and email address type ID. + * Only verified addresses can be used for lookups. + * + * @since COmanage Registry v5.0.0 + * @param int $typeId Email Address Type ID + * @param string $identifier Email Address + * @return int Person ID + * @throws Cake\Datasource\Exception\RecordNotFoundException + */ + + public function lookupPerson(int $typeId, string $identifier): int { + // The second parameter is called $identifier for consistency with IdentifiersTable::lookupPerson() + $id = $this->find() + ->where([ + 'LOWER(mail)' => strtolower($identifier), + 'type_id' => $typeId, + 'verified' => true, + 'person_id IS NOT NULL' + ]) + ->firstOrFail(); + + return $id->person_id; + } + + /** + * Perform a keyword search. + * + * @since COmanage Registry v5.0.0 + * @param int $coId CO ID to constrain search to + * @param string $q String to search for + * @param int $limit Search limit + * @return Array Array of search results, as from find('all) + */ + + public function search(int $coId, string $q, int $limit) { + return $this->find() + ->where([ + 'LOWER(EmailAddresses.mail)' => strtolower($q), + 'People.co_id' => $coId + ]) + ->limit($limit) + ->contain(['People' => 'PrimaryName']) + ->all(); + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + * @throws InvalidArgumentException + * @throws RecordNotFoundException + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $this->registerPrimaryKeyValidation($validator, $this->getPrimaryLinks()); + + $this->registerStringValidation($validator, $schema, 'mail', true); + $validator->add('mail', [ + 'content' => ['rule' => ['email'], + 'message' => __d('error', 'input.invalid.email')] + ]); + $validator->notEmptyString('mail'); + + $validator->add('type_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('type_id'); + + $validator->add('verified', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('verified'); + + $this->registerStringValidation($validator, $schema, 'description', false); + + $validator->add('frozen', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('frozen'); + + $validator->add('source_email_address_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('source_email_address_id'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/ExtIdentitySourceRecordsTable.php b/app/src/Model/Table/ExtIdentitySourceRecordsTable.php new file mode 100644 index 000000000..fec143baf --- /dev/null +++ b/app/src/Model/Table/ExtIdentitySourceRecordsTable.php @@ -0,0 +1,130 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Artifact); + + // Define associations + $this->belongsTo('ExternalIdentities'); + $this->belongsTo('ExternalIdentitySources'); + + $this->setDisplayField('source_key'); + + $this->setPrimaryLink(['external_identity_source_id']); + $this->setRequiresCO(true); + + $this->setViewContains([ + 'ExternalIdentitySources' + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, + 'edit' => false, + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('external_identity_source_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('external_identity_source_id'); + + $this->registerStringValidation($validator, $schema, 'source_key', true); + +// Since source_record comes from upstream, it's not clear that we should +// enforce any validation on it +// $this->registerStringValidation($validator, $schema, 'source_record', false); + + $validator->add('last_updane', [ + 'content' => ['rule' => 'dateTime'] + ]); + $validator->allowEmptyString('last_update'); + + $validator->add('external_identity_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('external_identity_id'); + + $this->registerStringValidation($validator, $schema, 'reference_identifier', false); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/ExternalIdentitiesTable.php b/app/src/Model/Table/ExternalIdentitiesTable.php new file mode 100644 index 000000000..83fdaa6a2 --- /dev/null +++ b/app/src/Model/Table/ExternalIdentitiesTable.php @@ -0,0 +1,336 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Secondary); + + // Define associations + $this->belongsTo('People'); + +// External Identities do not have Primary Names +// $this->hasOne('PrimaryName') +// ->setClassName('Names'); +// ->setConditions(['PrimaryName.primary_name' => true]); + $this->hasMany('Names') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('Addresses') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('AdHocAttributes') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('EmailAddresses') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('ExternalIdentityRoles') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('ExtIdentitySourceRecords') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('HistoryRecords') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('Identifiers') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('JobHistoryRecords') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('Pronouns') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('TelephoneNumbers') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('Urls') + ->setDependent(true) + ->setCascadeCallbacks(true); + + $this->setDisplayField('id'); + + $this->setPrimaryLink('person_id'); + $this->setRequiresCO(true); + $this->setRedirectGoal('self'); + + $this->setEditContains([ + 'Addresses', + 'AdHocAttributes', + 'EmailAddresses', + 'Identifiers', + 'Names', + //'ExternalIdentityRoles', + 'Pronouns', + 'TelephoneNumbers', + 'Urls' + ]); + + $this->setIndexContains(['Names']); + + $this->setViewContains([ + 'Addresses', + 'AdHocAttributes', + 'EmailAddresses', + 'ExtIdentitySourceRecords' => ['ExternalIdentitySources'], + 'Identifiers', + 'Names', + 'Pronouns', + 'TelephoneNumbers', + 'Urls' + ]); + + $this->setAutoViewVars([ + 'statuses' => [ + 'type' => 'enum', +// XXX maybe this (and EIRoles) should be SuspendableStatusEnum? + 'class' => 'StatusEnum' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) +// See also CFM-126 +// XXX need to add couAdmin, eventually + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ], + // Related models whose permissions we'll need, typically for table views + 'related' => [ + 'Names', + 'Addresses', + 'AdHocAttributes', + 'EmailAddresses', + 'ExternalIdentityRoles', + 'ExtIdentitySourceRecords', + 'HistoryRecords', + 'Identifiers', + 'JobHistoryRecords', + 'Pronouns', + 'TelephoneNumbers', + 'Urls' + ] + ]); + } + + /** + * Callback before model delete. + * + * @since COmanage Registry v5.0.0 + * @param CakeEventEvent $event The beforeDelete event + * @param $entity Entity + * @param ArrayObject $options Options + * @return boolean True on success + */ + + public function beforeDelete(\Cake\Event\Event $event, $entity, \ArrayObject $options) { + // If we were only dealing with hard delete, we wouldn't need implementedEvents() + // below, because ChangelogBehavior ignores hard deletes. + + // Manually delete any names, since the validation rules will fail on cascade. + // Since this isn't a hard delete we can't use deleteAll since we need + // ChangelogBehavior to fire. + + $names = $this->Names->find()->where(['external_identity_id' => $entity->id])->all(); + + foreach($names as $n) { + $this->Names->delete($n, ['checkRules' => false]); + } + + return true; + } + + /** + * Table specific logic to generate a display field. + * + * @since COmanage Registry v5.0.0 + * @param ExternalIdentity $entity Entity to generate display field for + * @return string Display field + */ + + public function generateDisplayField(\App\Model\Entity\ExternalIdentity $entity): string { + return $entity->names[0]->full_name; + } + + /** + * Define the table's implemented events. + * + * @since COmanage Registry v5.0.0 + */ + + public function implementedEvents(): array { + $events = parent::implementedEvents(); + + // We need to adjust our beforeDelete priority to run before ChangelogBehavior's. + $events['Model.beforeDelete'] = [ + 'callable' => 'beforeDelete', + 'priority' => 1 + ]; + + return $events; + } + + /** + * Callback after model save. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Event + * @param EntityInterface $entity Entity (ie: Co) + * @param ArrayObject $options Save options + * @return bool True on success + */ + + public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { + $this->recordHistory($entity); + + return true; + } + + /** + * Recalculate External Identity status based on External Identity Roles status. + * + * @since COmanage Registry v5.0.0 + * @param int $id External Identity ID + * @return string New External Identity status + */ + + public function recalculateStatus(int $id): ?string { + $newStatus = null; + + // Start by pulling the roles for this External Identity, along with the EI record + + $externalIdentity = $this->get($id, ['contain' => 'ExternalIdentityRoles']); + + if(!empty($externalIdentity->external_identity_roles)) { + foreach($externalIdentity->external_identity_roles as $role) { + if(!$newStatus) { + // This is the first role, just set the new status to it + + $newStatus = $role->status; + } else { + // Check if this role's status is more preferable than the current status + + if(ExternalIdentityStatusEnum::rank($role->status) > ExternalIdentityStatusEnum::rank($newStatus)) { + $newStatus = $role->status; + } + } + } + } + + if($newStatus) { + if($newStatus != $externalIdentity->status) { + // Update the External Identity status + $oldStatus = $externalIdentity->status; + $externalIdentity->status = $newStatus; + $this->save($externalIdentity); + + // Record history + $this->recordHistory( + entity: $externalIdentity, + action: ActionEnum::PersonStatusRecalculated, + comment: __d('result', + 'ExternalIdentities.status.recalculated', + [__d('enumeration', 'ExternalIdentityStatusEnum.'.$oldStatus), + __d('enumeration', 'ExternalIdentityStatusEnum.'.$newStatus)]) + ); + } + // else nothing to do, status is unchanged + } + // else no roles, leave status unchanged + + return $newStatus; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $this->registerPrimaryKeyValidation($validator, $this->getPrimaryLinks()); + + $this->registerStringValidation($validator, $schema, 'source_key', true); + + $validator->add('status', [ + 'content' => ['rule' => ['inList', ExternalIdentityStatusEnum::getConstValues()]] + ]); + $validator->notEmptyString('status'); + + $validator->add('date_of_birth', [ + 'content' => ['rule' => 'date'] + ]); + $validator->allowEmptyString('date_of_birth'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/ExternalIdentityRolesTable.php b/app/src/Model/Table/ExternalIdentityRolesTable.php new file mode 100644 index 000000000..b24377067 --- /dev/null +++ b/app/src/Model/Table/ExternalIdentityRolesTable.php @@ -0,0 +1,283 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Secondary); + + // Define associations + $this->belongsTo('ExternalIdentities'); + $this->belongsTo('Types') + ->setForeignKey('affiliation_type_id') + // Property is set so ruleValidateCO can find it. We don't use the + // _id suffix to match Cake's default pattern. + ->setProperty('affiliation_type'); + + $this->hasMany('Addresses') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('AdHocAttributes') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('PersonRoles') + ->setForeignKey('source_external_identity_role_id') + ->setProperty('source_external_identity_role'); + // We don't want these to cascade deletes, see beforeDelete() + $this->hasMany('TelephoneNumbers') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('HistoryRecords') + ->setDependent(true) + ->setCascadeCallbacks(true); + + $this->setDisplayField('id'); + + $this->setPrimaryLink('external_identity_id'); + $this->setRequiresCO(true); + $this->setRedirectGoal('self'); + + $this->setEditContains([ + 'Addresses', + 'AdHocAttributes', + 'TelephoneNumbers' + ]); + + $this->setViewContains([ + 'Addresses', + 'AdHocAttributes', + 'TelephoneNumbers' + ]); + + $this->setAutoViewVars([ + 'statuses' => [ + 'type' => 'enum', + 'class' => 'StatusEnum' + ], + 'affiliationTypes' => [ + 'type' => 'type', + 'attribute' => 'PersonRoles.affiliation_type' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) +// See also CFM-126 +// XXX need to add couAdmin, eventually + 'entity' => [ + 'delete' => false, + 'edit' => false, + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Callback before model delete. + * + * @since COmanage Registry v5.0.0 + * @param CakeEventEvent $event The beforeDelete event + * @param $entity Entity + * @param ArrayObject $options Options + * @return boolean True on success + */ + + public function beforeDelete(\Cake\Event\Event $event, $entity, \ArrayObject $options) { + // Is there a Person Role associated with this EI Role? + if(!empty($entity->id)) { + $prole = $this->PersonRoles->find() + ->where(['PersonRoles.source_external_identity_role_id' => $entity->id]) + ->first(); + + if(!empty($prole)) { + // Unset the foreign key to the source EI Role so we don't cascade + // deletes or otherwise mess things up. + + $this->llog('trace', "Removing link from PersonRole " . $prole->id . " to source ExternalIdentityRole " . $entity->id); + + $prole->source_external_identity_role_id = null; + $this->PersonRoles->saveOrFail($prole); + } + } + + $this->recordHistory(entity: $entity, action: ActionEnum::MVEADeleted); + + return true; + } + + /** + * Table specific logic to generate a display field. + * + * @since COmanage Registry v5.0.0 + * @param Person $entity Entity to generate display field for + * @return string Display field + */ + + public function generateDisplayField(\App\Model\Entity\ExternalIdentityRole $entity): string { + // Try to find something renderable + + if(!empty($entity->title)) { + return $entity->title; + } + +// XXX else affiliation type if set, else organization, else department + + return (string)$entity->id; + } + + /** + * Define the table's implemented events. + * + * @since COmanage Registry v5.0.0 + */ + + public function implementedEvents(): array { + $events = parent::implementedEvents(); + + // We need to adjust our beforeDelete priority to run before ChangelogBehavior's. + $events['Model.beforeDelete'] = [ + 'callable' => 'beforeDelete', + 'priority' => 1 + ]; + + return $events; + } + + /** + * Callback after model save. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Event + * @param EntityInterface $entity Entity (ie: Co) + * @param ArrayObject $options Save options + * @return bool True on success + */ + + public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { + if(!$entity->deleted) { + $this->recordHistory($entity); + + if($entity->isDirty('status')) { + $this->ExternalIdentities->recalculateStatus($entity->external_identity_id); + } + } + + return true; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $this->registerPrimaryKeyValidation($validator, $this->getPrimaryLinks()); + + $this->registerStringValidation($validator, $schema, 'role_key', true); + + $validator->add('affiliation_type_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('affiliation_type_id'); + + $this->registerStringValidation($validator, $schema, 'title', false); + + $this->registerStringValidation($validator, $schema, 'organization', false); + + $this->registerStringValidation($validator, $schema, 'department', false); + + $this->registerStringValidation($validator, $schema, 'manager_identifier', false); + + $this->registerStringValidation($validator, $schema, 'sponsor_identifier', false); + + $validator->add('valid_from', [ + 'content' => ['rule' => 'dateTime'] + ]); + $validator->allowEmptyString('valid_from'); + + $validator->add('valid_through', [ + 'content' => ['rule' => 'dateTime'] + ]); + $validator->allowEmptyString('valid_through'); + + $validator->add('status', [ + 'content' => ['rule' => ['inList', ExternalIdentityStatusEnum::getConstValues()]] + ]); + $validator->notEmptyString('status'); + + $validator->add('ordr', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('ordr'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/ExternalIdentitySourcesTable.php b/app/src/Model/Table/ExternalIdentitySourcesTable.php new file mode 100644 index 000000000..3bfa4ab2c --- /dev/null +++ b/app/src/Model/Table/ExternalIdentitySourcesTable.php @@ -0,0 +1,237 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('Cos'); + $this->belongsTo('Pipelines'); + + $this->hasMany('ExtIdentitySourceRecords') + ->setDependent(true) + ->setCascadeCallbacks(true); + + $this->setPluginRelations(); + + $this->setDisplayField('description'); + + $this->setPrimaryLink(['co_id']); + $this->setRequiresCO(true); + // We need to calculate the redirect URL for sync ourselves (in the controller) + $this->setRedirectGoal('special', 'sync'); + $this->setAllowLookupPrimaryLink(['retrieve', 'search', 'sync']); + + $this->setAutoViewVars([ + 'plugins' => [ + 'type' => 'plugin', + 'pluginType' => 'source' + ], + 'pipelines' => [ + 'type' => 'select', + 'model' => 'Pipelines' + ], + 'statuses' => [ + 'type' => 'enum', + 'class' => 'SyncModeEnum' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'configure' => ['platformAdmin', 'coAdmin'], + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'retrieve' => ['platformAdmin', 'coAdmin'], + 'search' => ['platformAdmin', 'coAdmin'], + 'sync' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'], + 'status' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Retrieve a record from an External Identity Source. + * + * @since COmanage Registry v5.0.0 + * @param int $id External Identity Source ID + * @param string $source_key EIS Backend Source Key + * @return array Array of source_key, source_record, and entity_data + */ + + public function retrieve(int $id, string $source_key): array { + // We want to pull the plugin configuration along with the EIS, to make + // the query simpler we contain all possible relations, which will + // usually only be a small number. + $source = $this->get($id, ['contain' => $this->getPluginRelations()]); + + $pModel = StringUtilities::pluginModel($source->plugin); + + $record = $this->$pModel->retrieve($source, $source_key); + + // Inject the source key so every backend doesn't have to do this + $record['entity_data']['source_key'] = $source_key; + + return $record; + } + + /** + * Search the External Identity Source. + * + * @since COmanage Registry v5.0.0 + * @param int $id External Identity Source ID + * @param array $searchAttrs Array of search attributes and values, as configured by searchAttributes() + * @return array Array of matching records + */ + + public function search(int $id, array $attrs): array { + // We want to pull the plugin configuration along with the EIS, to make + // the query simpler we contain all possible relations, which will + // usually only be a small number. + $source = $this->get($id, ['contain' => $this->getPluginRelations()]); + + $pModel = StringUtilities::pluginModel($source->plugin); + + return $this->$pModel->search($source, $attrs); + } + + /** + * Obtain the set of searchable attributes for this backend. + * + * @since COmanage Registry v5.0.0 + * @return array Array of searchable attributes and localized descriptions + */ + + public function searchableAttributes(int $id) { + $pModel = $this->pluginModelForEntityId($id); + + return $pModel->searchableAttributes(); + } + + /** + * Sync an External Identity from a Source to a Person via a Pipeline. + * + * @since COmanage Registry v5.0.0 + * @param int $id External Identity Source ID + * @param string $source_key EIS Backend Source Key + */ + + public function sync(int $id, string $source_key) { + // All work is actually handled by the Pipeline, but we need our configuration + // to know which Pipeline. + $eis = $this->get($id); + + // Also get the current record from the Backend, which might have been deleted + $eisBackendRecord = $this->retrieve($id, $source_key); + + $this->Pipelines->execute( + id: $eis->pipeline_id, + eisId: $id, + eisBackendRecord: $eisBackendRecord, + // Force the full Pipeline run even if the backend record didn't change + force: true + ); + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('co_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('co_id'); + + $this->registerStringValidation($validator, $schema, 'description', false); + + $validator->add('status', [ + 'content' => ['rule' => ['inList', SyncModeEnum::getConstValues()]] + ]); + $validator->notEmptyString('status'); + + $this->registerStringValidation($validator, $schema, 'plugin', true); + + $this->registerStringValidation($validator, $schema, 'sor_label', false); + + $validator->add('pipeline_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('pipeline_id'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/GroupMembersTable.php b/app/src/Model/Table/GroupMembersTable.php new file mode 100644 index 000000000..c5a697a72 --- /dev/null +++ b/app/src/Model/Table/GroupMembersTable.php @@ -0,0 +1,503 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + $this->addBehavior('Timezone'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Secondary); + + // Define associations + $this->belongsTo('GroupNestings'); + $this->belongsTo('Groups'); + $this->belongsTo('People'); + + $this->setDisplayField('id'); + + $this->setPrimaryLink(['group_id', 'person_id']); + $this->setRequiresCO(true); + + $this->setEditContains(['Groups', 'People.PrimaryName']); + + $this->setIndexContains([ + 'GroupNestings' => 'Groups', + 'Groups', + 'People.PrimaryName' + ]); + + $this->setPermissions([ + // XXX update for couAdmins, group owners, etc + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Define business rules. + * + * @since COmanage Registry v5.0.0 + * @param RulesChecker $rules RulesChecker object + * @return RulesChecker + */ + + public function buildRules(RulesChecker $rules): RulesChecker { + // AR-GroupMember-1 A Person cannot have two manually created GroupMember + // records for the same Group. + $rules->addCreate([$this, 'ruleIsGroupMember'], + 'isGroupMember', + ['errorField' => 'person_id']); + + return $rules; + } + + /** + * Table specific logic to generate a display field. + * + * @since COmanage Registry v5.0.0 + * @param Person $entity Entity to generate display field for + * @return string Display field + */ + + public function generateDisplayField(\App\Model\Entity\GroupMember $entity): string { + // Pull the group and person information to build a more useful display string + + return __d('field', 'group_membership', [$entity->person->primary_name->full_name, $entity->group->name]); + } + + /** + * Determine if the specified Person is a member of the specified Group. + * + * @since COmanage Registry v5.0.0 + * @param int $groupId Group ID + * @param int $personId Person ID + * @param bool $direct If true, the Person must be a direct member of the Group + * @param bool $checkValidity If true, check valid_from and valid_through dates + * @return bool true if Person is a member of Group, false otherwise + */ + + public function isMember(int $groupId, + int $personId, + bool $direct=false, + bool $checkValidity=true): bool { + // This function is here (instead of GroupsTable) because we need it for + // rule validation on new GroupMember save. + + $conditions = [ + 'group_id' => $groupId, + 'person_id' => $personId + ]; + + if($checkValidity) { + // Only pull currently valid group memberships + + $conditions['AND'][] = [ + 'OR' => [ + 'valid_from IS NULL', + 'valid_from < ' => date('Y-m-d H:i:s', time()) + ] + ]; + $conditions['AND'][] = [ + 'OR' => [ + 'valid_through IS NULL', + 'valid_through > ' => date('Y-m-d H:i:s', time()) + ] + ]; + } + + if($direct) { +// XXX need to add pipelines here eventually + $conditions[] = 'group_nesting_id IS NULL'; + } + + $count = $this->find()->where($conditions)->count(); + + // When !$direct, we could get more than one row back + return ($count > 0); + } + + /** + * Callback after model save. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Event + * @param EntityInterface $entity Entity (ie: Co) + * @param ArrayObject $options Save options + * @return bool True on success + */ + + public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { + // Pull the related entities for HistoryRecord comment creation. + $person = $this->People->get($entity->person_id, ['contain' => ['PrimaryName']]); + $group = $this->Groups->get($entity->group_id); + + $action = null; + $langKey = ''; + $langKeySuffix = ''; + $commentParams = [ + (!empty($person->primary_name) ? $person->primary_name->full_name : "?"), + $group->name + ]; + + if(!empty($entity->group_nesting_id)) { + // We need to allow retrieval of archived records since we might be called + // after the GroupNesting was deleted + $nesting = $this->GroupNestings->get($entity->group_nesting_id, + ['contain' => ['Groups'], + 'archived' => true]); + + $langKeySuffix = '.nesting'; + $commentParams[] = $nesting->group->name; + $commentParams[] = $entity->group_nesting_id; + } + + if($entity->isNew()) { + $action = ActionEnum::GroupMemberAdded; + $langKey = 'GroupMembers.added'; + } elseif($entity->get('deleted')) { + $action = ActionEnum::GroupMemberDeleted; + $langKey = 'GroupMembers.deleted'; + } else { + $action = ActionEnum::GroupMemberEdited; + $langKey = 'GroupMembers.edited'; + $commentParams[] = $this->changesToString($entity); + } + + $comment = __d('result', $langKey . $langKeySuffix, $commentParams); + + $this->recordHistory($entity, $action, $comment); + + // On save, we pull any nestings where this Group is the source and sync + // memberships for the target. (Any membership changes should then recurse.) + + $groupNestings = $this->GroupNestings->find() + ->where(['GroupNestings.group_id' => $entity->group_id]) + ->contain(['TargetGroups']) + ->all(); + + foreach($groupNestings as $groupNesting) { + $this->syncNestedMembership($entity->person_id, $groupNesting->target_group); + } + + return true; + } + + /** + * Application Rule to determine if the Person is already a member of the Group. + * + * @since COmanage Registyr v5.0.0 + * @param Entity $entity Entity to be validated + * @param array $options Application rule options + * @return boolean true if the Rule check passes, false otherwise + */ + + public function ruleIsGroupMember($entity, $options) { + // We don't allow the same Person to be manually added to the same Group + // twice, though they could have a separate membership via Nestings or + // EIS Pipelines. + + if($this->isMember($entity->group_id, $entity->person_id, true, false)) { + // Pull the Person and Group name for the error message. + $person = $this->People->get($entity->person_id, ['contain' => ['PrimaryName']]); + $group = $this->Groups->get($entity->group_id); + + return __d('error', 'exists.GroupMember', [$person->primary_name->full_name, $group->name]); + } + + return true; + } + + /** + * Sync an automatic group membership. + * + * @since COmanage Registry v5.0.0 + * @param GroupTypeEnum $groupType Type of Group to sync membership + * @param int $couId COU ID, or null for CO level groups + * @param int $personId Person ID of member + * @param bool $eligible Whether the person should be in the group + * @param bool $provision Whether to run provisioners + * @throws InvalidArgumentException + */ + + public function syncAutomaticMembership(string $groupType, + ?int $couId, + int $personId, + bool $eligible, + bool $provision=true) { + // Find the CO from the Person + $coId = $this->People->findCoForRecord($personId); + + if(!$coId) { + throw new \InvalidArgumentException(__d('error', 'notfound', __d('controller', 'People'))); + } + + // Find the requested group + $targetGroup = $this->Groups->find() + ->where([ + 'co_id' => $coId, + 'group_type' => $groupType, + // $couId will be null for CO level groups + 'cou_id IS' => $couId + ]) + ->firstOrFail(); + + // Is $personId already a member? We don't use $this->isMember because we + // may delete this record, below. + + $memberEntity = $this->find()->where(['group_id' => $targetGroup->id, 'person_id' => $personId])->first(); + $isMember = !empty($memberEntity); + + $hAction = null; + + if($eligible && !$isMember) { + // Add a membership + + $membership = [ + 'group_id' => $targetGroup->id, + 'person_id' => $personId + ]; + + $entity = $this->newEntity($membership); + +// XXX need to make sure $provision is honored here + $this->saveOrFail($entity, ['provision' => $provision]); + $this->llog('rule', "Added automatic membership for Person ID $personId to Group ID " . $targetGroup->id); + } elseif(!$eligible && $isMember) { + // Remove the membership + + $this->delete($memberEntity); + $this->llog('rule', "Removed automatic membership for Person ID $personId from Group ID " . $targetGroup->id); + } + // else nothing to do + } + + public function syncNestedMembership(int $personId, + \Cake\Datasource\EntityInterface $targetGroup, + //\Cake\ORM\ResultSet $groupNestings, + //bool $eligible, // XXX still needed? + bool $provision=true) { + // The operation we perform (add or delete) may be inverted by the CoGroupNesting + // configuration. + + // Our pseudologic for what to do here is as follows: + // t = isMemberOf($targetGroup) + // t' = shouldBeMemberOf($targetGroup) + // + // if(t && !t') addTo($targetGroup) + // elseif(!t && t') removeFrom($targetGroup) + + // $coPersonId should be a member of $targetGroup if any of the following are true + // (1) Nesting/Negate = false + // AND TargetGroup/Mode = any + // AND $sourceMember + // AND not a member of any source group for target where Nesting/Negate = true + // (2) Nesting/Negate = false + // AND TargetGroup/Mode = all + // AND member of all source groups for target + // AND not a member of any source group for target where Nesting/Negate = true + // (3) Nesting/Negate = true + // AND TargetGroup/Mode = any + // AND !$sourceMember + // AND member of any non-negated source group for target + // AND not a member of any source group for target where Nesting/Negate = true + // (4) Nesting/Negate = true + // AND TargetGroup/Mode = all + // AND !$sourceMember + // AND member of all non-negated source groups for target + // AND not a member of any source group for target where Nesting/Negate = true + + // As of v5.0.0, we clarify that if a Person is a member of multiple source + // Groups that convey nested membership into the target Group, we will create + // one membership _for each nesting_, regardless of the nesting mode. ie: + // if nesting_mode_all is true, then once the Person is a member of all + // source groups, they will receive the same number of memberships. + + // Pull the set of nestings for the target group. + + $groupNestings = $this->GroupNestings->find() + ->where(['GroupNestings.target_group_id' => $targetGroup->id]) + ->all(); + + // We convert $groupNestings to an array to avoid any confusion with the + // nested foreach() loops. Note that (unlike v4) we do not need to check for + // suspended Group status here since Groups cannot be suspended if they are + // nested (AR-Group-2), and cannot be nested if they are suspended (AR-GroupNesting-1). + // (This prevents admins from inadvertantly messing things up.) + foreach($groupNestings->toArray() as $groupNesting) { + $shouldBe = false; // Should $person be a member of $targetGroup? + $negated = false; // $person is ineligible for $targetGroup due to any negative membership + $isAny = false; // $person is a member of any (positive) source group for $targetGroup + $isAll = false; // $person is a member of all (positive) source groups for $targetGroup + $isCurrent = false; // $person is a member of $targetGroup due to $groupNesting + + // Walk all nestings to determine negation and current memberships. To track + // $isAll, we need at least one positive membership. In other words, a Target + // Group with only one Nesting, and that one Nesting is negative, does not + // automatically make everybody else a member. + $pAvail = 0; // Available positive memberships + $pCount = 0; // Actual positive memberships + + // Don't conflict with the outer foreach... + foreach($groupNestings->toArray() as $n) { + if($n->negate) { + // If this is the current nesting we don't need to look anything up + //if((($n->id == $groupNesting->id) && $sourceMember) + // || + if($this->isMember($n->group_id, $personId)) { + $negated = true; + } + } else { + $pAvail++; + + if($this->isMember($n->group_id, $personId)) { + $isAny = true; + $pCount++; + } + } + } + + // We need at least one positive group to count as ALL + $isAll = ($pAvail > 0 && $pCount == $pAvail); + + if(!$negated && !$targetGroup->nesting_mode_all && $isAny) { + // Case (1) and (3) + $shouldBe = true; + } elseif(!$negated && $targetGroup->nesting_mode_all && $isAll) { + // Case (2) and (4) + $shouldBe = true; + } + + // Is $personId already a member via this Nesting? + $memberEntity = $this->find() + ->where([ + 'group_id' => $targetGroup->id, + 'person_id' => $personId, + 'group_nesting_id' => $groupNesting->id + ]) + ->first(); + $isCurrent = !empty($memberEntity); + + if(!$isCurrent && $shouldBe) { + // Add a GroupMember record associated with this Nesting + + $membership = [ + 'group_id' => $targetGroup->id, + 'person_id' => $personId, + 'group_nesting_id' => $groupNesting->id + ]; + + $entity = $this->newEntity($membership); + + // XXX need to make sure $provision is honored here + $this->saveOrFail($entity, ['provision' => $provision]); + $this->llog('rule', "Added nested membership for Person ID $personId to Group ID " . $targetGroup->id . " (Group Nesting ID " . $groupNesting->id . ")"); + } elseif($isCurrent && !$shouldBe) { + // Remove the GroupMember associated with this Nesting + + $this->delete($memberEntity); + $this->llog('rule', "Removed nested membership for Person ID $personId from Group ID " . $targetGroup->id . " (Group Nesting ID " . $groupNesting->id . ")"); + } + } + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('group_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('group_id'); + + $validator->add('person_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('person_id'); + + $validator->add('valid_from', [ + 'content' => ['rule' => 'dateTime'] + ]); + $validator->allowEmptyString('valid_from'); + + $validator->add('valid_through', [ + 'content' => ['rule' => 'dateTime'] + ]); + $validator->allowEmptyString('valid_through'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/GroupNestingsTable.php b/app/src/Model/Table/GroupNestingsTable.php new file mode 100644 index 000000000..806584b67 --- /dev/null +++ b/app/src/Model/Table/GroupNestingsTable.php @@ -0,0 +1,392 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Secondary); + + // Define associations + $this->belongsTo('Groups'); + $this->belongsTo('TargetGroups') + ->setClassName('Groups') + ->setForeignKey('target_group_id') + // Property is set so ruleValidateCO can find it. We don't use the + // _id suffix to match Cake's default pattern. + ->setProperty('target_group'); + + $this->hasMany('GroupMembers'); + + $this->setDisplayField('id'); + + $this->setPrimaryLink('group_id'); + $this->setRequiresCO(true); + + $this->setEditContains(['Groups', 'TargetGroups']); + + $this->setIndexContains(['Groups', 'TargetGroups']); + + $this->setPermissions([ +// XXX update for couAdmins, group owners, etc + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Obtain the set of groups available as a nesting target group for the + * specified source. + * + * @since COmanage Registry v5.0.0 + * @param int $groupId Group ID (of source Group) + * @return array Array of available target Groups, as returned by find('list') + */ + + public function availableGroups(int $groupId): array { + // Find the CO for $id. This will throw an exception if not found. + $sourceGroup = $this->Groups->get($groupId); + + // We don't remove groups that are already nested from the list -- we'll + // catch those in rule validation. + + return $this->Groups->find('list') + ->where([ + 'Groups.co_id' => $sourceGroup->co_id, + // AR-Group-Nesting-1 Only Active groups may be nested + 'Groups.status' => SuspendableStatusEnum::Active, + // AR-Group-Nesting-2 A group may not nest into itself + 'Groups.id IS NOT' => $groupId, + // AR-Group-Nesting-3 Automatic groups cannot be targets + 'OR' => [ + 'Groups.group_type NOT IN' => [GroupTypeEnum::ActiveMembers, GroupTypeEnum::AllMembers], + // Unclear why null values don't qualify for NOT IN... + 'Groups.group_type IS' => null + ] + ]) + ->order(['Groups.name' => 'ASC']) + ->toArray(); + } + + /** + * Define business rules. + * + * @since COmanage Registry v5.0.0 + * @param RulesChecker $rules RulesChecker object + * @return RulesChecker + */ + + public function buildRules(RulesChecker $rules): RulesChecker { + // Since the groups in the nesting can't be changed after creation, these + // rules only need apply on new entity creation. + + // AR-Group-Nesting-1 Only Active groups may be nested + + $rules->addCreate([$this, 'ruleIsActive'], + 'isActive', + ['errorField' => 'target_group_id']); + + // AR-Group-Nesting-2 A group may not nest into itself + + $rules->addCreate([$this, 'ruleIsNotSource'], + 'isNotSource', + ['errorField' => 'target_group_id']); + + // AR-Group-Nesting-3 A group may not nest into an Automatic group + + $rules->addCreate([$this, 'ruleIsNotAutomatic'], + 'isNotAutomatic', + ['errorField' => 'target_group_id']); + + // AR-Group-Nesting-4 A group may not nest into the same target group + // more than once. + + $rules->addCreate([$this, 'ruleAlreadyNested'], + 'alreadyNested', + ['errorField' => 'target_group_id']); + + // AR-Group-Nesting-5 Group Nestings may not loop + + $rules->addCreate([$this, 'ruleLoops'], + 'loops', + ['errorField' => 'target_group_id']); + + return $rules; + } + + /** + * Check for loops in Group Nestings. + * + * @since COmanage Registry v5.0.0 + * @param int $groupId Source Group ID + * @param int $targetId Target Group ID + * @return bool true if no loop is detected, false otherwise + */ + + protected function checkLoops(int $groupId, int $targetId): bool { + // Pull the set of nestings for which $targetId is the source. + + $nestings = $this->find('all') + ->where(['GroupNestings.group_id' => $targetId]) + ->all(); + + foreach($nestings as $n) { + // If _this_ target_group_id matches $groupId then we have a loop. + // We also fail if this target_group_id is nested into $groupId. +// XXX do we need to maxrecursion this? + + if($n->target_group_id == $groupId + || !$this->checkLoops($groupId, $n->target_group_id)) { + return false; + } + } + + return true; + } + + /** + * Determine if the requested group already nests into the specified target. + * + * @since COmanage Registry v5.0.0 + * @param int $groupId Source Group ID + * @param int $targetId Target Group ID + * @return bool True if $groupId does not already nest into $targetId, false otherwise + */ + + protected function checkParents(int $groupId, int $targetId): bool { + // Pull the set of nestings for which $groupId is the source. + + $nestings = $this->find('all') + ->where(['GroupNestings.group_id' => $groupId]) + ->all(); + + foreach($nestings as $n) { + // We fail if this nesting points to $targetId, or if any nestings + // for _this_ target_group_id point to $targetId +// XXX do we need to maxrecursion this? + if($n->target_group_id == $targetId + || !$this->checkParents($n->target_group_id, $targetId)) { + return false; + } + } + + return true; + } + + /** + * Callback after model save. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Event + * @param EntityInterface $entity Entity (ie: Co) + * @param ArrayObject $options Save options + * @return bool True on success + */ + + public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { + // XXX This is temporary until JobShell is available (CFM-169), at which + // point either all reconciliation moves to JobShell, or maybe only if + // the source group has a large number of records to process (though then + // we have to cascade, so maybe that's too hard to figure out). + $this->Groups->reconcile($entity->target_group_id); + + return true; + } + + /** + * Application Rule to determine if the source group is already nested in the target. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Entity to be validated + * @param array $options Application rule options + * @return boolean true if the Rule check passes, false otherwise + */ + + public function ruleAlreadyNested($entity, $options) { + // The first (easy) check is if there is a direct pair already recorded + + $count = $this->find('all') + ->where([ + 'GroupNestings.group_id' => $entity->group_id, + 'GroupNestings.target_group_id' => $entity->target_group_id + ]) + ->count(); + + if($count > 0 || !$this->checkParents($entity->group_id, $entity->target_group_id)) { + return __d('error', 'GroupNestings.exists'); + } + + return true; + } + + /** + * Application Rule to determine if the target group is Active. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Entity to be validated + * @param array $options Application rule options + * @return boolean true if the Rule check passes, false otherwise + */ + + public function ruleIsActive($entity, $options) { + if(!empty($entity->target_group_id)) { + // get() throws an Exception if not found + $group = $this->Groups->get($entity->target_group_id); + + if($group->status != SuspendableStatusEnum::Active) { + return __d('error', 'GroupNestings.active', [$group->name]); + } + } + + return true; + } + + /** + * Application Rule to determine if the target group is (not) the source group. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Entity to be validated + * @param array $options Application rule options + * @return boolean true if the Rule check passes, false otherwise + */ + + public function ruleIsNotAutomatic($entity, $options) { + if(!empty($entity->target_group_id)) { + // get() throws an Exception if not found + $group = $this->Groups->get($entity->target_group_id); + + if($group->isAutomatic()) { + return __d('error', 'GroupNestings.automatic', [$group->name]); + } + } + + return true; + } + + /** + * Application Rule to determine if the target group is (not) the source group. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Entity to be validated + * @param array $options Application rule options + * @return boolean true if the Rule check passes, false otherwise + */ + + public function ruleIsNotSource($entity, $options) { + if($entity->group_id == $entity->target_group_id) { + return __d('error', 'GroupNestings.same'); + } + + return true; + } + + /** + * Application Rule to determine if the target group is already nested into the + * source group. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Entity to be validated + * @param array $options Application rule options + * @return boolean true if the Rule check passes, false otherwise + */ + + public function ruleLoops($entity, $options) { + if(!$this->checkLoops($entity->group_id, $entity->target_group_id)) { + return __d('error', 'GroupNestings.loop'); + } + + return true; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('group_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('group_id'); + + $validator->add('target_group_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('target_group_id'); + + $validator->add('negate', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('negate'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/GroupsTable.php b/app/src/Model/Table/GroupsTable.php new file mode 100644 index 000000000..4dc712503 --- /dev/null +++ b/app/src/Model/Table/GroupsTable.php @@ -0,0 +1,1002 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Primary); + + // Define associations + $this->belongsTo('Cos'); + $this->belongsTo('Cous'); + + // Most Groups (except other Owners groups) have an Owner Group, + // which we should define with a hasOne relation (and a foreign key + // like owners_for_group_id). However, this doesn't intuitively define + // the relationship (having owners_group_id point to the Owners group + // is more obvious than having the Owners group fk back to the original), + // and also makes it more expensive to query the database, so we use + // a belongsTo relation instead. (This also aligns with Cou::parent_id.) + // The downside of this is we have to manually cascade deletes to the Owners + // group, since cascades don't travers belongsTo. + $this->belongsTo('OwnersGroup') + ->setClassName('Groups') + ->setForeignKey('owners_group_id') + ->setProperty('owners_group'); + + // And the inverse relation, for the Owners Group + $this->hasOne('OwnersForGroup') + ->setClassName('Groups') + ->setForeignKey('owners_group_id'); + + $this->hasMany('GroupMembers') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('GroupNestings') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('HistoryRecords') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('Identifiers') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('ProvisioningHistoryRecords') + ->setDependent(true) + ->setCascadeCallbacks(true); + + $this->setDisplayField('name'); + + $this->setPrimaryLink('co_id'); + $this->setAllowLookupPrimaryLink(['provision', 'reconcile']); + $this->setRequiresCO(true); + + $this->setEditContains([ + 'Identifiers', + // For an Owners Group, the group it manages owners for + 'OwnersForGroup', + // For a regular group, the Owners Group + 'OwnersGroup' + ]); + + $this->setViewContains([ + 'Identifiers', + // For an Owners Group, the group it manages owners for + 'OwnersForGroup', + // For a regular group, the Owners Group + 'OwnersGroup' + ]); + + $this->setAutoViewVars([ + 'statuses' => [ + 'type' => 'enum', + 'class' => 'SuspendableStatusEnum' + ], + 'groupTypes' => [ + 'type' => 'enum', + 'class' => 'GroupTypeEnum' + ] + ]); + + $this->setPermissions([ + // XXX update for couAdmins, etc + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'provision' => ['platformAdmin', 'coAdmin'], + 'reconcile' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that are permitted on readonly entities (besides view) + 'readOnly' => ['reconcile'], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + // Note that self service Group creation will be implemented via + // a Dashboard widget (CFM-316) and NOT via this index page + 'index' => ['platformAdmin', 'coAdmin'] + ], + // Related models whose permissions we'll need, typically for table views + 'related' => [ +// XXX As a first pass, this (combined with the implementation in AppController::calculatePermissions) +// will render a link to group-members?group_id=X for all groups in the index view +// groups?co_id=2. This may or may not be right in the long term, eg for private +// groups. Maybe it's OK for now, since all groups are visible to all members of the CO. + 'GroupMembers', + 'GroupNestings', + 'HistoryRecords', + 'IdentifierAssignments', + 'Identifiers', + 'ProvisioningTargets' + ] + ]); + } + + /** + * Add the system groups for a CO or COU. (AR-CO-6, AR-COU-4) + * + * @since COmanage Registry v5.0.0 + * @param int $coId CO ID + * @param int $couId COU ID + * @param bool $rename If true, rename any existing groups + * @return bool True on success + * @throws InvalidArgumentException + * @throws RuntimeException + * @throws PersistenceFailedException + */ + + public function addDefaults(int $coId, int $couId=null, bool $rename=false): bool { + // Pull the name of the CO/COU + + $Cos = TableRegistry::getTableLocator()->get('Cos'); + + try { + $co = $Cos->get($coId); + } + catch(\Cake\Datasource\Exception\RecordNotFoundException $e) { + throw new \InvalidArgumentException(__d('error', __d('controller', 'Cos', [1]))); + } + + $couName = null; + + if($couId) { + $Cous = TableRegistry::getTableLocator()->get('Cous'); + + try { + $cou = $Cous->get($couId); + } + catch(\Cake\Datasource\Exception\RecordNotFoundException $e) { + throw new \InvalidArgumentException(__d('error', 'notfound', [__d('controller', 'Cous', [1])])); + } + + $couName = $cou->name; + } + + // The names get prefixed "CO" or "CO:COU:", as appropriate + + $defaultGroups = [ + ':admins' => [ + 'group_type' => GroupTypeEnum::Admins, + 'auto' => false, + 'description' => __d('field', 'Groups.desc.admins', [$couName ?: $co->name]), + 'open' => false, + 'status' => SuspendableStatusEnum::Active, + 'cou_id' => ($couId ?: null) + ], + ':members:active' => [ + 'group_type' => GroupTypeEnum::ActiveMembers, + 'auto' => true, + 'description' => __d('field', 'Groups.desc.members.active', [$couName ?: $co->name]), + 'open' => false, + 'status' => SuspendableStatusEnum::Active, + 'cou_id' => ($couId ?: null) + ], + ':members:all' => [ + 'group_type' => GroupTypeEnum::AllMembers, + 'auto' => true, + 'description' => __d('field', 'Groups.desc.members', [$couName ?: $co->name]), + 'open' => false, + 'status' => SuspendableStatusEnum::Active, + 'cou_id' => ($couId ?: null) + ], + ]; + + foreach($defaultGroups as $suffix => $attrs) { + // Construct the full group name + $gname = "CO" . ($couName ? ":COU:".$couName : "") . $suffix; + + // See if there is already a group with this type for this CO + + $grp = $this->find() + ->where([ + 'Groups.co_id' => $coId, + 'Groups.group_type' => $attrs['group_type'], + 'Groups.cou_id IS' => $couId ?: null + ]) + ->first(); + + if(!$grp) { + // No existing group, create a new one + + $entity = $this->newEntity($attrs); + $entity->co_id = $coId; + $entity->name = $gname; + + if(!$this->save($entity)) { + throw new \RuntimeException(__d('error', 'save', ['GroupsTable::addDefaults'])); + } + } elseif($rename) { + // We already have an entity, so just update the fields we need to change + $grp->name = $gname; + $grp->description = $attrs['description']; + + if(!$this->save($grp)) { + throw new \RuntimeException(__d('error', 'save', ['GroupsTable::addDefaults'])); + } + } + } + + return true; + } + + /** + * Callback before model delete. + * + * @since COmanage Registry v5.0.0 + * @param CakeEventEvent $event The beforeDelete event + * @param $entity Entity + * @param ArrayObject $options Options + * @return boolean True on success + */ + + public function beforeDelete(EventInterface $event, $entity, \ArrayObject $options) { + // AR-Group-8 When a Group is deleted, its corresponding Owners Group is also deleted. + if(!empty($entity->owners_group_id)) { + $ownersGroup = $this->get($entity->owners_group_id); + $this->delete($ownersGroup); + + // We leave the foreign key in place on $entity in case someone decides + // to look at the archived data. + } + + return true; + } + + /** + * Callback before data is marshaled into an entity. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event beforeMarshal event + * @param ArrayObject $data Entity data + * @param ArrayObject $options Callback options + */ + + public function beforeMarshal(EventInterface $event, \ArrayObject $data, \ArrayObject $options) { + // If no group_type was set, this is a Standard Group, so fill in the field. + if(empty($data['group_type'])) { + $data['group_type'] = GroupTypeEnum::Standard; + } + } + + /** + * Define business rules. + * + * @since COmanage Registry v5.0.0 + * @param RulesChecker $rules RulesChecker object + * @return RulesChecker + */ + + public function buildRules(RulesChecker $rules): RulesChecker { + // AR-Group-1 Two Groups within the same CO cannot share the same name + $rules->add($rules->isUnique(['name', 'co_id'], __d('error', 'exists', [__d('controller', 'Groups', [1])]))); + + // AR-Group-2 A Group cannot be set to Suspended if it is nested into a + // Target Group or is a Target Group for a nesting. This and AR-Group-3 + // are to avoid unexpected consequences from implicitly undoing a nesting... + // the administrator must do that first. + $rules->addUpdate([$this, 'ruleIsNested'], + 'isNestedUpdate', + ['errorField' => 'status']); + + // AR-Group-3 A Group cannot be deleted if it is nested into a Target Group + // or is a Target Group for a nesting + $rules->addDelete([$this, 'ruleIsNested'], + 'isNestedDelete', + ['errorField' => 'status']); + + // AR-Group-4 The name, description, and status of a Group of type Owners + // cannot be manually changed. + $rules->addUpdate([$this, 'ruleOwnerIsModified'], + 'ownerDescriptionModified', + ['errorField' => 'description']); + $rules->addUpdate([$this, 'ruleOwnerIsModified'], + 'ownerNameModified', + ['errorField' => 'name']); + $rules->addUpdate([$this, 'ruleOwnerIsModified'], + 'ownerStatusModified', + ['errorField' => 'status']); + + // Similarly, the group_type cannot be changed for any Group + $rules->addUpdate([$this, 'ruleTypeIsModified'], + 'typeModified', + ['errorField' => 'group_type']); + + // AR-Group-9 Standard Groups may not be named starting with the prefix CO:, + // which is reserved for System Groups. + $rules->add([$this, 'ruleCheckNamePrefix'], + 'checkNamePrefix', + ['errorField' => 'name']); + + return $rules; + } + + /** + * Create an Owners Group for the requested Group. + * + * @since COmanage Registry v5.0.0 + * @param Group $group Group Entity to create an Owners Group for + * @return int Owners Group ID + * @throws PersistenceFailedException + */ + + public function createOwnersGroup($group): int { + if($group->isOwners()) { + throw new \InvalidArgumentException("Group is already an Owners Group"); + } + + $ownerGroup = $this->newEntity([ + 'co_id' => $group->co_id, + 'cou_id' => $group->cou_id, + // For now we just prefix everything with the same string, but maybe + // we want to be smarter for System Groups? + 'name' => 'CO:owners:' . $group->name, + 'description' => __d('field', 'Groups.owners.desc.affix', [$group->name]), + 'open' => false, + 'status' => SuspendableStatusEnum::Active, + 'group_type' => GroupTypeEnum::Owners + ]); + + // AR-Group-6 Groups of type Owners cannot be provisioned. + $this->saveOrFail($ownerGroup); + + // Update the original Group with a pointer to this one + $group->owners_group_id = $ownerGroup->id; + + $this->saveOrFail($group); + + return $ownerGroup->id; + } + + /** + * Find a CO's Administrators group. + * + * @since COmanage Registry v5.0.0 + * @param \Cake\ORM\Query $query Query + * @param array $options Options: co_id (required) + * @return \Cake\ORM\Query Query + */ + + public function findAdminGroup(Query $query, array $options): Query { + return $query->where([ + 'co_id' => $options['co_id'], + 'cou_id IS' => null, + 'status' => SuspendableStatusEnum::Active, + 'group_type' => GroupTypeEnum::Admins + ]); + } + + /** + * Get the Admin Group for a CO. + * + * @since COmanage Registry v5.0.0 + * @param int $coId CO ID + * @return int Group ID + */ + + public function getAdminGroupId(int $coId): int { + $g = $this->find('adminGroup', ['co_id' => $coId])->firstOrFail(); + + return $g->id; + } + + /** + * Obtain an iterator for all members of the requested Group. + * + * @since COmanage Registry v5.0.0 + * @param int $id Group ID + * @param int $groupNestingId If provided, only members due to this Group Nesting ID + * @return PaginatedSqlIterator Iterator for GroupMembers + */ + + public function getMembers(int $id, int $groupNestingId=null): PaginatedSqlIterator { + $conditions = [ + 'group_id' => $id, +// XXX add check for valid_from/through and test +// 'valid_from' +// 'valid_through' + ]; + + if($groupNestingId) { + $conditions['group_nesting_id'] = $groupNestingId; + } + + return new PaginatedSqlIterator($this->GroupMembers->getTarget(), $conditions); + } + + /** + * Get Members of this Group who are Members due to the specified Group + * Nesting ID. + * + * @since COmanage Registry v5.0.0 + * @param int $id Group ID + * @param int $groupNestingId Group Nesting ID + * @return PaginatedSqlIterator Iterator for GroupMembers + */ + + public function getMembersViaNesting(int $id, int $groupNestingId): PaginatedSqlIterator { + return $this->getMembers($id, $groupNestingId); + } + + /** + * Define the table's implemented events. + * + * @since COmanage Registry v5.0.0 + */ + + public function implementedEvents(): array { + $events = parent::implementedEvents(); + + // We need to adjust our beforeDelete priority to run before ChangelogBehavior's. + $events['Model.beforeDelete'] = [ + 'callable' => 'beforeDelete', + 'priority' => 1 + ]; + + return $events; + } + + /** + * Callback after model save. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Event + * @param EntityInterface $entity Entity (ie: Co) + * @param ArrayObject $options Save options + * @return bool True on success + */ + + public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { + if($entity->isNew()) { + $action = ActionEnum::GroupAdded; + $comment = __d('result', 'Groups.added', [$entity->name]); + } elseif($entity->get('deleted')) { + $action = ActionEnum::GroupDeleted; + $comment = __d('result', 'Groups.deleted', [$entity->name]); + } else { + $action = ActionEnum::GroupEdited; + $comment = __d('result', 'Groups.edited', [$entity->name, $this->changesToString($entity)]); + } + + $this->recordHistory($entity, $action, $comment); + + if(!$entity->isOwners()) { + if($entity->isNew()) { + // When a new Group is created, create the owners Group for it. + // This includes automatic Groups. + + $this->createOwnersGroup($entity); + } elseif(!$entity->get('deleted')) { + // If a Group is updated, we may need to update the same attributes + // in the Owners Group. + + $this->updateOwnersGroup($entity); + } + } + + return true; + } + + /** + * Marshal object data for provisioning. + * + * @since COmanage Registry v5.0.0 + * @param int $id Entity ID + * @return array An array of provisionable data and eligibility + */ + + public function marshalProvisioningData(int $id): array { + $ret = []; + + $ret['data'] = $this->get($id, [ + // We need archives for handling deleted records + 'archived' => 'true', + 'contain' => [ + 'GroupMembers', + 'Identifiers' + ] + ]); + + // Provisioning Eligibility is + // - Deleted if the changelog deleted flag is true + // - Eligible if the status is Active + // - Ineligible otherwise + + $ret['eligibility'] = ProvisioningEligibilityEnum::Ineligible; + + // We filter various attributes depending on the status of the record. + + if($ret['data']->deleted) { + $ret['eligibility'] = ProvisioningEligibilityEnum::Deleted; + + // For deleted or archived records, we remove all Group Members, + // but we leave the Identifiers in place. + + $ret['data']->group_members = []; + } elseif($ret['data']->status == SuspendableStatusEnum::Active) { + $ret['eligibility'] = ProvisioningEligibilityEnum::Eligible; + + // For Eligible, we still need to remove Group Memberships that are + // invalid, and Identifiers that are suspended. + + $groupMembers = []; + + foreach($ret['data']->group_members as $gm) { + if($gm->isValid()) { + $groupMembers[] = $gm; + } + } + + $ret['data']->group_members = $groupMembers; + + $identifiers = []; + + foreach($ret['data']->identifiers as $id) { + if($id->status == SuspendableStatusEnum::Active) { + $identifiers[] = $id; + } + } + + $ret['data']->identifiers = $identifiers; + } else { + $ret['eligibility'] = ProvisioningEligibilityEnum::Ineligible; + // For Ineligible records, we remove the group memberships, and + // any suspended Identifiers. + + $ret['data']->group_members = []; + + $identifiers = []; + + foreach($ret['data']->identifiers as $id) { + if($id->status == SuspendableStatusEnum::Active) { + $identifiers[] = $id; + } + } + + $ret['data']->identifiers = $identifiers; + } + + return $ret; + } + + /** + * Reconcile the members of an automatic or nested Group. + * + * @since COmanage Registry v5.0.0 + * @param int $id Group ID + */ + + public function reconcile(int $id) { + $group = $this->get($id); + + if($group->isAutomatic()) { + $this->reconcileAutomaticGroup($group); + } else { + $this->reconcileNestedMemberships($group); + } + } + + /** + * Reconcile the members of an automatic Group. + * + * @since COmanage Registry v5.0.0 + * @param EntityInterface $entity Group + */ + + protected function reconcileAutomaticGroup(\Cake\Datasource\EntityInterface $entity) { + // In order to handle very large groups, we can't pull the full set of + // members into memory. Instead, we use the paginated iterator. This + // involves two passes. + + // First, we pull the current members of the Group, and for each member + // make sure they are still eligible. + + $iterator = $this->getMembers($entity->id); + + foreach($iterator as $k => $groupMember) { + if(!empty($entity->cou_id)) { + if($entity->group_type == GroupTypeEnum::ActiveMembers) { + // If $groupMember is not an active member of cou_id, remove the membership + if(!$this->Cous->PersonRoles->hasActive($groupMember->person_id, $entity->cou_id)) { + $this->llog('rule', "AR-PersonRole-2 Reconciliation removing membership for Person ID " . $groupMember->person_id . " from Group ID " . $groupMember->group_id); + $this->GroupMembers->delete($groupMember); + } + } else { + // If $groupMember does not have any role in cou_id, remove the membership + if(!$this->Cous->PersonRoles->hasAny($groupMember->person_id, $entity->cou_id)) { + $this->llog('rule', "AR-PersonRole-1 Reconciliation removing membership for Person ID " . $groupMember->person_id . " from Group ID " . $groupMember->group_id); + $this->GroupMembers->delete($groupMember); + } + } + } else { + // Look at the Person record + $person = $this->GroupMembers->People->get($groupMember->person_id); + + if($entity->group_type == GroupTypeEnum::ActiveMembers) { + if(!$person || !$person->isActive()) { + $this->llog('rule', "AR-Person-2 Reconciliation removing membership for Person ID " . $groupMember->person_id . " from Group ID " . $groupMember->group_id); + $this->GroupMembers->delete($groupMember); + } + } else { + if(!$person || $person->status == StatusEnum::Archived) { + $this->llog('rule', "AR-Person-1 Reconciliation removing membership for Person ID " . $groupMember->person_id . " from Group ID " . $groupMember->group_id); + $this->GroupMembers->delete($groupMember); + } + } + } + } + + // Second, we pull the members of the CO/COU and make sure they have the + // correlated membership. + + if(!empty($entity->cou_id)) { + // This won't return roles in Archived status, but returns all others + $iterator = $this->Cous->PersonRoles->getMembers($entity->cou_id); + + foreach($iterator as $k => $personRole) { + if($entity->group_type == GroupTypeEnum::AllMembers + || $personRole->isActive()) { + // Check if the Person is already a member of the Group + if(!$this->GroupMembers->isMember($entity->id, $personRole->person_id)) { + // Add the membership + + $membership = [ + 'group_id' => $entity->id, + 'person_id' => $personRole->person_id + ]; + + $gmEntity = $this->GroupMembers->newEntity($membership); + + $this->GroupMembers->saveOrFail($gmEntity); + $this->llog('rule', ($entity->group_type == GroupTypeEnum::AllMembers ? "AR-PersonRole-2" : "AR-PersonRole-1") . " Reconciliation added automatic membership for Person ID " . $personRole->person_id . " to Group ID " . $entity->id); + } + } + } + } else { + $iterator = $this->People->getMembers($entity->co_id); + + foreach($iterator as $k => $person) { + if($entity->group_type == GroupTypeEnum::AllMembers + || $person->isActive()) { + // Add the membership + + $membership = [ + 'group_id' => $entity->id, + 'person_id' => $person->id + ]; + + $entity = $this->GroupMembers->newEntity($membership); + + $this->GroupMembers->saveOrFail($entity); + $this->llog('rule', ($entity->group_type == GroupTypeEnum::AllMembers ? "AR-Person-2" : "AR-Person-1") . " Reconciliation added automatic membership for Person ID " . $person->id . " to Group ID " . $entity->id); + } + } + } + + return; + } + + /** + * Reconcile the members of a nested Group. + * + * @since COmanage Registry v5.0.0 + * @param EntityInterface $entity Group + */ + + protected function reconcileNestedMemberships(\Cake\Datasource\EntityInterface $entity) { + // When a new GroupNesting is saved, we're called on the _target_. + + // Start by pulling the Group Nestings for this Group. We'll only go one level deep. + + $groupNestings = $this->GroupNestings->find() + ->where(['GroupNestings.target_group_id' => $entity->id]) + ->all(); + + // First iterate through the current members of the target group (who are + // members due to one of the nestings) and recheck their eligibility. This + // will remove anyone who is no longer eligible. + + // We convert $groupNestings to an array for the outer loop to ensure we don't + // have conflicts with the next loop + foreach($groupNestings->toArray() as $groupNesting) { + $iterator = $this->getMembersViaNesting($groupNesting->target_group_id, $groupNesting->id); + + foreach($iterator as $k => $targetGroupMember) { + $this->GroupMembers->syncNestedMembership($targetGroupMember->person_id, + $entity); + } + } + + // Next, for each nesting iterate through the members of that nesting and + // recheck their eligibility. This will add in anyone who is now eligible. + // (We do this second since the first iteration might shrink the population + // to check here.) + + foreach($groupNestings->toArray() as $groupNesting) { + $iterator = $this->getMembers($groupNesting->group_id); + + foreach($iterator as $k => $sourceGroupMember) { + $this->GroupMembers->syncNestedMembership($sourceGroupMember->person_id, + $entity); + } + } + + return true; + } + + /** + * Application Rule to determine if the Group name has an invalid prefix. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Entity to be validated + * @param array $options Application rule options + * @return boolean true if the Rule check passes, false otherwise + */ + + public function ruleCheckNamePrefix($entity, $options) { + if($entity->group_type == GroupTypeEnum::Standard + && strncmp($entity->name, "CO:", 3)==0) { + return __d('error', 'Groups.name.prefix'); + } + + return true; + } + + /** + * Application Rule to determine if the group is nested. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Entity to be validated + * @param array $options Application rule options + * @return boolean true if the Rule check passes, false otherwise + */ + + public function ruleIsNested($entity, $options) { + // We check that the subject group is either a source or a target, but + // only if the $entity status is Suspended. + + if($entity->status == SuspendableStatusEnum::Suspended) { + $count = $this->GroupNestings->find('all') + ->where([ + 'OR' => [ + 'GroupNestings.group_id' => $entity->id, + 'GroupNestings.target_group_id' => $entity->id + ] + ]) + ->count(); + + if($count > 0) { + return __d('error', 'Groups.nested'); + } + } + + return true; + } + + /** + * Application Rule to determine if a non-modifiable Owners Group field was modified. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Entity to be validated + * @param array $options Application rule options + * @return boolean true if the Rule check passes, false otherwise + */ + + public function ruleOwnerIsModified($entity, $options) { + if(!$entity->isOwners()) { + return true; + } + + // We'll check the field specified in $options['errorField'] + if($entity->isDirty($options['errorField'])) { + return __d('error', 'fields.read_only', $options['errorField']); + } + + return true; + } + + /** + * Application Rule to determine if the Group Type was changed. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Entity to be validated + * @param array $options Application rule options + * @return boolean true if the Rule check passes, false otherwise + */ + + public function ruleTypeIsModified($entity, $options) { + if($entity->isDirty('group_type') + // For some reason the field is flagged as dirty on update + // (presumably when we update owners_group_id in localAfterSave) + // so we need to compare the original value + && $entity->get('group_type') != $entity->getOriginal('group_type')) { + return __d('error', 'fields.read_only', 'group_type'); + } + + return true; + } + + /** + * Perform a keyword search. + * + * @since COmanage Registry v5.0.0 + * @param int $coId CO ID to constrain search to + * @param string $q String to search for + * @param int $limit Search limit + * @return Array Array of search results, as from find('all) + */ + + public function search(int $coId, string $q, int $limit) { + // Tokenize $q on spaces + $tokens = explode(" ", $q); + + // We take two loops through, the first time we only do a prefix search + // (foo%). If that doesn't reach the search limit, we'll do an infix search + // the second time around. + + $whereClause = []; + + foreach($tokens as $t) { + $whereClause['AND'][] = [ + 'OR' => [ + 'LOWER(Groups.name) LIKE' => '%' . strtolower($t) . '%' + ] + ]; + } + + return $this->find() + ->where($whereClause) + ->andWhere(['Groups.co_id' => $coId]) + ->order(['Groups.name']) + ->limit($limit) + ->all(); + } + + /** + * Update the attributes of an Owners Group, based on the attributes of the + * related primary Group. + * + * @since COmanage Registry v5.0.0 + * @param Group $group Owners Group entity + * @return int Owners Group ID + * @throws PersistenceFailedException + */ + + public function updateOwnersGroup($group): int { + if($group->isOwners()) { + throw new \InvalidArgumentException(__d('error', 'Groups.owners.desc.affix')); + } + + $ownerGroup = $this->get($group->owners_group_id); + + // We synchronize name, description, and status + $ownerGroup->name = 'CO:owners:' . $group->name; + $ownerGroup->description = $group->name . " Owners"; + $ownerGroup->status = $group->status; + + // We need to disable rule checking since these fields are not normally modifiable + $this->saveOrFail($ownerGroup, ['checkRules' => false]); + + return $ownerGroup->id; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('co_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('co_id'); + + $validator->add('cou_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('cou_id'); + + $this->registerStringValidation($validator, $schema, 'name', true); + + $this->registerStringValidation($validator, $schema, 'description', false); + + $validator->add('open', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('open'); + + $validator->add('status', [ + 'content' => ['rule' => ['inList', SuspendableStatusEnum::getConstValues()]] + ]); + $validator->notEmptyString('status'); + + $validator->add('group_type', [ + 'content' => ['rule' => ['inList', GroupTypeEnum::getConstValues()]] + ]); + $validator->notEmptyString('group_type'); + + $validator->add('nesting_mode_all', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('nesting_mode_all'); + + // This will be null for Owner Groups, which don't have further Owner Groups + $validator->add('owner_group_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('owner_group_id'); + + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/HistoryRecordsTable.php b/app/src/Model/Table/HistoryRecordsTable.php new file mode 100644 index 000000000..f78350ac3 --- /dev/null +++ b/app/src/Model/Table/HistoryRecordsTable.php @@ -0,0 +1,250 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Artifact); + + // Define associations + $this->belongsTo('ApiUser') + ->setForeignKey('actor_api_user_id') + ->setProperty('actor_api_user'); + $this->belongsTo('ActorPeople') + ->setClassName('People') + ->setForeignKey('actor_person_id') + // Property is set so ruleValidateCO can find it. We don't use the + // _id suffix to match Cake's default pattern. + ->setProperty('actor_person'); + $this->belongsTo('People'); + $this->belongsTo('PersonRoles'); + $this->belongsTo('ExternalIdentities'); + $this->belongsTo('ExternalIdentityRoles'); + $this->belongsTo('Groups'); + + $this->setDisplayField('comment'); + +// XXX note primary link is external_identity_id when set... +// or the other fields as we add them + $this->setPrimaryLink(['external_identity_id', 'group_id', 'person_id']); + $this->setRequiresCO(true); + +// XXX does some of this stuff really belong in the controller? + // Cake appears to incorrectly use the ActorPeople foreign key definition + // even though the relation to PrimaryName is for People. There's probably + // a patch that needs to be made, but for now we'll just force the foreign + // key back. + $this->setEditContains(['ActorPeople' => ['PrimaryName' => ['foreignKey' => 'person_id']]]); + $this->setIndexContains(['ActorPeople' => ['PrimaryName' => ['foreignKey' => 'person_id']]]); + $this->setViewContains([ + 'People' => ['PrimaryName'], + // contain results in a join when the relation is belongsTo (or hasOne), + // and joining the same table twice makes the database unhappy, so we + // force ActorPeople to use multiple queries. + 'ActorPeople' => ['Names' => ['queryBuilder' => function ($q) { + return $q->where(['primary_name' => true]); + }]], + 'ExternalIdentities', + 'Groups' + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, + 'edit' => false, + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Perform actions while marshaling data, before validation. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Event + * @param ArrayObject $data Object data, in array format + * @param ArrayObject $options Entity save options + */ + + public function beforeMarshal(EventInterface $event, \ArrayObject $data, \ArrayObject $options) + { + if(!empty($data['comment'])) { + // Truncate the comment to fit the column width + $column = $this->getSchema()->getColumn('comment'); + + $data['comment'] = substr($data['comment'], 0, $column['length']); + } + } + + /** + * Table specific logic to generate a display field. + * + * @since COmanage Registry v5.0.0 + * @param HistoryRecord $entity Entity to generate display field for + * @return string Display field + */ + + public function generateDisplayField(\App\Model\Entity\HistoryRecord $entity): string { + // Comments may be too long to render, so we just use the model name + // (which will get appended with the record ID) + + return __d('controller', 'HistoryRecords', [1]); + } + + /** + * Record a History Record entry for a Group. + * + * @since COmanage Registry v5.0.0 + * @param int $groupId Group ID + * @param string $action Action + * @param string $comment Comment + * @param int $personId Person ID + * @return int History Record ID + */ + + public function recordForGroup(int $groupId, + string $action, + string $comment, + ?int $personId=null): int { + $record = [ + 'group_id' => $groupId, + 'action' => $action, + 'comment' => $comment + ]; + + if($personId) { + $record['person_id'] = $personId; + } + + $obj = $this->newEntity($record); + + $this->saveOrFail($obj); + + return $obj->id; + } + + /** + * Record a History Record entry for a Person. + * + * @since COmanage Registry v5.0.0 + * @param int $personId Person ID + * @param string $action Action + * @param string $comment Comment + * @param int $personRoleId Person Role ID + * @param int $externalIdentityId External Identity ID + * @param int $externalIdentityRoleId External Identity Role ID + * @return int History Record ID + */ + + public function recordForPerson(?int $personId, + string $action, + string $comment, + ?int $personRoleId=null, + ?int $externalIdentityId=null, + ?int $externalIdentityRoleId=null): int { + $record = [ + 'person_id' => $personId, + 'action' => $action, + 'comment' => $comment + ]; + + if($personRoleId) { + $record['person_role_id'] = $personRoleId; + } + + if($externalIdentityId) { + $record['external_identity_id'] = $externalIdentityId; + } + + if($externalIdentityRoleId) { + $record['external_identity_role_id'] = $externalIdentityRoleId; + } + + $obj = $this->newEntity($record); + + $this->saveOrFail($obj); + + return $obj->id; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + * @throws InvalidArgumentException + * @throws RecordNotFoundException + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $this->registerPrimaryKeyValidation($validator, $this->getPrimaryLinks()); + + $this->registerStringValidation($validator, $schema, 'action', true); + + // We disable validateInput for the comment field since changesToString likes to + // include > characters. + $this->registerStringValidation($validator, $schema, 'comment', required: true, validateInput: false); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/IdentifierAssignmentsTable.php b/app/src/Model/Table/IdentifierAssignmentsTable.php new file mode 100644 index 000000000..b0325c9f6 --- /dev/null +++ b/app/src/Model/Table/IdentifierAssignmentsTable.php @@ -0,0 +1,512 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Orderable'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('Cos'); + $this->belongsTo('Groups'); + $this->belongsTo('EmailAddressTypes') + ->setClassName('Types') + ->setForeignKey('email_address_type_id') + ->setProperty('email_address_type'); + $this->belongsTo('IdentifierTypes') + ->setClassName('Types') + ->setForeignKey('identifier_type_id') + ->setProperty('identifier_type'); + + $this->setPluginRelations(); + + $this->setDisplayField('description'); + + $this->setPrimaryLink(['co_id', 'group_id', 'person_id']); + $this->setRequiresCO(true); + $this->setAllowUnkeyedPrimaryLink(['assign']); + $this->setRedirectGoal(action: 'assign', goal: 'primaryLink'); + + $this->setAutoViewVars([ + 'contexts' => [ + 'type' => 'enum', + 'class' => 'IdentifierAssignmentContextEnum' + ], + 'emailAddressTypes' => [ + 'type' => 'type', + 'attribute' => ['EmailAddresses.type'] + ], + 'groups' => [ + 'type' => 'select', + 'model' => 'Groups' + ], + 'identifierTypes' => [ + 'type' => 'type', + 'attribute' => ['Identifiers.type'] + ], + 'plugins' => [ + 'type' => 'plugin', + 'pluginType' => 'assigner' + ], + 'statuses' => [ + 'type' => 'enum', + 'class' => 'SuspendableStatusEnum' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'configure' => ['platformAdmin', 'coAdmin'], + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'assign' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Assign Identifiers for an Entity. + * + * @since COmanage Registry v5.0.0 + * @param string $entityType Entity Table (eg: "People") + * @param int $entityId Entity ID + * @param bool $provision Whether or not to run provisioners after assignment + * @param int $actorPersonId Person ID of Actor assigning identifiers + */ + + public function assign( + string $entityType, + int $entityId, + bool $provision=true, +// XXX CFM-76 HistoryRecords don't seem to do anything with actorPersonId yet +// Also need to update StandardController or something for regular requests + int $actorPersonId=null + ): array { + $ret = [ + 'already' => [], + 'assigned' => [], + 'errors' => [] + ]; + + // Pull the entity, which we'll use to map to the CO and also to pass to + // other functions. + + $contains = ['Identifiers' => 'Types']; + + if($entityType == 'People') { + $contains[] = 'PrimaryName'; + } + + $EntityTable = TableRegistry::getTableLocator()->get($entityType); + + // Map the entity to its CO, which we'll need to pull the + // Identifier Assignment configuration. + + $coId = $EntityTable->findCoForRecord($entityId); + + $context = ($entityType == 'Groups' + ? IdentifierAssignmentContextEnum::Group + : IdentifierAssignmentContextEnum::Person); + + $ias = $this->find() + ->where([ + 'IdentifierAssignments.co_id' => $coId, + 'IdentifierAssignments.status' => SuspendableStatusEnum::Active, + 'IdentifierAssignments.context' => $context + ]) + ->order(['IdentifierAssignments.ordr' => 'ASC']) + ->contain($this->getPluginRelations()) + ->all(); + + foreach($ias as $ia) { +// XXX CFM-57 If not group eligible skip this (but log that we skipped it) + // We'll create a transaction for each Identifier Assignment + + $cxn = $this->getConnection(); + $cxn->begin(); + + // We pull the entity at the start of each loop to reload any + // identifiers that were generated on the previous loop and therefore + // might be used in a subsequent assignment. (It might be slightly + // more efficient to manually track the generated identifier, but + // this should be less brittle if we add support for another model + // alongside Identifiers and EmailAddresses.) + + $entity = $EntityTable->get($entityId, ['contain' => $contains]); + + // Check if there is already an identifier of this type + + if(!$this->assigned($ia, $entity)) { + // Request a new Identifier + + try { + $Plugin = TableRegistry::getTableLocator()->get($ia->plugin); + + // The plugin is expected to throw InvalidArgumentException on + // unsupported context, or RuntimeException on some other error. + $ret['assigned'][$ia->description] = $Plugin->assign($ia, $entity); + + $this->llog('trace', "New Identifier '".$ia->description."' assigned (".$ret['assigned'][$ia->description].") for $entityType $entityId"); + + $this->attachIdentifier($ia, $entity, $ret['assigned'][$ia->description]); + } + catch(\Exception $e) { + $this->llog('debug', "Identifier '".$ia->description."' assignment failed for $entityType $entityId: " . $e->getMessage()); + $ret['errors'][$ia->description] = $e->getMessage(); + $cxn->rollback(); + } + } else { + $this->llog('trace', "Identifier '".$ia->description."' already assigned for $entityType $entityId"); + $ret['already'][$ia->description] = true; // XXX maybe return the identifier? + // We can't rollback here because it will cause parent transactions + // (eg: Pipelines) to fail +// $cxn->rollback(); + } + + $cxn->commit(); + } + + // Trigger provisioning, letting errors bubble up (AR-GMR-5) + if(method_exists($EntityTable, "requestProvisioning") && !empty($entity->id)) { + $this->llog('rule', "AR-GMR-5 Requesting provisioning for $entityType " . $entity->id); + $EntityTable->requestProvisioning(id: $entity->id, context: ProvisioningContextEnum::Automatic); + } + + return $ret; + } + + /** + * Determine if an identifier of a given type is already assigned to an entity. + * Suspended identifiers are considered assigned. + * + * IMPORTANT: This function should be called within a transaction to ensure + * actions taken based on availability are atomic. + * + * @since COmanage Registry v5.0.0 + * @param IdentifierAssignment $ia Identifier Assignment + * @param EntityInterface $entity Entity + * @return bool True if an identifier of the specified type is already assigned, false otherwise + */ + + public function assigned($ia, $entity): bool { + $fk = StringUtilities::entityToForeignKey($entity); + + $className = !empty($ia->email_address_type_id) + ? 'EmailAddresses' + : 'Identifiers'; + + $typeId = !empty($ia->email_address_type_id) + ? $ia->email_address_type_id + : $ia->identifier_type_id; + $EntityTable = TableRegistry::getTableLocator()->get($className); + + $count = $EntityTable->find() + ->where([ + $className . '.' . $fk => $entity->id, + $className . '.type_id' => $typeId + ]) + ->epilog('FOR UPDATE') +// We can't use aggregate functions with FOR UPDATE +// ->count() + ->all(); + + return (bool)($count->count()); + } + + /** + * Attach a newly generated Identifier (or Email Address) to the entity + * for which it was generated. + * + * @since COmanage Registry v5.0.0 + * @param IdentifierAssignment $ia Identifier Assignment + * @param EntityInterface $entity Subject Entity + * @param string $identifier Identifier (or Email Address) + * @return int Newly created entity ID + */ + + public function attachIdentifier($ia, $entity, string $identifier): int { + // eg: person_id, group_id + $fk = StringUtilities::entityToForeignKey($entity); + $entityClassName = StringUtilities::entityToClassName($entity); + + // Are we attaching an Identifier or an Email Address? + $targetClassName = !empty($ia->email_address_type_id) + ? 'EmailAddresses' + : 'Identifiers'; + + $TargetEntityTable = TableRegistry::getTableLocator()->get($targetClassName); + + $newRecord = []; + + if($targetClassName == 'EmailAddresses') { + $newRecord = [ + 'mail' => $identifier, + 'type_id' => $ia->email_address_type_id, + // AR-IdentifierAssignment-3 EmailAddresses generated via Identifier Assignment are considered verified + 'verified' => true + ]; + } else { + $newRecord = [ + 'identifier' => $identifier, + 'type_id' => $ia->identifier_type_id, + 'status' => SuspendableStatusEnum::Active, + 'login' => $ia->login + ]; + } + + // Add in the foreign key + $newRecord[$fk] = $entity->id; + + $newEntity = $TargetEntityTable->newEntity($newRecord); + + $TargetEntityTable->saveOrFail($newEntity); + + $Types = TableRegistry::getTableLocator()->get('Types'); + + $typeLabel = $Types->getTypeLabel((int)$newRecord['type_id']); + + // We can actually pass either $newEntity or $entity to recordHistory() + // with basically the same effect, but passing $entity will result in + // fewer lookups to get the foreign keys required to record history. + $TargetEntityTable->$entityClassName->recordHistory( + entity: $entity, + action: ActionEnum::IdentifierAutoAssigned, + comment: __d('result', "IdentifierAssignments.history", [$identifier, $typeLabel, $ia->description]) + ); + + return $newEntity->id; + } + + /** + * Define business rules. + * + * @since COmanage Registry v5.0.0 + * @param RulesChecker $rules RulesChecker object + * @return RulesChecker + */ + + public function buildRules(RulesChecker $rules): RulesChecker { + // AR-IdentifierAssignment-1 An IdentifierAssignment must apply to either an + // Identifier or an EmailAddress, but not both. + + $rules->add([$this, 'ruleWhichType'], + 'targetType', + ['errorField' => 'identifier_type_id']); + + return $rules; + } + + /** + * Check if an identifier or email address is available for use, ie + * if it is not defined (regardless of status) within the same CO. + * + * IMPORTANT: This function should be called within a transaction to ensure + * actions taken based on availability are atomic. + * + * @since COmanage Registry v5.0.0 + * @param string $className Class name ("Identifiers" or "EmailAddresses") + * @param int $typeId Type ID + * @param string $candidate Candidate identifier or email address + * @param EntityInterface $entity Entity to check availability for + * @return bool True if the candidate is already in use + * @throws OverflowException If $candidate is already in use + */ + + public function checkAvailability( + string $className, + int $typeId, + string $candidate, + $entity + ): bool { + $fieldName = ($className == 'EmailAddresses' ? 'mail' : 'identifier'); + $foreignKey = StringUtilities::entityToForeignKey($entity); + + $Table = TableRegistry::getTableLocator()->get($className); + + // In order to allow ensure that another process doesn't perform the same + // availability check while we're running, we need to lock the appropriate + // tables/rows at read time. We do this with FOR UPDATE. + + $r = $Table->find() + ->where([ + // AR-Identifier-Assignment-2 Availability checks for newly + // generated Identifiers and EmailAddresses are case insensitive. +// XXX CFM-306 This really requires a case insensitive index, but DBAL doesn't support +// those because it's a "database specific" thing. It might be possible to do this +// by overriding the schema manager... +// https://www.doctrine-project.org/projects/doctrine-dbal/en/current/reference/schema-manager.html#overriding-the-schema-manager + 'LOWER('.$className.'.'.$fieldName.')' => strtolower($candidate), + // Because type_ids are specific to a CO, we effectively + // constrain the search within a CO by typeId + $className.'.type_id' => $typeId, + // Only consider records where the foreign key of the same type + // is not null. (eg: We don't consider an Identifier assigned to + // a Group to be taken if we're assigning for a Person.) + $className.'.'.$foreignKey.' IS NOT NULL', + ]) + ->epilog('FOR UPDATE') +// We can't use aggregate functions with FOR UPDATE +// ->count() + ->all(); + + if($r->count() > 0) { + throw new \OverflowException(__d('error', 'IdentifierAssignments.exists', $candidate)); + } + +// XXX CFM-309: Once Identifier Validators are a thing call them here +// (see v4 AppModel::checkAvailability) + + return true; + } + + /** + * Application Rule to determine if an appropriate target type is selected. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Entity to be validated + * @param array $options Application rule options + * @return boolean true if the Rule check passes, false otherwise + */ + + public function ruleWhichType($entity, $options) { + if(!$entity->email_address_type_id + && !$entity->identifier_type_id) { + // No type was set + return(__d('error', 'IdentifierAssignments.type')); + } elseif($entity->email_address_type_id + && $entity->identifier_type_id) { + // Both types were set + return(__d('error', 'IdentifierAssignments.type')); + } + + return true; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('co_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('co_id'); + + $this->registerStringValidation($validator, $schema, 'description', false); + + $validator->add('status', [ + 'content' => ['rule' => ['inList', SuspendableStatusEnum::getConstValues()]] + ]); + $validator->notEmptyString('status'); + + $this->registerStringValidation($validator, $schema, 'plugin', true); + + $validator->add('context', [ + 'content' => ['rule' => ['inList', IdentifierAssignmentContextEnum::getConstValues()]] + ]); + $validator->notEmptyString('context'); + + $validator->add('group_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('group_id'); + + $validator->add('identifier_type_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + // See AR-IdentifierAssignment-1 + $validator->allowEmptyString('identifier_type_id'); + + $validator->add('login', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('login'); + + $validator->add('email_address_type_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + // See AR-IdentifierAssignment-1 + $validator->allowEmptyString('email_address_type_id'); + + $validator->add('ordr', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('ordr'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/IdentifiersTable.php b/app/src/Model/Table/IdentifiersTable.php new file mode 100644 index 000000000..1d5fb5e74 --- /dev/null +++ b/app/src/Model/Table/IdentifiersTable.php @@ -0,0 +1,291 @@ + [ + 'badge', + 'enterprise', + 'eppn', + 'eptid', + 'epuid', + 'gid', + 'mail', + 'national', + 'network', + 'oidcsub', + 'openid', + 'orcid', + 'provisioningtarget', + 'reference', + 'pairwiseid', + 'subjectid', + 'sorid', + 'uid' + ] + ]; + + /** + * Provide the default layout + * + * @since COmanage Registry v5.0.0 + * @return string Type of redirect + */ + public function getLayout(): string { + return "iframe"; + } + + /** + * Perform Cake Model initialization. + * + * @since COmanage Registry v5.0.0 + * @param array $config Configuration options passed to constructor + */ + + public function initialize(array $config): void { + // Timestamp behavior handles created/modified updates + $this->addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Secondary); + + // Define associations + $this->belongsTo('ExternalIdentities'); + $this->belongsTo('Groups'); + $this->belongsTo('People'); + $this->belongsTo('ProvisioningTargets'); + $this->belongsTo('Types'); + + $this->setDisplayField('identifier'); + + $this->setPrimaryLink(['external_identity_id', 'group_id', 'person_id']); + $this->setRequiresCO(true); + $this->setRedirectGoal('self'); + $this->setAllowLookupPrimaryLink(['unfreeze']); + + $this->setAutoViewVars([ + 'types' => [ + 'type' => 'type', + 'attribute' => 'Identifiers.type' + ], + 'statuses' => [ + 'type' => 'enum', + 'class' => 'TemplateableStatusEnum' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'unfreeze' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that are permitted on readonly entities (besides view) + 'readOnly' => ['unfreeze'], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ], + // Related models whose permissions we'll need, typically for table views + 'related' => [ + 'AuthenticationEvents' + ] + ]); + } + + /** + * Callback before data is marshaled into an entity. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event beforeMarshal event + * @param ArrayObject $data Entity data + * @param ArrayObject $options Callback options + */ + + public function beforeMarshal(EventInterface $event, \ArrayObject $data, \ArrayObject $options) + { + if(empty($data['status'])) { + // Set a default status of Active if not otherwise set (eg: via EIS/Pipelines) + $data['status'] = SuspendableStatusEnum::Active; + } + } + + /** + * Callback after model save. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Event + * @param EntityInterface $entity Entity (ie: Co) + * @param ArrayObject $options Save options + * @return bool True on success + */ + + public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { + $this->recordHistory($entity); + + return true; + } + + /** + * Look up a Person ID from an identifier and identifier type ID. + * Only active Identifiers can be used for lookups. + * + * @since COmanage Registry v5.0.0 + * @param int $typeId Identifier Type ID + * @param string $identifier Identifier + * @return int Person ID + * @throws Cake\Datasource\Exception\RecordNotFoundException + */ + + public function lookupPerson(int $typeId, string $identifier): int { + $id = $this->find() + ->where([ + 'identifier' => $identifier, + 'type_id' => $typeId, + 'status' => SuspendableStatusEnum::Active, + 'person_id IS NOT NULL' + ]) + ->firstOrFail(); + + return $id->person_id; + } + + /** + * Perform a keyword search. + * + * @since COmanage Registry v5.0.0 + * @param int $coId CO ID to constrain search to + * @param string $q String to search for + * @param int $limit Search limit + * @return Array Array of search results, as from find('all) + */ + + public function search(int $coId, string $q, int $limit) { + return $this->find() + ->where([ + 'Identifiers.identifier' => $q, + 'OR' => [ + 'People.co_id' => $coId, + 'Groups.co_id' => $coId + ] + ]) + ->limit($limit) + ->contain(['People' => 'PrimaryName', 'Groups']) + ->all(); + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + * @throws InvalidArgumentException + * @throws RecordNotFoundException + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $this->registerPrimaryKeyValidation($validator, $this->getPrimaryLinks()); + + $this->registerStringValidation($validator, $schema, 'identifier', true); + $validator->add('identifier', [ + // Identifier must have at least one non-space character in order to avoid + // errors (eg: with provisioning ldap) + 'content' => ['rule' => ['notBlank'], + 'message' => __d('error', 'input.blank')] + ]); + + $validator->add('type_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('type_id'); + + $validator->add('login', [ + 'content' => ['rule' => ['boolean']] + ]); + + // AR-Identifier-1 Login Identifiers can only be attached to People + $validator->add('login', 'loginPersonIdentifier', [ + 'rule' => function ($value, array $context) { + if($value && empty($context['data']['person_id'])) { + return __d('error', 'Identifiers.login'); + } + + return true; + } + ]); + + $validator->allowEmptyString('login'); + + $validator->add('status', [ + 'content' => ['rule' => ['inList', SuspendableStatusEnum::getConstValues()]] + ]); + $validator->notEmptyString('status'); + + $validator->add('frozen', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('frozen'); + + $validator->add('source_identifier_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('source_identifier_id'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/JobHistoryRecordsTable.php b/app/src/Model/Table/JobHistoryRecordsTable.php new file mode 100644 index 000000000..3453fed49 --- /dev/null +++ b/app/src/Model/Table/JobHistoryRecordsTable.php @@ -0,0 +1,214 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Artifact); + + // Define associations + $this->belongsTo('Jobs'); + $this->belongsTo('People'); + $this->belongsTo('ExternalIdentities'); + + $this->setDisplayField('comment'); + + $this->setPrimaryLink(['job_id']); + $this->setRequiresCO(true); + + $this->setAutoViewVars([ + 'statuses' => [ + 'type' => 'enum', + 'class' => 'JobStatusEnum' + ] + ]); + + $this->setViewContains([ + 'People' => ['PrimaryName'], + 'ExternalIdentities' => ['PrimaryName'] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, + 'edit' => false, + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Perform actions while marshaling data, before validation. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Event + * @param ArrayObject $data Object data, in array format + * @param ArrayObject $options Entity save options + */ + + public function beforeMarshal(EventInterface $event, \ArrayObject $data, \ArrayObject $options) + { + if(!empty($data['comment'])) { + // Truncate the comment to fit the column width + $column = $this->getSchema()->getColumn('comment'); + + $data['comment'] = substr($data['comment'], 0, $column['length']); + } + } + + /** + * Table specific logic to generate a display field. + * + * @since COmanage Registry v5.0.0 + * @param JobHistoryRecord $entity Entity to generate display field for + * @return string Display field + */ + + public function generateDisplayField(\App\Model\Entity\JobHistoryRecord $entity): string { + // Comments may be too long to render, so we just use the model name + // (which will get appended with the record ID) + + return __d('controller', 'JobHistoryRecords', [1]); + } + + /** + * Record a Job History Record. + * + * @since COmanage Registry v5.0.0 + * @param int $jobId Job ID + * @param string $recordKey A Job specific record identifier + * @param string $comment Comment + * @param int $personId Person ID + * @param int $externalIdentityId External Identity ID + * @return int Job History Record ID + */ + + public function record(int $jobId, + string $recordKey, + string $comment, + string $status=JobStatusEnum::Notice, + ?int $personId=null, + ?int $externalIdentityId=null, + ?int $externalIdentityRoleId=null): int { + $obj = $this->newEntity([ + 'job_id' => $jobId, + 'record_key' => $recordKey, + 'comment' => $comment, + 'person_id' => $personId, + 'external_identity_id' => $externalIdentityId, + 'status' => $status + ]); + + $this->saveOrFail($obj); + + // For now, always trace log Job History. We might do something more complicated later. + // eg: Make it configurable whether we create Job History, log, or both? + // This is documented at https://spaces.at.internet2.edu/display/COmanage/Registry+PE+Jobs#RegistryPEJobs-RegistryJobHistory + $this->llog('trace', $comment, "{$jobId}:{$recordKey}"); + + return $obj->id; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + * @throws InvalidArgumentException + * @throws RecordNotFoundException + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('job_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('job_id'); + + $validator->add('record_key', [ + 'length' => ['rule' => ['validateMaxLength', ['column' => $schema->getColumn('record_key')]], + 'provider' => 'table'], + ]); + $validator->allowEmptyString('record_key'); + + $this->registerStringValidation($validator, $schema, 'comment', true); + + $validator->add('status', [ + 'content' => ['rule' => ['inList', JobStatusEnum::getConstValues()]] + ]); + $validator->notEmptyString('status'); + + $validator->add('person_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('person_id'); + + $validator->add('external_identity_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('external_identity_id'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/JobsTable.php b/app/src/Model/Table/JobsTable.php new file mode 100644 index 000000000..cdf55aec5 --- /dev/null +++ b/app/src/Model/Table/JobsTable.php @@ -0,0 +1,730 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Artifact); + + // Define associations + $this->belongsTo('Cos'); + $this->belongsTo('RequeuedFromJobs') + ->setClassName('Jobs') + ->setForeignKey('requeued_from_job_id') + // Property is set so ruleValidateCO can find it. We don't use the + // _id suffix to match Cake's default pattern. + ->setProperty('requeued_from_job'); + + $this->hasMany('JobHistoryRecords') + ->setDependent(true) + ->setCascadeCallbacks(true); + + $this->setPluginRelations(); + + $this->setDisplayField('id'); + + $this->setPrimaryLink('co_id'); + $this->setRequiresCO(true); + $this->setAllowLookupPrimaryLink(['cancel']); + + $this->setAutoViewVars([ + 'statuses' => [ + 'type' => 'enum', + 'class' => 'JobStatusEnum' + ] + ]); + + $this->setViewContains([ + 'RequeuedFromJobs' + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'cancel' => ['platformAdmin', 'coAdmin'], + 'delete' => false,// ['platformAdmin', 'coAdmin'], + 'edit' => false,// ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, // ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ], + 'readOnly' => ['cancel'], + // Related models whose permissions we'll need, typically for table views + 'related' => [ + 'JobHistoryRecords' + ] + ]); + } + + /** + * Assign a Job to a worker. This function should be called by the process + * that will be processing the specified job. + * + * @since COmanage Registry v5.0.0 + * @param Job $job Job to assign + * @throws InvalidArgumentException + */ + + public function assign(Job $job) { + // The Job must be Queued to be Assigned + if($job->status != JobStatusEnum::Queued) { + throw new \InvalidArgumentException( + __d('error', + 'Jobs.status.invalid', + [ + $jobs->id, + __d('enumeration', 'JobStatusEnum.Queued'), + __d('enumeration', 'JobStatusEnum.Assigned'), + __d('enumeration', 'JobStatusEnum.'.$job->status) + ] + ) + ); + } + + $job->status = JobStatusEnum::Assigned; + $job->assigned_host = gethostname() ?: "unknown"; + $job->assigned_pid = getmypid() ?: -1; + + $this->saveOrFail($job); + } + + /** + * Assign the next Job in the queue to the current worker. This function is + * intended to be called by JobShell. + * + * @since COmanage Registry v5.0.0 + * @param int $coId CO ID to assign job from + * @return Job Job to process, or null if there are no more jobs in the queue + */ + + public function assignNext(int $coId): ?Job { + // Start a new transaction. When we select from the Job queue, we need a read lock + // to ensure another worker doesn't grab the same process. + $cxn = $this->getConnection(); + $cxn->begin(); + + $job = $this->find() + ->where([ + 'status' => JobStatusEnum::Queued, + 'co_id' => $coId, + 'OR' => [ + 'start_after_time IS NULL', + 'start_after_time <' => date('Y-m-d H:i:s', time()) + ] + ]) + // We sort by id to pull the oldest job first + ->order(['id' => 'ASC']) + ->epilog('FOR UPDATE') + ->first(); + + if($job) { + // Assign the Job while we're still in the transaction + $this->assign($job); + } + + $cxn->commit(); + + return $job; + } + + /** + * Define business rules. + * + * @since COmanage Registry v5.0.0 + * @param RulesChecker $rules RulesChecker object + * @return RulesChecker + */ + + public function buildRules(RulesChecker $rules): RulesChecker { + // AR-Job-1 A Job may not be registered if an existing Job with the same plugin + // and parameters is registered in either Queued or In Progress status + $rules->addCreate([$this, 'ruleAlreadyRegistered'], + 'alreadyRegistered', + ['errorField' => 'parameters']); + + // If another rule is added here, register() will need to be updated with a more + // sophisticated mechanism to disable AR-Job-1 whene passed $concurrent=true. + + return $rules; + } + + /** + * Cancel a Job. The Job must be in a cancelable state. + * + * @since COmanage Registry v5.0.0 + * @param int $id Job ID + * @param string $actor Login Identifier of actor who requested cancelation + * @throws InvalidArgumentException + * @throws RecordNotFoundException + */ + + public function cancel(int $id, string $actor) { + $job = $this->get($id); + + if(!$job->canCancel()) { + throw new \InvalidArgumentException(__d('error', + 'Jobs.status.invalid.cancel', + [ + $job->id, + __d('enumeration', 'JobStatusEnum.'.$job->status) + ])); + } + + $this->finish( + job: $job, + summary: __d('result', 'Jobs.canceled.by', [$actor]), + result: JobStatusEnum::Canceled + ); + } + + /** + * Confirm a Job was properly finished. + * + * @since COmanage Registry v5.0.0 + * @param int $pid Process ID (on the current host) + */ + + public function confirmFinished(int $pid) { + // Ordinarily we want the plugin to mark the Job as finished so it can update the + // finish summary, but if the process exits abnormally we want to capture that + // and clean up the Job. Note under extremely rare circumstances it's possible for + // the PID we're looking for to be reassigned before we can run this check, but + // that probably implies a host with runaway processes (since the PID count must cycle). + + $job = $this->find() + ->where([ + 'status' => JobStatusEnum::InProgress, + 'assigned_pid' => $pid, + 'assigned_host' => gethostname() ?: "unknown" + ]) + ->first(); + + if(!empty($job)) { + // Terminate the job + $this->finish( + job: $job, + summary: __d('error', 'Jobs.failed.abnormal'), + result: JobStatusEnum::Failed + ); + } + } + + /** + * Mark a job as completed. + * + * @since COmanage Registry v2.0.0 + * @param Job $job Job to update + * @param string $summary Summary + * @param JobStatusEnum $result Result + * @throws InvalidArgumentException + */ + + public function finish(Job $job, string $summary="", string $result=JobStatusEnum::Complete) { + // The Job must be InProgress to be finished, unless we're canceling it + if($job->status != JobStatusEnum::InProgress + && !($result == JobStatusEnum::Canceled && $job->canCancel())) { + throw new \InvalidArgumentException( + __d('error', + 'Jobs.status.invalid', + [ + $job->id, + __d('enumeration', 'JobStatusEnum.InProgress'), + __d('enumeration', 'JobStatusEnum.'.$result), + __d('enumeration', 'JobStatusEnum.'.$job->status) + ] + ) + ); + } + + $job->status = $result; + $job->finish_summary = $summary; + $job->finish_time = date('Y-m-d H:i:s', time()); + + $this->saveOrFail($job); + + // On success, if a requeue_interval is specified then register a new job + // with the same parameters, but with a delayed start time. Do the same thing + // on failure if a retry_interval is specified. We need to do this after we + // update the status of $id to avoid issues with concurrent jobs. + + if($result == JobStatusEnum::Complete + && !empty($job->requeue_interval) + && $job->requeue_interval > 0) { + // The new job will be substantially the same as the last one... + + $this->register($job->co_id, + $job->plugin, + json_decode($job->parameters, true), + $job->register_summary, + false, + // we only support serialized jobs, not concurrent + false, + $job->requeue_interval, + $job->requeue_interval, + $job->retry_interval, + $job->id); + } elseif($result == JobStatusEnum::Failed + && !empty($job->retry_interval) + && $job->retry_interval > 0) { + // The new job will be substantially the same as the last one... + + $this->register(job->co_id, + $job->plugin, + json_decode($job->parameters, true), + $job->register_summary, + false, + // we only support serialized jobs, not concurrent + false, + $job->retry_interval, + $job->requeue_interval, + $job->retry_interval, + $job->id); + } + } + + /** + * Determine if a Job has been canceled. + * + * @since COmanage Registry v5.0.0 + * @param int $id Job ID + * @return bool True if the Job has been canceled, false otherwise + */ + + public function isCanceled(int $id): bool { + // Make sure to skip any cached records, since a Job InProgress needs up to + // date status to determine if it should stop. + + $job = $this->get($id, ['cache' => false]); + + return $job->status == JobStatusEnum::Canceled; + } + + /** + * Process a Job. Jobs must be in Ready status (ie: assigned) in order to be processed. + * + * @since COmanage Registry v5.0.0 + * @param Job $job Job to process + * @throws InvalidArgumentException + */ + + public function process(Job $job) { + // The Job must be Assigned to be processed + if($job->status != JobStatusEnum::Assigned) { + throw new \InvalidArgumentException( + __d('error', + 'Jobs.status.invalid', + [ + $jobs->id, + __d('enumeration', 'JobStatusEnum.Assigned'), + __d('enumeration', 'JobStatusEnum.InProgress'), + __d('enumeration', 'JobStatusEnum.'.$job->status) + ] + ) + ); + } + + try { + // First create an instance of the Entry Point Model + $pClass = $this->instantiatePluginModel($job->plugin, '\Lib\Jobs'); + + $JobHistoryRecords = TableRegistry::getTableLocator()->get('JobHistoryRecords'); + + // Maybe set the connection on the JobHistoryTable (if we were run via + // the queue runner). + $cxn = ConnectionManager::get('plugin'); + + if(!empty($cxn)) { + $JobHistoryRecords->setConnection($cxn); + } + + $pClass->run( + $this, + $JobHistoryRecords, + $job, + json_decode(json: $job->parameters, associative: true) + ); + } + catch(\Exception $e) { + $this->finish($job, $e->getMessage(), JobStatusEnum::Failed); + } + } + + /** + * Register a new Job. + * + * @since COmanage Registry v5.0.0 + * @param int $coId CO ID + * @param string $plugin Plugin Entry Point Model, in Plugin.Model format + * @param array $parameters Plugin parameters + * @param string $registerSummary Summary + * @param bool $synchronous Whether the Job is started (true) or queued (false) + * @param bool $concurrent Whether multiple instances of this Job with the same parameters are permitted to run concurrently + * @param int $delay Minimum number of seconds to delay the start of this Job + * @param int $requeueInterval If non-zero, number of seconds after successful completion to requeue the same Job + * @param int $retryInterval If non-zero, number of seconds after failed completion to requeue the same Job + * @param int $requeuedFrom If requeued, the ID of the Job that created this Job + * @return Job Job entity + * @throws InvalidArgumentException + */ + + public function register( + int $coId, + string $plugin, + array $parameters=[], + string $registerSummary="", + bool $synchronous=false, + bool $concurrent=false, + int $delay=0, + ?int $requeueInterval=null, + ?int $retryInterval=null, + ?int $requeuedFrom=null + ): Job { + // Start a transaction. In addition to ruleAlreadyRegistered needing a read lock, + // if we're synchronous we need to make sure the current caller gets assigned the Job. + + $cxn = ConnectionManager::get('default'); + $cxn->begin(); + + // Insert a new Job into the job table. A synchronous job is queued with status + // "In Progress" on the assumption that the caller will immediately begin + // processing it. Otherwise, the job is given status "Queued". + + $errors = $this->validateJobParameters($plugin, $coId, $parameters); + + if(!empty($errors)) { + // Convert the error array to a string + $err = ""; + + foreach($errors as $p => $e) { + $err .= "$p: $e,"; + } + + $cxn->rollback(); + throw new \InvalidArgumentException(rtrim($err, ",")); + } + + $entity = $this->newEntity([ + 'co_id' => $coId, + 'plugin' => $plugin, + 'parameters' => json_encode($parameters), + 'register_summary' => $registerSummary, + 'register_time' => date('Y-m-d H:i:s', time()), + 'status' => JobStatusEnum::Queued, + 'requeue_interval' => $requeueInterval, + 'retry_interval' => $retryInterval, + 'requeued_from_job_id' => $requeuedFrom, + 'start_after_time' => date('Y-m-d H:i:s', time()+$delay) + // We don't set percent_complete here since not all jobs might use that field, + // and then a null vs 0 can be used to distinguish. + ]); + + // If $concurrent is true, we want to disable AR-Job-1. Right now, since this + // is the only application rule we can simply disable rule checking, but if + // another rule is added this won't work. + $this->saveOrFail($entity, ['checkRules' => !$concurrent]); + + if($synchronous) { + // Assign the job within the transaction to make sure it doesn't get + // picked up by a queue runner + $this->assign($entity); + } + + $cxn->commit(); + + return $entity; + } + + /** + * Application Rule to determine if the Job is already registered. + * + * @since COmanage Registyr v5.0.0 + * @param Entity $entity Entity to be validated + * @param array $options Application rule options + * @return boolean true if the Rule check passes, false otherwise + */ + + public function ruleAlreadyRegistered($entity, $options) { + // We can't SELECT COUNT ... FOR UPDATE, so we select first which is good enough + $count = $this->find() + ->where([ + 'plugin' => $entity->plugin, + 'parameters' => $entity->parameters, + 'status IN' => [JobStatusEnum::InProgress, JobStatusEnum::Queued] + ]) + ->epilog('FOR UPDATE') + ->first(); + + if(!empty($count)) { + $this->llog( + level: 'error', + msg: "AR-Job-1 A Job matching the requested plugin (" . $entity->plugin . ") and parameters is already registered", + id: $entity->id + ); + return __d('error', 'Jobs.registered.already', [$entity->plugin]); + } + + return true; + } + + /** + * Set the percent complete for a job. + * + * @since COmanage Registry v5.0.0 + * @param Job $job Job to update + * @param int $percent Percent (between 0 and 100 inclusive) + */ + + public function setPercentComplete(Job $job, int $percent) { + $job->percent_complete = $percent; + + $this->saveOrFail($job); + } + + /** + * Mark a job as in progress. + * + * @since COmanage Registry v2.0.0 + * @param Job $job Job to update + * @param string $summary Summary + * @throws InvalidArgumentException + */ + + public function start(Job $job, string $summary="") { + // The Job must be Assigned to be started + if($job->status != JobStatusEnum::Assigned) { + throw new \InvalidArgumentException( + __d('error', + 'Jobs.status.invalid', + [ + $jobs->id, + __d('enumeration', 'JobStatusEnum.Assigned'), + __d('enumeration', 'JobStatusEnum.InProgress'), + __d('enumeration', 'JobStatusEnum.'.$job->status) + ] + ) + ); + } + + $job->status = JobStatusEnum::InProgress; + $job->start_summary = $summary; + $job->start_time = date('Y-m-d H:i:s', time()); + + $this->saveOrFail($job); + } + + /** + * Validate Job parameters. + * + * @since COmanage Registry v5.0.0 + * @param string $plugin Plugin Entry Point Model, in Plugin.Model form + * @param int $coId CO ID + * @param array $params Parameters to validate + * @return array An array of validation errors, keyed on parameter name + * @throws InvalidArgumentException + */ + + protected function validateJobParameters(string $plugin, int $coId, array $params): array { + $ret = []; + + $pClass = $this->instantiatePluginModel($plugin, '\Lib\Jobs'); + + // Validate each provided parameter + + $pluginParameters = $pClass->parameterFormat(); + + foreach($params as $p => $val) { + if(!empty($pluginParameters[$p])) { + switch($pluginParameters[$p]['type']) { + case 'bool': + case 'boolean': + throw new \RuntimeException('not implemented'); +// XXX implement + break; + case 'int': + case 'integer': + if(!preg_match('/^[0-9.+-]*$/', $val)) { + $ret[$p] = __d('error', 'Jobs.plugin.parameter.int'); + } + break; + case 'select': + throw new \RuntimeException('not implemented'); +// XXX implement + break; + case 'string': + // For now, anything can pass as a string + break; + default: + $ret[$p] = __d('error', 'Jobs.plugin.parameter.type', [$pluginParameters[$p]['type']]); + break; + } + } else { + // Requested parameter is not defined + $ret[$p] = __d('error', 'Jobs.plugin.parameter.invalid'); + } + } + + // Check that required parameters were provided + + foreach(array_keys($pluginParameters) as $p) { + if(isset($pluginParameters[$p]['required']) + && $pluginParameters[$p]['required'] + && empty($params[$p])) { + $ret[$p] = __d('error', 'Jobs.plugin.parameter.required'); + } + } + + return $ret; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('co_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('co_id'); + + $this->registerStringValidation($validator, $schema, 'plugin', true); +/* + $validator->add('parameters', [ + 'content' => ['rule' => 'isArray'] + ]); + $validator->allowEmptyArray('parameters');*/ +// This doesn't work because parameters is passed as an array but stored as a string +// $this->registerStringValidation($validator, $schema, 'parameters', false); + + $validator->add('requeue_interval', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('requeue_interval'); + + $validator->add('retry_interval', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('retry_interval'); + + $validator->add('requeued_from_job_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('requeued_from_job_id'); + + $validator->add('status', [ + 'content' => ['rule' => ['inList', JobStatusEnum::getConstValues()]] + ]); + $validator->notEmptyString('status'); + + $this->registerStringValidation($validator, $schema, 'assigned_host', false); + + $validator->add('assigned_pid', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('assigned_pid'); + + $this->registerStringValidation($validator, $schema, 'register_summary', false); + + $this->registerStringValidation($validator, $schema, 'start_summary', false); + + $this->registerStringValidation($validator, $schema, 'finish_summary', false); + + $validator->add('register_time', [ + 'content' => ['rule' => 'dateTime'] + ]); + $validator->allowEmptyString('register_time'); + + $validator->add('start_after_time', [ + 'content' => ['rule' => 'dateTime'] + ]); + $validator->allowEmptyString('start_after_time'); + + $validator->add('start_time', [ + 'content' => ['rule' => 'dateTime'] + ]); + $validator->allowEmptyString('start_time'); + + $validator->add('finish_time', [ + 'content' => ['rule' => 'dateTime'] + ]); + $validator->allowEmptyString('finish_time'); + + $validator->add('percent_complete', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->add('percent_complete', [ + 'range' => ['rule' => 'range', 0, 100] + ]); + $validator->allowEmptyString('percent_complete'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/MetaTable.php b/app/src/Model/Table/MetaTable.php new file mode 100644 index 000000000..bc77e08cc --- /dev/null +++ b/app/src/Model/Table/MetaTable.php @@ -0,0 +1,90 @@ +setTableType(\App\Lib\Enum\TableTypeEnum::Metadata); + + $this->setDisplayField('upgrade_version'); + } + + /** + * Determine the current "upgrade" version. + * + * @since COmanage Registry v5.0.0 + * @return Current version + */ + + public function getUpgradeVersion() { + $sql = "SELECT upgrade_version FROM meta"; + + $connection = ConnectionManager::get('default'); + $results = $connection->execute($sql)->fetchAll('assoc'); + + return $results['upgrade_version']; + } + + /** + * Update the current "upgrade" version. + * + * @since COmanage Registry v5.0.0 + * @param String $version New current version + * @param Boolean $insert Whether to assume an insert rather than an update + * @return Boolean True on success + */ + + public function setUpgradeVersion($version, $insert=false) { + $sql = null; + + if($insert) { + $sql = "INSERT INTO meta (upgrade_version) VALUES (:v)"; + } else { + $sql = "UPDATE meta SET upgrade_version = :v"; + } + + $connection = ConnectionManager::get('default'); + $results = $connection->execute($sql, ['v' => $version])->fetchAll('assoc'); + + return true; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/NamesTable.php b/app/src/Model/Table/NamesTable.php new file mode 100644 index 000000000..69fcfc3cb --- /dev/null +++ b/app/src/Model/Table/NamesTable.php @@ -0,0 +1,437 @@ + [ + 'alternate', + 'author', + 'fka', + 'official', + 'preferred' + ] + ]; + + /** + * Provide the default layout + * + * @since COmanage Registry v5.0.0 + * @return string Type of redirect + */ + public function getLayout(): string { + return "iframe"; + } + + /** + * Perform Cake Model initialization. + * + * @since COmanage Registry v5.0.0 + * @param array $config Configuration options passed to constructor + */ + + public function initialize(array $config): void { + $this->addBehavior('Changelog'); + $this->addBehavior('Log'); + // Timestamp behavior handles created/modified updates + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Secondary); + + // Define associations + $this->belongsTo('People'); + $this->belongsTo('ExternalIdentities'); + $this->belongsTo('Types'); + + $this->setDisplayField('full_name'); + + $this->setPrimaryLink(['external_identity_id', 'person_id']); + $this->setAllowLookupPrimaryLink(['primary', 'unfreeze']); + $this->setRequiresCO(true); + // Models that AcceptCoId should be expicitly added to StandardApiController::initialize() + $this->setAcceptsCoId(true); + $this->setRedirectGoal('self'); + + $this->setAutoViewVars([ + 'languages' => [ + 'type' => 'enum', + 'class' => 'LanguageEnum' + ], + 'types' => [ + 'type' => 'type', + 'attribute' => 'Names.type' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'primary' => ['platformAdmin', 'coAdmin'], + 'unfreeze' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that are permitted on readonly entities (besides view) + 'readOnly' => ['unfreeze'], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Callback before data is marshaled into an entity. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event beforeMarshal event + * @param ArrayObject $data Entity data + * @param ArrayObject $options Callback options + */ + + public function beforeMarshal(EventInterface $event, \ArrayObject $data, \ArrayObject $options) + { + if(!empty($data['source_name_id'])) { + // Source records may not assert primary name on the Person copy. +// XXX this implies an EIS name cannot be a primary name - document as an AR + $data['primary_name'] = false; + } + } + + /** + * Define business rules. + * + * @since COmanage Registry v5.0.0 + * @param RulesChecker $rules RulesChecker object + * @return RulesChecker + */ + + public function buildRules(RulesChecker $rules): RulesChecker { + // AR-Name-4 Each Person or ExternalIdentity must have at least one name at + // all times. + $rules->addDelete([$this, 'ruleMinimumOneName'], + 'minimumOneName', + // This rule is really an entity rule, not a field rule, + // but cake won't pass the error without a specific field + ['errorField' => 'id']); + + // AR-Name-1 The Primary Name cannot be deleted. + $rules->addDelete([$this, 'rulePrimaryNameDelete'], + 'primaryNameDelete', + ['errorField' => 'primary_name']); + + return $rules; + } + + /** + * Callback after model save. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Event + * @param EntityInterface $entity Entity (ie: Co) + * @param ArrayObject $options Save options + * @return bool True on success + */ + + public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { + $this->recordHistory($entity); + + // AR-Name-1 A Person must have exactly one Primary Name at all times. + // To enforce this, if the current $entity is flagged Primary Name, AND + // the current entity is new or was not previously the Primary Name, we look + // for any other names on the same Person or External Identity that are + // flagged Primary and unset them. + + if($entity->primary_name) { + if($entity->isNew() || !$entity->getOriginal('primary_name')) { + // We either have a brand new name flagged as primary, or a previously + // existing name that has been updated to be primary. Unset any other primary_name. + + $where = array_merge( + $entity->whereClause(), + [ + 'id IS NOT' => $entity->id, + 'primary_name' => true, + ] + ); + + // We can use the ORM here since we are unsetting primary_name, which + // means we won't get into an infinite callback loop. (Meanwhile, we do + // want other callbacks, in particular ChangelogBehavior, to run.) + + $query = $this->find('all')->where($where); + + foreach($query->all() as $obj) { + $obj->primary_name = false; + $this->save($obj); + } + } + + $this->recordHistory($entity, ActionEnum::NamePrimary, __d('result', 'Names.primary_name')); + } + + return true; + } + + /** + * Obtain the primary name entity for a person. + * + * @since COmanage Registry v5.0.0 + * @param int $id Record ID + * @param string $recordType Type of record to find primary name for, 'person' or 'external_identity' + * @return Name Name Entity + */ + + public function primaryName(int $id, string $recordType='person') { + if($recordType == 'person') { + // Return the Primary Name + + return $this->find() + ->where(['person_id' => $id, + 'primary_name' => true]) + ->firstOrFail(); + } else { + // Return the first name, whatever it is + + return $this->find() + ->where(['external_identity_id' => $id]) + ->firstOrFail(); + } + } + + /** + * Application Rule to determine if there is at least one Name associated + * with the Person. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Entity to be validated + * @param array $options Application rule options + * @return boolean true if the Rule check passes, false otherwise + */ + + public function ruleMinimumOneName($entity, $options) { + $count = $this->find()->where($entity->whereClause())->count(); + + if($count == 1) { + $this->llog( + level: 'error', + msg: "AR-Name-4 Each Person or ExternalIdentity must have at least one name at all times", + id: $entity->id + ); + return __d('error', 'Names.minimum'); + } + + return true; + } + + /** + * Application Rule to determine if the Primary Name is trying to be deleted. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Entity to be validated + * @param array $options Application rule options + * @return boolean true if the Rule check passes, false otherwise + */ + + public function rulePrimaryNameDelete($entity, $options) { + if($entity->primary_name) { + $this->llog( + level: 'error', + msg: "AR-Name-1 The Primary Name cannot be deleted", + id: $entity->id + ); + return __d('error', 'Names.primary_name.del'); + } + + return true; + } + + /** + * Perform a keyword search. + * + * @since COmanage Registry v5.0.0 + * @param int $coId CO ID to constrain search to + * @param string $q String to search for + * @param int $limit Search limit + * @return Array Array of search results, as from find('all) + */ + + public function search(int $coId, string $q, int $limit) { + // Tokenize $q on spaces + $tokens = explode(" ", $q); + + $ret = array(); + + // We take two loops through, the first time we only do a prefix search + // (foo%). If that doesn't reach the search limit, we'll do an infix search + // the second time around. + + $whereClause = []; + + foreach($tokens as $t) { + $whereClause[1]['AND'][] = [ + 'OR' => [ + 'LOWER(Names.given) LIKE' => strtolower($t) . '%', + 'LOWER(Names.middle) LIKE' => strtolower($t) . '%', + 'LOWER(Names.family) LIKE' => strtolower($t) . '%' + ] + ]; + + $whereClause[2]['AND'][] = [ + 'OR' => [ + 'LOWER(Names.given) LIKE' => '%' . strtolower($t) . '%', + 'LOWER(Names.middle) LIKE' => '%' . strtolower($t) . '%', + 'LOWER(Names.family) LIKE' => '%' . strtolower($t) . '%' + ] + ]; + } + + $results = $this->find() + ->where($whereClause[1]) + ->andWhere(['People.co_id' => $coId]) + ->order(['Names.family', 'Names.given', 'Names.middle']) + ->limit($limit) + ->contain(['People' => 'PrimaryName']) + ->all(); + + if($results->count() < $limit) { + $results2 = $this->find() + ->where($whereClause[2]) + ->andWhere(['People.co_id' => $coId]) + ->order(['Names.family', 'Names.given', 'Names.middle']) + ->limit($limit - $results->count()) + ->contain(['People' => 'PrimaryName']) + ->all(); + + $results = $results->append($results2); + } + + return $results; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + * @throws InvalidArgumentException + * @throws RecordNotFoundException + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + // We need the current CO ID to dynamically set validation rules according + // to CoSettings. + + if(!$this->curCoId) { + throw new \InvalidArgumentException(__d('error', 'coid')); + } + + $CoSettings = TableRegistry::getTableLocator()->get('CoSettings'); + + $settings = $CoSettings->find()->where(['co_id' => $this->curCoId])->firstOrFail(); + + $permittedFields = $settings->name_permitted_fields_array(); + $requiredFields = $settings->name_required_fields_array(); + + $this->registerPrimaryKeyValidation($validator, $this->getPrimaryLinks()); + + foreach(['honorific', 'given', 'middle', 'family', 'suffix'] as $f) { + $validator->add($f, [ + 'size' => ['rule' => ['validateMaxLength', ['column' => $schema->getColumn($f)]], + 'provider' => 'table'], + 'filter' => ['rule' => ['validateInput'], + 'provider' => 'table'] + ]); + if(in_array($f, $requiredFields)) { + $validator->notEmptyString($f); + } else { + $validator->allowEmptyString($f); + } + } + + $validator->add('type_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('type_id'); + + $validator->add('language', [ + 'content' => ['rule' => ['inList', LanguageEnum::getConstValues()]] + ]); + $validator->allowEmptyString('language'); + + $validator->add('primary_name', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('primary_name'); + + $this->registerStringValidation($validator, $schema, 'display_name', false); + + $validator->add('frozen', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('frozen'); + + $validator->add('source_name_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('source_name_id'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/PeopleTable.php b/app/src/Model/Table/PeopleTable.php new file mode 100644 index 000000000..b4a6527fd --- /dev/null +++ b/app/src/Model/Table/PeopleTable.php @@ -0,0 +1,682 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Primary); + + // Define associations + $this->belongsTo('Cos'); + + $this->hasOne('PrimaryName') + ->setClassName('Names') + // We have to explicitly set the foreign key here so that the relations + // ManagerPeople and SponsorPeople (in PersonRolesTable) get the correct + // foreign key into Names table when pulling data via contains (as in + // marshalProvisioningData(), below) + ->setForeignKey('person_id') + ->setConditions(['PrimaryName.primary_name' => true]); + $this->hasMany('Names') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('Addresses') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('AdHocAttributes') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('EmailAddresses') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('ExternalIdentities') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('GroupMembers') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('HistoryRecords') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('Identifiers') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('JobHistoryRecords') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('PersonRoles') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('Pronouns') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('ProvisioningHistoryRecords') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('TelephoneNumbers') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('Urls') + ->setDependent(true) + ->setCascadeCallbacks(true); + +// XXX can we change this to Name? + $this->setDisplayField('id'); + + $this->setPrimaryLink('co_id'); + $this->setRequiresCO(true); + $this->setRedirectGoal('self'); + $this->setAllowLookupPrimaryLink(['provision']); + +// XXX does some of this stuff really belong in the controller? + $this->setEditContains([ + 'PrimaryName', + 'Addresses', + 'AdHocAttributes', + 'EmailAddresses', + 'Identifiers', + 'Names', + //'PersonRoles', + 'Pronouns', + 'TelephoneNumbers', + 'Urls' + ]); + $this->setIndexContains(['PrimaryName']); + $this->setViewContains(['PrimaryName']); + + $this->setAutoViewVars([ + 'statuses' => [ + 'type' => 'enum', + 'class' => 'StatusEnum' + ], + 'types' => [ + 'type' => 'type', + 'attribute' => 'Names.type' + ] + ]); + + // XXX expand/revise this as needed to work best with looking up the related models + $this->setFilterConfig([ + 'family' => [ + 'type' => 'relatedModel', + 'model' => 'Name', + 'active' => true, + 'order' => 2 + ], + 'given' => [ + 'type' => 'relatedModel', + 'model' => 'Name', + 'active' => true, + 'order' => 1 + ], + 'mail' => [ + 'type' => 'relatedModel', + 'model' => 'EmailAddress', + 'active' => true, + 'order' => 3 + ], + 'identifier' => [ + 'type' => 'relatedModel', + 'model' => 'Identifier', + 'active' => true, + 'order' => 4 + ], + 'timezone' => [ + 'type' => 'field', + 'active' => false, + 'order' => 99 + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) +// See also CFM-126 + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'provision' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ], + // Related models whose permissions we'll need, typically for table views + 'related' => [ + 'Addresses', + 'AdHocAttributes', + 'Names', + 'EmailAddresses', + 'ExternalIdentities', + 'HistoryRecords', + 'IdentifierAssignments', + 'Identifiers', + 'PersonRoles', + 'ProvisioningTargets', + 'TelephoneNumbers', + 'Urls' + ] + ]); + } + + /** + * Callback before model delete. + * + * @since COmanage Registry v5.0.0 + * @param CakeEventEvent $event The beforeDelete event + * @param $entity Entity + * @param ArrayObject $options Options + * @return boolean True on success + */ + + public function beforeDelete(\Cake\Event\Event $event, $entity, \ArrayObject $options) { +// XXX we are effectively reimplementing expunge logic here, maybe move it to +// a new protected PeopleTable::expunge() function (called only from here)? + // If we were only dealing with hard delete, we wouldn't need implementedEvents() + // below, because ChangelogBehavior ignores hard deletes. + + // Whether soft or hard deleting, we need to remove Automatic Group Memberships + // before we delete the Person, or lookups performed while managing those + // group memberships will fail. + + $this->reconcileCoMembersGroupMemberships(entity: $entity, deleted: true); + + if(isset($options['useHardDelete']) + && $options['useHardDelete'] + && $entity->id > 0) { + // Hard delete, so clear out any foreign keys pointing to this Person. + // This will also clear foreign keys from archived changelog records. + + $this->PersonRoles->updateAll( + [ 'manager_person_id' => null ], + [ 'manager_person_id' => $entity->id ] + ); + + $this->PersonRoles->updateAll( + [ 'sponsor_person_id' => null ], + [ 'sponsor_person_id' => $entity->id ] + ); + + // Manually delete any names, since the validation rules will fail on cascade. + $this->Names->deleteAll( + [ 'person_id' => $entity->id ] + ); + } else { + // Manually delete any names, since the validation rules will fail on cascade. + // Since this isn't a hard delete we can't use deleteAll since we need + // ChangelogBehavior to fire. + + $names = $this->Names->find()->where(['person_id' => $entity->id])->all(); + + foreach($names as $n) { + $this->Names->delete($n, ['checkRules' => false]); + } + } + + return true; + } + + /** + * Customized finder for the Index Population View + * + * @param Query $query Cake ORM Query + * @param array $options Cake ORM Query options + * + * @return CakeORMQuery Cake ORM Query + * @since COmanage Registry v5.0.0 + */ + public function findIndexed(Query $query, array $options): Query { + return $query->select([ + 'People.id', + 'PrimaryName.given', + 'PrimaryName.family', + 'People.status', + 'People.created', + 'People.modified', + 'People.timezone', + 'People.date_of_birth' + ]) + ->distinct(); + } + + /** + * Table specific logic to generate a display field. + * + * @since COmanage Registry v5.0.0 + * @param Person $entity Entity to generate display field for + * @return string Display field + */ + + public function generateDisplayField(\App\Model\Entity\Person $entity): string { + if(empty($entity->primary_name)) { + throw new \InvalidArgumentException(__d('error', 'Names.primary_name')); + } + + return $entity->primary_name->full_name; + } + + /** + * Obtain an iterator for the set of Members in the specified CO. + * + * @since COmanage Registry v5.0.0 + * @param int $coId CO ID + * @return PaginatedSqlIterator Iterator for People + */ + + public function getMembers(int $coId): PaginatedSqlIterator { + $conditions = [ + 'co_id' => $coId, + 'status IS NOT' => StatusEnum::Archived + ]; + + return new PaginatedSqlIterator($this, $conditions); + } + + /** + * Define the table's implemented events. + * + * @since COmanage Registry v5.0.0 + */ + + public function implementedEvents(): array { + $events = parent::implementedEvents(); + + // We need to adjust our beforeDelete priority to run before ChangelogBehavior's. + $events['Model.beforeDelete'] = [ + 'callable' => 'beforeDelete', + 'priority' => 1 + ]; + + return $events; + } + + /** + * Callback after model save. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Event + * @param EntityInterface $entity Entity (ie: Co) + * @param ArrayObject $options Save options + * @return bool True on success + */ + + public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { + $this->recordHistory($entity); + + // XXX implement this eventually? + //$provision = (isset($options['provision']) ? $options['provision'] : true); + + if(!$entity->deleted) { + // If the entity was deleted we handled this in beforeDelete, above + $this->reconcileCoMembersGroupMemberships($entity); + } + + return true; + } + + /** + * Marshal object data for provisioning. + * + * @since COmanage Registry v5.0.0 + * @param int $id Entity ID + * @return array An array of provisionable data and eligibility + */ + + public function marshalProvisioningData(int $id): array { + $ret = []; + + $ret['data'] = $this->get($id, [ + // We need archives for handling deleted records + 'archived' => 'true', + 'contain' => [ + 'PrimaryName' => [ 'Types' ], + 'Addresses' => [ 'Types' ], + 'AdHocAttributes', + 'EmailAddresses' => [ 'Types' ], + 'ExternalIdentities' => [ + 'Addresses' => [ 'Types' ], + 'AdHocAttributes', + 'EmailAddresses' => [ 'Types' ], + 'ExternalIdentityRoles' => [ + 'Addresses' => [ 'Types' ], + 'AdHocAttributes', + 'TelephoneNumbers' => [ 'Types' ], + 'Types' + ], + 'Identifiers' => [ 'Types' ], + 'Names' => [ 'Types' ], + 'Pronouns', + 'TelephoneNumbers' => [ 'Types' ], + 'Urls' => [ 'Types' ] + ], + 'GroupMembers' => [ 'Groups' ], + 'Identifiers' => [ 'Types' ], + 'Names' => [ 'Types' ], + 'PersonRoles' => [ + 'Addresses' => [ 'Types' ], + 'AdHocAttributes', + 'Cous', + 'ManagerPeople' => [ 'PrimaryName' ], + 'SponsorPeople' => [ 'PrimaryName' ], + 'TelephoneNumbers' => [ 'Types' ], + 'Types' + ], + 'Pronouns', + 'TelephoneNumbers' => [ 'Types' ], + 'Urls' => [ 'Types' ] + ] + ]); + + // Provisioning Eligibility is + // - Deleted if the changelog deleted flag is true OR status is Archived + // - Eligible if entity->isActive() + // - Ineligible otherwise + + // Most statuses don't provision anything + $ret['eligibility'] = ProvisioningEligibilityEnum::Deleted; + + // We filter various attributes depending on the status of the record. + + if($ret['data']->deleted || $ret['data']->status == StatusEnum::Archived) { + $ret['eligibility'] = ProvisioningEligibilityEnum::Deleted; + + // For deleted or archived records, we remove everything except names + // and identifiers, which might be useful for error reporting and record keeping. + // Unlike Ineligible, we *don't* keep the All Members groups. + + $ret['data']->ad_hoc_attributes = []; + $ret['data']->addresses = []; + $ret['data']->email_addresses = []; + $ret['data']->external_identities = []; + $ret['data']->group_members = []; + $ret['data']->group_owners = []; + $ret['data']->person_roles = []; + $ret['data']->pronouns = []; + $ret['data']->telephone_numbers = []; + $ret['data']->urls = []; + } elseif($ret['data']->isActive()) { + $ret['eligibility'] = ProvisioningEligibilityEnum::Eligible; + + // For Eligible, we still need to remove Person Roles and Group Memberships + // that are invalid, and Identifiers that are suspended. + + $personRoles = []; + + foreach($ret['data']->person_roles as $pr) { + if($pr->isValid()) { + $personRoles[] = $pr; + } + } + + $ret['data']->person_roles = $personRoles; + + $groupMembers = []; + + foreach($ret['data']->group_members as $gm) { + if($gm->isValid()) { + $groupMembers[] = $gm; + } + } + + $ret['data']->group_members = $groupMembers; + + $identifiers = []; + + foreach($ret['data']->identifiers as $ident) { + if($ident->status == SuspendableStatusEnum::Active) { + $identifiers[] = $ident; + } + } + + $ret['data']->identifiers = $identifiers; + } else { + $ret['eligibility'] = ProvisioningEligibilityEnum::Ineligible; + // For Ineligible records, we remove the items that may be used for eligibilities, + // specifically group memberships/ownerships and PersonRoles. We leave the + // All Members group in place. We also remove any suspended Identifiers. + + $groupMembers = []; + + foreach($ret['data']->group_members as $gm) { + if($gm->group->isAllMembers()) { + $groupMembers[] = $gm; + } + } + + $ret['data']->group_members = $groupMembers; + + $identifiers = []; + + foreach($ret['data']->identifiers as $ident) { + if($ident->status == SuspendableStatusEnum::Active) { + $identifiers[] = $ident; + } + } + + $ret['data']->identifiers = $identifiers; + + $ret['data']->group_owners = []; + $ret['data']->person_roles = []; + } + + return $ret; + } + + /** + * Recalculate Person status based on Person Roles status. + * + * @since COmanage Registry v5.0.0 + * @param int $id Person ID + * @return string New Person status + */ + + public function recalculateStatus(int $id): ?string { + $newStatus = null; + + // Start by pulling the roles for this person, along with the Person record + + $person = $this->get($id, ['contain' => 'PersonRoles']); + + if(!empty($person->person_roles)) { + foreach($person->person_roles as $role) { + if(!$newStatus) { + // This is the first role, just set the new status to it + + $newStatus = $role->status; + } else { + // Check if this role's status is more preferable than the current status + + if(StatusEnum::rank($role->status) > StatusEnum::rank($newStatus)) { + $newStatus = $role->status; + } + } + } + } + + if($newStatus) { + if($newStatus != $person->status) { + // Locked status cannot be recalculated. This isn't an error, per se. + if($person->status == StatusEnum::Locked) { + $this->llog('trace', 'Not recalculating Person " . $person->id . " status since the record is locked'); + return $curStatus; + } + + // Update the Person status + $oldStatus = $person->status; + $person->status = $newStatus; + $this->save($person); + + // Record history + $this->recordHistory( + entity: $person, + action: ActionEnum::PersonStatusRecalculated, + comment: __d('result', + 'People.status.recalculated', + [__d('enumeration', 'StatusEnum.'.$oldStatus), + __d('enumeration', 'StatusEnum.'.$newStatus)]) + ); + + // We shouldn't need to manually trigger provisioning here since we'll typically + // be called via PersonRole::afterSave(), which will be called by some other + // context (StandardController, Pipelines, etc) that will manage provisioning + // after the PersonRole save (to the calling context's perspective) is finished. +// $this->requestProvisioning(id: $obj->id, context: ProvisioningContextEnum::Automatic); + } + // else nothing to do, status is unchanged + } + // else no roles, leave status unchanged + + return $newStatus; + } + + /** + * Reconcile memberships in CO members groups based on the Person entity. + * + * @since COmanage Registry v5.0.0 + * @param EntityInterface $entity Person Entity + * @param bool $provision Whether to run provisioners + * @param bool $deleted Whether $entity should be treated as deleted + * @throws InvalidArgumentException + * @throws RuntimeException + */ + + public function reconcileCoMembersGroupMemberships( + \Cake\Datasource\EntityInterface $entity, + bool $provision=true, + bool $deleted=false + ) { + // This is similar to PersonRole::reconcileCouMembersGroupMemberships. + + $activeEligible = !$deleted && $entity->isActive(); + $allEligible = !$deleted && ($entity->status != StatusEnum::Archived); + + // Update the automatic CO groups + $this->llog('rule', "AR-Person-1 Syncing membership in All Members Group for CO " . $entity->co_id . " for Person " . $entity->id . ", eligibility=" . $allEligible); + $this->GroupMembers->syncAutomaticMembership(GroupTypeEnum::AllMembers, null, $entity->id, $allEligible, $provision); + $this->llog('rule', "AR-Person-2 Syncing membership in Active Members Group for CO " . $entity->co_id . " for Person " . $entity->id . ", eligibility=" . $activeEligible); + $this->GroupMembers->syncAutomaticMembership(GroupTypeEnum::ActiveMembers, null, $entity->id, $activeEligible, $provision); + + // Pull the Person Roles for this Person. Note if COUs are not in use this + // will be a bit of extra work, but probably not worth worrying about. + + $personRoles = $this->PersonRoles->find('all') + ->where(['person_id' => $entity->id]) + ->all(); + + foreach($personRoles as $role) { + if(!empty($role->cou_id)) { + // If the Person is not $allEligible, then no COU groups are eligible either. + + if($allEligible) { + // If a Person has multiple roles in the same COU, we'll be doing a bit + // of extra work in calling reconcileCouMembersGroupMemberships multiple + // times, since it will correctly handle multiple roles in the same COU + // in a single call. + + $this->PersonRoles->reconcileCouMembersGroupMemberships($role, $provision, $activeEligible); + } else { + // Make sure there are no memberships for this COU + $this->GroupMembers->syncAutomaticMembership(GroupTypeEnum::AllMembers, $role->cou_id, $entity->id, false, $provision); + $this->GroupMembers->syncAutomaticMembership(GroupTypeEnum::ActiveMembers, $role->cou_id, $entity->id, false, $provision); + } + } + } + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $validator->add('co_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('co_id'); + + $validator->add('status', [ + 'content' => ['rule' => ['inList', StatusEnum::getConstValues()]] + ]); + $validator->notEmptyString('status'); + + $validator->add('timezone', [ + 'content' => ['rule' => ['validateTimeZone'], + 'provider' => 'table' ] + ]); + $validator->allowEmptyString('timezone'); + + $validator->add('date_of_birth', [ + 'content' => ['rule' => 'date'] + ]); + $validator->allowEmptyString('date_of_birth'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/PersonRolesTable.php b/app/src/Model/Table/PersonRolesTable.php new file mode 100644 index 000000000..ac797fee5 --- /dev/null +++ b/app/src/Model/Table/PersonRolesTable.php @@ -0,0 +1,670 @@ + [ + 'affiliate', + 'alum', + 'employee', + 'faculty', + 'librarywalkin', + 'member', + 'staff', + 'student' + ] + ]; + + // Cache status info if we automatically recalculated the status in beforeMarshal + protected $autoStatus = null; + + /** + * Perform Cake Model initialization. + * + * @since COmanage Registry v5.0.0 + * @param array $config Configuration options passed to constructor + */ + + public function initialize(array $config): void { + // Timestamp behavior handles created/modified updates + $this->addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + $this->addBehavior('Timezone'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Secondary); + + // Define associations + $this->belongsTo('Cous'); + $this->belongsTo('People'); + $this->belongsTo('ManagerPeople') + ->setClassName('People') + ->setForeignKey('manager_person_id') + // Property is set so ruleValidateCO can find it. We don't use the + // _id suffix to match Cake's default pattern. + ->setProperty('manager_person'); + $this->belongsTo('SponsorPeople') + ->setClassName('People') + ->setForeignKey('sponsor_person_id') + ->setProperty('sponsor_person'); + $this->belongsTo('SourceExternalIdentityRoles') + ->setClassName('ExternalIdentityRoles') + ->setForeignKey('source_external_identity_role_id') + ->setProperty('source_external_identity_role'); + $this->belongsTo('Types') + ->setForeignKey('affiliation_type_id') + ->setProperty('affiliation_type'); + + $this->hasMany('Addresses') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('AdHocAttributes') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('TelephoneNumbers') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('HistoryRecords') + ->setDependent(true) + ->setCascadeCallbacks(true); + + $this->setDisplayField('title'); + + $this->setPrimaryLink('person_id'); + $this->setRequiresCO(true); + $this->setRedirectGoal('self'); + $this->setAllowLookupPrimaryLink(['unfreeze']); + + $this->setEditContains([ + 'Addresses', + 'AdHocAttributes', + 'TelephoneNumbers', + // contain results in a join when the relation is belongsTo (or hasOne), + // and joining the same table twice makes the database unhappy, so we + // force these to use multiple queries. + 'ManagerPeople' => ['Names' => ['queryBuilder' => function ($q) { + return $q->where(['primary_name' => true]); + }]], + 'SponsorPeople' => ['Names' => ['queryBuilder' => function ($q) { + return $q->where(['primary_name' => true]); + }]], + 'SourceExternalIdentityRoles' + ]); + + $this->setViewContains([ + 'SourceExternalIdentityRoles' + ]); + + $this->setAutoViewVars([ + 'statuses' => [ + 'type' => 'enum', + 'class' => 'StatusEnum' + ], + 'affiliationTypes' => [ + 'type' => 'type', + 'attribute' => 'PersonRoles.affiliation_type' + ], + 'cous' => [ + 'type' => 'select', + 'model' => 'Cous' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) +// See also CFM-126 +// XXX need to add couAdmin, eventually + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'unfreeze' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that are permitted on readonly entities (besides view) + 'readOnly' => ['unfreeze'], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Callback before data is marshaled into an entity. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event beforeMarshal event + * @param ArrayObject $data Entity data + * @param ArrayObject $options Callback options + */ + + public function beforeMarshal(EventInterface $event, \ArrayObject $data, \ArrayObject $options) + { + // Perform validity date/status reconciliation. status should always be set, + // but we'll check for it just in case. + if(!empty($data['status'])) { + // Note that $data['id'] will _not_ be set, even for updates, so we can't directly + // reference the Person Role ID in log records + + // AR-PersonRole-4 A Person Role with a Valid From date in the future and a status + // of Active, Expired, or Grace Period will be given a status of Pending Activation, + // unless the Person Role is frozen. A Person Role in Pending Activation status with + // a valid from date in the past will be given a status of Active. + + if(!empty($data['valid_from'])) { + $validFrom = new FrozenTime($data['valid_from']); + + if($validFrom->isPast() + && $data['status'] == StatusEnum::PendingActivation) { + if(empty($data['frozen']) || !$data['frozen']) { + $this->autoStatus = [ 'from' => $data['status'], 'to' => StatusEnum::Active ]; + $this->llog('rule', "AR-PersonRole-4 Updating status on Person Role for Person " . $data['person_id'] . " from Pending Activation to Active"); + $data['status'] = StatusEnum::Active; + } else { + $this->llog('trace', 'Not recalculating status on Person Role for Person ' . $data['person_id'] . ' since the record is frozen'); + } + } elseif($validFrom->isFuture() + && in_array($data['status'], [StatusEnum::Active, + StatusEnum::Expired, + StatusEnum::GracePeriod])) { + if(empty($data['frozen']) || !$data['frozen']) { + $this->autoStatus = [ 'from' => $data['status'], 'to' => StatusEnum::PendingActivation ]; + $this->llog('rule', "AR-PersonRole-4 Updating status on Person Role for Person " . $data['person_id'] . " from " . $data['status'] . " to Pending Activation"); + $data['status'] = StatusEnum::PendingActivation; + } else { + $this->llog('trace', 'Not recalculating status on Person Role for Person ' . $data['person_id'] . ' since the record is frozen'); + } + } + } + + // AR-PersonRole-5 A Person Role with a Valid Through date in the past and a status + // of Active, Grace Period, or Pending Activation will be given a status of Expired, + // unless the Person Role is frozen. A Person Role in Expired status with a valid + // from date in the future will be given a status of Active. + + if(!empty($data['valid_through'])) { + $validThrough = new FrozenTime($data['valid_through']); + + if($validThrough->isFuture() + && $data['status'] == StatusEnum::Expired) { + if(empty($data['frozen']) || !$data['frozen']) { + $this->autoStatus = [ 'from' => $data['status'], 'to' => StatusEnum::Active ]; + $this->llog('rule', "AR-PersonRole-5 Updating status on Person Role for Person " . $data['person_id'] . " from Expired to Active"); + $data['status'] = StatusEnum::Active; + } else { + $this->llog('trace', 'Not recalculating status on Person Role for Person ' . $data['person_id'] . ' since the record is frozen'); + } + } elseif($validThrough->isPast() + && in_array($data['status'], [StatusEnum::Active, + StatusEnum::GracePeriod, + StatusEnum::PendingActivation])) { + if(empty($data['frozen']) || !$data['frozen']) { + $this->autoStatus = [ 'from' => $data['status'], 'to' => StatusEnum::Expired ]; + $this->llog('rule', "AR-PersonRole-5 Updating status on Person Role for Person " . $data['person_id'] . " from " . $data['status'] . " to Pending Expired"); + $data['status'] = StatusEnum::Expired; + } else { + $this->llog('trace', 'Not recalculating status on Person Role for Person ' . $data['person_id'] . ' since the record is frozen'); + } + } + } + } + } + + /** + * Define business rules. + * + * @since COmanage Registry v5.0.0 + * @param RulesChecker $rules RulesChecker object + * @return RulesChecker + */ + + public function buildRules(RulesChecker $rules): RulesChecker { + // AR-PersonRole-6 If both valid from and valid through dates are provided for + // a Person Role, the valid from date must be earlier than the valid through date. + + $rules->add([$this, 'ruleDatesSequential'], + 'datesSequential', + ['errorField' => 'valid_from']); + + return $rules; + } + + /** + * Table specific logic to generate a display field. + * + * @since COmanage Registry v5.0.0 + * @param Person $entity Entity to generate display field for + * @return string Display field + */ + + public function generateDisplayField(\App\Model\Entity\PersonRole $entity): string { + // Try to find something renderable + + if(!empty($entity->title)) { + return $entity->title; + } + +// XXX else affiliation type if set, else cou name, else organization, else department + + return (string)$entity->id; + } + + /** + * Get information related to automatic status recalculation. + * + * @since COmanage Registry v5.0.0 + * @return array 'from': Old status, 'to': New status + */ + + public function getAutoStatus(): ?array { + return $this->autoStatus; + } + + /** + * Obtain an iterator for the set of Members in the specified COU. + * + * @since COmanage Registry v5.0.0 + * @param int $couId COU ID + * @return PaginatedSqlIterator Iterator for Person Roles + */ + + public function getMembers(int $couId): PaginatedSqlIterator { + // We don't explicitly look at valid from/through, instead we expect that + // Expiration Policies will correctly set status. + $conditions = [ + 'cou_id' => $couId, + 'status IS NOT' => StatusEnum::Archived + ]; + + return new PaginatedSqlIterator($this, $conditions); + } + + /** + * Determine if the specified Person has a valid Person Role in the specified + * COU. Note this function only looks at status, not validity dates. + * + * @param int $personId Person ID + * @param int $couId COU ID + * @return bool True if the Person has at least one active Person Role, false otherwise + */ + + public function hasActive(int $personId, int $couId): bool { + // We return true if the Person has at least one active Role in the + // specified COU. We ignore validity dates, expecting instead that + // expiration policies will correctly update the Role status as needed. + + // We need to examine the status of all roles in the COU, not just the current + // one, to see if the person is eligible for the relevant members group. + + $roles = $this->find('all') + ->where(['person_id' => $personId, + 'cou_id' => $couId]) + ->all(); + + foreach($roles as $role) { + // Any one active role is sufficient + + if($role->isActive()) { + return true; + } + } + + return false; + } + + /** + * Determine if the specified Person has any Person Role in the specified COU. + * + * @param int $personId Person ID + * @param int $couId COU ID + * @return bool True if the Person has at least one Person Role, false otherwise + */ + + public function hasAny(int $personId, int $couId): bool { + // We return true if the Person has at least one Role in the specified COU, + // regardless of status. + + // We need to examine the status of any roles returned since an Archived Role + // does not count as "Any" Role. + + $roles = $this->find('all') + ->where(['person_id' => $personId, + 'cou_id' => $couId]) + ->all(); + + if(empty($roles)) { + return false; + } + + foreach($roles as $role) { + // Any non-archived role is sufficient + if($role->status != StatusEnum::Archived) { + return true; + } + } + + return false; + } + + /** + * Callback after model save. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Event + * @param EntityInterface $entity Entity (ie: Co) + * @param ArrayObject $options Save options + * @return bool True on success + */ + + public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { + $this->recordHistory($entity); + + $this->reconcileCouMembersGroupMemberships($entity); + + if($entity->isDirty('status')) { + $this->People->recalculateStatus($entity->person_id); + } + + return true; + } + + /** + * Reconcile memberships in COU members groups based on the + * PersonRole(s) for a Person and the COU(s) for those roles. + * + * @since COmanage Registry v5.0.0 + * @param EntityInterface $entity PersonRole Entity + * @param bool $provision Whether to run provisioners + * @param bool $personActive If false, role is not eligible for Active Members Group + * @throws InvalidArgumentException + * @throws RuntimeException + */ + + public function reconcileCouMembersGroupMemberships(\Cake\Datasource\EntityInterface $entity, bool $provision=true, bool $personActive=true) { + // First see if there is a COU associated with this Role. + + if(!$entity->cou_id) { + if(!$entity->isNew()) { + // If we're going from a COU to no COU we need to remove the automatic + // group memberships (the inverse of below, where we go from no COU to + // having a COU) + + $oldCouId = $entity->getOriginal('cou_id'); + + if($oldCouId) { + $this->llog('rule', "AR-PersonRole-1 Removing PersonRole " . $entity->id . " (Person " . $entity->person_id . ") from All Members Group for COU " . $oldCouId . " due to removal of Person Role from COU"); + $this->People->GroupMembers->syncAutomaticMembership(GroupTypeEnum::AllMembers, $oldCouId, $entity->person_id, false, $provision); + $this->llog('rule', "AR-PersonRole-2 Removing PersonRole " . $entity->id . " (Person " . $entity->person_id . ") from Active Members Group for COU " . $oldCouId . " due to removal of Person Role from COU"); + $this->People->GroupMembers->syncAutomaticMembership(GroupTypeEnum::ActiveMembers, $oldCouId, $entity->person_id, false, $provision); + } + } + + // Since there is no COU associated with this Person Role, there is + // nothing else to do + + return; + } + + if(!$entity->person_id) { + // We're probably deleting the CO + return; + } + + // We need to examine the status of all roles in the COU, not just the current + // one, to see if the person is eligible for the relevant members group. + + $roles = $this->find('all') + ->where(['person_id' => $entity->person_id, + 'cou_id' => $entity->cou_id]) + ->all(); + + // For $activeEligible, we need at least one active role + $activeRole = false; + + // For $allEligible, we need at least one role not Archived + $allEligible = false; + + foreach($roles as $role) { + if($role->isActive()) { + $activeRole = true; + } + + if($role->status != StatusEnum::Archived) { + $allEligible = true; + } + } + + $activeEligible = $personActive && $activeRole; + + // Create or remove memberships for the Active and All groups for this COU. + + $this->llog('rule', "AR-PersonRole-1 Syncing membership in All Members Group for COU " . $entity->cou_id . " for PersonRole " . $entity->id . " (Person " . $entity->person_id . "), eligibility=" . $allEligible); + $this->People->GroupMembers->syncAutomaticMembership(GroupTypeEnum::AllMembers, $entity->cou_id, $entity->person_id, $allEligible, $provision); + $this->llog('rule', "AR-PersonRole-2 Syncing membership in Active Members Group for COU " . $entity->cou_id . " for PersonRole " . $entity->id . " (Person " . $entity->person_id . "), eligibility=" . $activeEligible); + $this->People->GroupMembers->syncAutomaticMembership(GroupTypeEnum::ActiveMembers, $entity->cou_id, $entity->person_id, $activeEligible, $provision); + + if(!$entity->isNew()) { + // Remove group memberships if the COU ID (PersonRole moved) or Person ID + // (PersonRole relinked) has changed. + + if($entity->get('cou_id') !== $entity->getOriginal('cou_id')) { + // We must have a COU ID, since we checked above for the case where we don't + $oldCouId = $entity->getOriginal('cou_id'); + + if($oldCouId) { + $this->llog('rule', "AR-PersonRole-1 Removing PersonRole " . $entity->id . " (Person " . $entity->person_id . ") from All Members Group for COU " . $oldCouId . " due to removal of Person Role from COU"); + $this->People->GroupMembers->syncAutomaticMembership(GroupTypeEnum::AllMembers, $oldCouId, $entity->person_id, false, $provision); + $this->llog('rule', "AR-PersonRole-1 Removing PersonRole " . $entity->id . " (Person " . $entity->person_id . ") from All Members Group for COU " . $oldCouId . " due to removal of Person Role from COU"); + $this->People->GroupMembers->syncAutomaticMembership(GroupTypeEnum::ActiveMembers, $oldCouId, $entity->person_id, false, $provision); + } + // else no prior COU ID, nothing to do + } + } + } + + /** + * Application Rule to determine if validity dates are sequential + * + * @param Entity $entity Entity to be validated + * @param array $options Application rule options + * + * @return bool|string true if the Rule check passes, false otherwise + * @since COmanage Registry v5.0.0 + */ + + public function ruleDatesSequential($entity, array $options): bool|string { + // This rule only applies if both valid_from and valid_through are set. + + if(!empty($entity->valid_from) && !empty($entity->valid_through)) { + $diff = $entity->valid_from->diff($entity->valid_through); + + if($diff->invert) { + return __d('error', 'PersonRoles.valid_from.after'); + } + } + + return true; + } + + /** + * Perform a keyword search. + * + * @since COmanage Registry v5.0.0 + * @param int $coId CO ID to constrain search to + * @param string $q String to search for + * @param int $limit Search limit + * @return Array Array of search results, as from find('all) + */ + + public function search(int $coId, string $q, int $limit) { + // Tokenize $q on spaces + $tokens = explode(" ", $q); + + // We take two loops through, the first time we only do a prefix search + // (foo%). If that doesn't reach the search limit, we'll do an infix search + // the second time around. + + $whereClause = []; + + foreach($tokens as $t) { + $whereClause['AND'][] = [ + 'OR' => [ + 'LOWER(PersonRoles.title) LIKE' => '%' . strtolower($tokens[0]) . '%', + 'LOWER(PersonRoles.organization) LIKE' => '%' . strtolower($tokens[0]) . '%', + 'LOWER(PersonRoles.department) LIKE' => '%' . strtolower($tokens[0]) . '%' + ] + ]; + } + + return $this->find() + ->where($whereClause) + ->andWhere(['People.co_id' => $coId]) + ->limit($limit) + ->contain(['People' => 'PrimaryName']) + ->all(); + } + + /** + * Determine the source foreign key attribute for this table, for tables that + * have Pipelined attributes from External Identities to People. + * + * @since COmanage Registry v5.0.0 + * @return string Source name field (eg: source_name_id) + */ + + public function sourceForeignKey(): string { + // PersonRoles doesn't follow the standard pattern + return "source_external_identity_role_id"; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('person_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('person_id'); + + $validator->add('cou_id', [ + 'content' => ['rule' => 'isInteger'] + ]); +// XXX this should be dynamically set based on CO Settings + $validator->allowEmptyString('cou_id'); + + $validator->add('affiliation_type_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('affiliation_type_id'); + + $validator->add('sponsor_person_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('sponsor_person_id'); + + $validator->add('manager_person_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('manager_person_id'); + + $this->registerStringValidation($validator, $schema, 'title', false); + + $this->registerStringValidation($validator, $schema, 'organization', false); + + $this->registerStringValidation($validator, $schema, 'department', false); + + $validator->add('valid_from', [ + 'content' => ['rule' => 'dateTime'] + ]); + $validator->allowEmptyString('valid_from'); + + $validator->add('valid_through', [ + 'content' => ['rule' => 'dateTime'] + ]); + $validator->allowEmptyString('valid_through'); + + $validator->add('status', [ + 'content' => ['rule' => ['inList', StatusEnum::getConstValues()]] + ]); + $validator->notEmptyString('status'); + + $validator->add('ordr', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('ordr'); + + $validator->add('source_external_identity_role_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('source_external_identity_role_id'); + + $validator->add('frozen', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('frozen'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/PipelinesTable.php b/app/src/Model/Table/PipelinesTable.php new file mode 100644 index 000000000..32f088a1d --- /dev/null +++ b/app/src/Model/Table/PipelinesTable.php @@ -0,0 +1,1865 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('Cos'); + $this->belongsTo('MatchServers') + ->setClassName('Servers') + ->setForeignKey('match_server_id') + ->setProperty('match_server'); + $this->belongsTo('MatchEmailAddressTypes') + ->setClassName('Types') + ->setForeignKey('match_email_address_type_id') + ->setProperty('match_email_address_type'); + $this->belongsTo('MatchIdentifierTypes') + ->setClassName('Types') + ->setForeignKey('match_identifier_type_id') + ->setProperty('match_identifier_type'); + $this->belongsTo('SyncAffiliationTypes') + ->setClassName('Types') + ->setForeignKey('sync_affiliation_type_id') + ->setProperty('sync_affiliation_type'); + $this->belongsTo('SyncCous') + ->setClassName('Cous') + ->setForeignKey('sync_cou_id') + ->setProperty('sync_cou'); + $this->belongsTo('SyncReplaceCous') + ->setClassName('Cous') + ->setForeignKey('sync_replace_cou_id') + ->setProperty('sync_replace_cou'); + $this->belongsTo('SyncIdentifierTypes') + ->setClassName('Types') + ->setForeignKey('sync_identifier_type_id') + ->setProperty('sync_identifier_type'); + + $this->hasMany('ExternalIdentitySources'); + + $this->setDisplayField('description'); + + $this->setPrimaryLink('co_id'); + $this->setRequiresCO(true); + + $this->setAutoViewVars([ + 'matchEmailAddressTypes' => [ + 'type' => 'type', + 'attribute' => 'EmailAddresses.type' + ], + 'matchIdentifierTypes' => [ + 'type' => 'type', + 'attribute' => 'Identifiers.type' + ], + 'matchStrategies' => [ + 'type' => 'enum', + 'class' => 'MatchStrategyEnum' + ], + 'statuses' => [ + 'type' => 'enum', + 'class' => 'SuspendableStatusEnum' + ], + 'syncAffiliationTypes' => [ + 'type' => 'type', + 'attribute' => 'PersonRoles.affiliation_type' + ], + 'syncCous' => [ + 'type' => 'select', + 'model' => 'Cous' + ], + 'syncIdentifierTypes' => [ + 'type' => 'select', +// XXX We need to filter this to just Person Identifiers + 'model' => 'Types' + ], + 'syncReplaceCous' => [ + 'type' => 'select', + 'model' => 'Cous' + ], + // Just go with Cake's default pluralization + 'syncStatusOnDeletes' => [ + 'type' => 'enum', + 'class' => 'DeletedRoleStatusEnum' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Correlate an array of mapped backend record data (as returned by + * mapAttributesToCO) to an existing External Identity (as returned by get) + * by finding ID keys for related models. + * + * @since COmanage Registry v5.0.0 + * @param ExternalIdentity $externalIdentity ExternalIdentity, including related models + * @param array $mapped Backend data, including related models + * @return array Backend data, with record keys + */ + + protected function correlateRecordKeys( + ExternalIdentity $externalIdentity, + array $mapped + ): array { + $ret = $mapped; + + // Unlike when syncing an External Identity to a Person, syncing an EIS Record + // to an External Identity doesn't have a key to map associated models in the + // EIS Record to the External Identity. (The exception being the role_key on + // the External Identity Role.) + + // While we could enforce a key as part of the EIS API, that might be tricky + // for some backends to implement (eg: an LdapSource record with two email + // addresses can't guarantee it will always retrieve them in the same order), + // and the only benefit of such a requirement would be that we could do an update + // rather than a delete and add. + + // Instead we try to map records in the Backend data to records in the + // External Identity data. We mostly iteratively loop over the various related + // models. This isn't necessarily the most efficient approach, but in most cases + // we're dealing with O(1) MVEA records. (eg: An EIS record will typically have + // 0, 1, or maybe 2 EmailAddresses attached to it.) + + // Note that our goal here is to identity attributes that _haven't_ changed. + // By finding a matching ID patchEntity() will know not to update the related + // model. If an attribute changes in the Backend record, we won't match it + // here and the old value will be deleted while the new value will be added. + // (We do still need to handle some metadata for new records, though, in particular + // foreign keys.) + + // Start with the ID of the External Identity itself. + $ret['id'] = $externalIdentity->id; + + // Next look through the External Identity's related models. + foreach([ + // related models need EntityMetaTrait + 'ad_hoc_attributes', + 'addresses', + 'email_addresses', + 'identifiers', + 'names', + 'pronouns', + 'telephone_numbers', + 'urls' + ] as $m) { + if(!empty($externalIdentity->$m) && !empty($ret[$m])) { + // There is at least one associated model of this type on the + // External Identity, and in the mapped Backend data + foreach($externalIdentity->$m as $rentity) { + // Check all mapped records for the same model + foreach($ret[$m] as $i => $mdata) { + if(!isset($ret[$m][$i]['id']) // We saw this one already + && $rentity->isProbablyThisArray($mdata)) { + // Insert the record ID + $ret[$m][$i]['id'] = $rentity->id; + break; // We can exit the inner loop, but not the outer one + } + } + } + } + + if(!empty($ret[$m])) { + // And make sure each mapped Backend record has a parent record ID. + // We do this separately to catch any new records. + foreach(array_keys($ret[$m]) as $i) { + $ret[$m][$i]['external_identity_id'] = $externalIdentity->id; + } + } + } + + // Now map any External Identity Roles. We can use the role_key to help here. + + if(!empty($externalIdentity->external_identity_roles) + && !empty($ret['external_identity_roles'])) { + foreach($externalIdentity->external_identity_roles as $roleentity) { + foreach($ret['external_identity_roles'] as $i => $rdata) { + if($roleentity->role_key == $rdata['role_key']) { + // Insert the record ID for existing records (updates) + $ret['external_identity_roles'][$i]['id'] = $roleentity->id; + + // While we're here, work with any related models + foreach([ + // related models need EntityMetaTrait + 'ad_hoc_attributes', + 'addresses', + 'telephone_numbers' + ] as $m) { + if(!empty($ret['external_identity_roles'][$i][$m])) { + if(!empty($roleentity->$m)) { + // There is at least one associated model of this type on the + // External Identity Role, and in the mapped Backend data + foreach($roleentity->$m as $rentity) { + // Check all mapped records for the same model + foreach($ret['external_identity_roles'][$i][$m] as $j => $mdata) { + if(!isset($ret['external_identity_roles'][$i][$m][$j]['id']) // We saw this one already + && $rentity->isProbablyThisArray($mdata)) { + // Insert the record ID + $ret['external_identity_roles'][$i][$m][$j]['id'] = $rentity->id; + break; // We can exit the inner loop, but not the outer ones + } + } + } + } + + // Insert the parent record ID, separately to catch any new records + foreach(array_keys($ret['external_identity_roles'][$i][$m]) as $j) { + $ret['external_identity_roles'][$i][$m][$j]['external_identity_role_id'] = $roleentity->id; + } + } + } + + break; // We can exit the inner loop, but not the outer one + } + } + } + + // And finally any related models for the External Identity. We can skip this + // for new Roles since the related models are also going to be new (and + // therefore not have existing keys). For deleted Roles, when the Role itself + // is deleted the associated models will also be deleted (as dependencies) so + // we don't need to facilitate that here. + } + + if(!empty($ret['external_identity_roles'])) { + // Insert the parent record ID, again separately to catch any new records. + foreach(array_keys($ret['external_identity_roles']) as $i) { + $ret['external_identity_roles'][$i]['external_identity_id'] = $externalIdentity->id; + } + } + + return $ret; + } + + /** + * Create an initial Person record from an External Identity. + * + * @since COmanage Registry v5.0.0 + * @param Pipeline $pipeline Pipeline + * @param ExternalIdentitySource $eis External Identity Source + * @param ExtIdentitySourceRecord $eisRecord External Identity Source Record + * @param array $eisAttributes Attributes provided by EIS Backend + * @return Person New Person entity + */ + + protected function createPersonFromEIS( + Pipeline $pipeline, + ExternalIdentitySource $eis, + ExtIdentitySourceRecord $eisRecord, + array $eisAttributes + ): Person { + // This is a skeletal Person record so that we can hang other entities + // from it (eg: External Identity). As such, we're just prepopulating + // some defaults but these values are NOT linked to the original source + // record and can be changed via other steps in the Pipeline, an + // administrator, or self service tooling. We also do not create roles + // or run identifier assignments, etc, that stuff happens later in the + // Pipeline. + + $mappedAttributes = $this->mapAttributesToCO($pipeline, $eisAttributes); + + $newPerson = [ + 'co_id' => $pipeline->co_id, + 'status' => StatusEnum::Pending + ]; + + if(!empty($mappedAttributes['date_of_birth'])) { + $newPerson['date_of_birth'] = $mappedAttributes['date_of_birth']; + } + + if(empty($mappedAttributes['names'][0])) { + throw new \InvalidArgumentException('At least one name is required for createPersonFromEIS'); + } + + // AR-Pipeline-1 If a Pipeline creates a new Person, the first Name + // returned by the External Identity Source backend will be used as + // the initial Primary Name for the new Person. + $newPerson['names'][] = $mappedAttributes['names'][0]; + // Force this to be the primary name just in case it wasn't set + $newPerson['names'][0]['primary_name'] = true; + + $entity = $this->Cos->People->newEntity($newPerson, ['associated' => 'Names']); + + $this->Cos->People->saveOrFail($entity, ['associated' => 'Names']); + + $this->Cos->People->recordHistory( + entity: $entity, + action: ActionEnum::PersonAddedPipeline, + comment: __d('result', + 'People.added.pipeline', + [$pipeline->description, + $pipeline->id, + $eis->description, + $eis->id, + $eisRecord->source_key]) + ); + + return $entity; + } + + /** + * Copy the data from an entity and filter metadata, returning an array + * suitable for creating a new entity. Related models are also removed. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Entity to copy + * @return array Array of filtered entity data + */ + + protected function duplicateFilterEntityData($entity): array { + // There's some overlap with TableMetaTrait::filterMetadataFields... + + $newdata = $entity->toArray(); + + // This list is a combination of eliminating fields that create + // noise in change detection for History creation, as well as + // functional attributes that cause problems if set (eg: frozen). + unset( + $newdata['id'], + $newdata['external_identity_id'], + $newdata['external_identity_role_id'], + $newdata['actor_identifier'], + $newdata['created'], + $newdata['deleted'], + $newdata['frozen'], + $newdata['full_name'], + // XXX we temporarily filter manager and sponsor identifiers because + // we haven't yet implemented support for mapping them + $newdata['manager_identifier'], + $newdata['sponsor_identifier'], + $newdata['modified'], + $newdata['primary_name'], + $newdata['revision'], + $newdata['role_key'], + // We don't want status for the External Identity, and we handle it + // specially for External Identity Roles + $newdata['status'] + ); + + // Timestamps are FrozenTime objects in the entity data, and is_scalar + // will filter them out, so convert them to strings + foreach(['valid_from', 'valid_through'] as $attr) { + if(!empty($entity->$attr)) { + $newdata[$attr] = $entity->$attr->i18nFormat('yyyy-MM-dd HH:mm:ss'); + } + } + + // This will remove anything that isn't stringy + return array_filter($newdata, 'is_scalar'); + } + + /** + * Execute the specified Pipeline on the provided EIS data. + * + * @since COmanage Registry v5.0.0 + * @param int $id Pipeline ID + * @param int $eisId Exxternal Identity Source ID + * @param array $eisBackendRecord Record returned by EIS Backend + * @param bool $force Force the Pipeline to run all steps, even if no changes were detected + */ + + public function execute( + int $id, + int $eisId, + array $eisBackendRecord, + bool $force=false + ) { + // Start with our configuration(s) + $pipeline = $this->get($id); + $eis = $this->ExternalIdentitySources->get($eisId); + + // Start a Transaction + $cxn = $this->getConnection(); + $cxn->begin(); + + try { + $this->llog('trace', "Executing Pipeline $id for EIS $eisId source key " . $eisBackendRecord['source_key']); + + // (1) Create or update the ExtIdentitySourceRecord based on the + // data provided by the backend + $eisRecord = $this->manageEISRecord( + $pipeline, + $eis, + $eisBackendRecord['source_key'], + $eisBackendRecord['source_record'] + ); + + if(!$force && $eisRecord['status'] == 'unchanged') { + $this->llog('trace', "Record for EIS $eisId source key " . $eisBackendRecord['source_key'] . " is unchanged, stopping Pipeline"); + + $cxn->commit(); + return; + } + + // (2) Match against an existing Person or create a new Person, in + // accordance with the Pipeline's Match Strategy + $personInfo = $this->obtainPerson( + $pipeline, + $eis, + $eisRecord['record'], + $eisBackendRecord['entity_data'] + ); + + $person = $personInfo['person']; + + // We can't record the start history until we have a Person entity + $this->Cos->People->ExternalIdentities->recordHistory( + entity: $person, + action: ActionEnum::PersonPipelineStarted, + comment: __d('result', + 'Pipelines.started', + [$pipeline->description, $id, $eis->description, $eisId, $eisBackendRecord['source_key']]) + ); + + // (3) Create or update an External Identity based on the sync strategy + // and the backend attributes + $externalIdentity = $this->syncExternalIdentity( + $pipeline, + $person, + $eis, + $eisRecord['record'], + $eisBackendRecord['entity_data'] + ); + + // If the Person record was matched (meaning it isn't new) create a + // History Record here, now that we have an External Identity + + if($personInfo['status'] == 'matched') { + $this->Cos->People->ExternalIdentities->recordHistory( + entity: $person, + action: ActionEnum::PersonMatchedPipeline, + comment: __d('result', + 'Pipelines.matched', + [$pipeline->description, $id, $eis->description, $eisId, $eisBackendRecord['source_key'], $personInfo['strategy']]) + ); + } + + // (4) Sync the External Identity attributes with the Person record + $person = $this->syncPerson( + $pipeline, + $externalIdentity, + $person + ); + + // (5) Assign Identifiers + + // We can basically ignore the results from assign() since we don't + // directly report them anywhere. + + $this->Cos->IdentifierAssignments->assign( + entityType: 'People', + entityId: $person->id, + provision: false, +// XXX should we pass this in when we have it? CFM-343 + actorPersonId: null + ); + + // (6) Update Person Status + // - We no longer need to do anything here since status recalculation + // happens automatically +/* + $person = $this->updatePersonStatus( + $pipeline, + $externalIdentity, + $person + );*/ + + // (7) Provision + + $this->Cos->People->requestProvisioning( + id: $person->id, + context: ProvisioningContextEnum::Automatic + ); + + $this->Cos->People->ExternalIdentities->recordHistory( + entity: $person, + action: ActionEnum::PersonPipelineComplete, + comment: __d('result', + 'Pipelines.complete', + [$id, $eisId, $eisBackendRecord['source_key']]) + ); + + $this->llog('trace', "Pipeline $id complete for EIS $eisId source key " . $eisBackendRecord['source_key']); + + $cxn->commit(); + } + catch(\Exception $e) { + $cxn->rollback(); + + $this->llog('error', "Pipeline $id for EIS $eisId source key " . $eisBackendRecord['source_key'] . " failed: " . $e->getMessage()); + + throw new \RuntimeException($e->getMessage()); + } + } + + /** + * Pipeline step to create or update the External Identity Source Record. + * + * @since COmanage Registry v5.0.0 + * @param Pipeline $pipeline Pipeline + * @param ExternalIdentitySource $eis External Identity Source + * @param string $sourceKey Source Key + * @param string $sourceRecord Source Record + * @param array $eisBackendRecord Record returned by EIS Backend + * @return array ExtIdentitySourceRecord and change status + */ + + protected function manageEISRecord( + Pipeline $pipeline, + ExternalIdentitySource $eis, + string $sourceKey, + string $sourceRecord, + ): array { + $status = 'unknown'; + + // Do we already have an EISRecord for this source_key? + $eisRecord = $this->ExternalIdentitySources->ExtIdentitySourceRecords + ->find() + ->where([ + 'ExtIdentitySourceRecords.external_identity_source_id' => $eis->id, + 'ExtIdentitySourceRecords.source_key' => $sourceKey + ]) + ->contain(['ExternalIdentities' => 'People']) + ->first(); + + if($eisRecord) { + // Update the record as needed, but only if the source record changed. + // We consider any aspect of the source record changing to mark the + // EIS record as changed, even if it's not material to the attributes + // that construct the External Identity. + +// XXX update this to test hashed value, once implemented + if((empty($eisRecord->source_record) && !empty($sourceRecord)) + || (!empty($eisRecord->source_record) && empty($sourceRecord)) + || (!empty($eisRecord->source_record) && !empty($sourceRecord) + && $eisRecord->source_record != $sourceRecord)) { + // We have an update of some form or another, including, possibly, a delete + + $this->llog('trace', "Updating Record for EIS " . $eis->description . " (" . $eis->id . ") source key $sourceKey"); + + // XXX support hashing here + $eisRecord->source_record = $sourceRecord; + $eisRecord->last_update = date('Y-m-d H:i:s', time()); + + $status = 'updated'; + } else { + $this->llog('trace', "Record for EIS " . $eis->description . " (" . $eis->id . ") source key $sourceKey unchanged"); + + $status = 'unchanged'; + } + } else { + // Insert a new record + $this->llog('trace', "Creating a new Record for EIS " . $eis->description . " (" . $eis->id . ") source key $sourceKey"); + + $eisRecord = $this->ExternalIdentitySources->ExtIdentitySourceRecords + ->newEntity([ + 'external_identity_source_id' => $eis->id, + 'source_key' => $sourceKey, +// XXX support hashing here + 'source_record' => $sourceRecord, + 'last_update' => date('Y-m-d H:i:s', time()) + ]); + + $status = 'new'; + } + + $this->ExternalIdentitySources->ExtIdentitySourceRecords->saveOrFail($eisRecord); + + // Because --force is implemented in the Pipeline, we need to return the + // $eisRecord regardless of whether or not it changed, and so we also need + // a status flag to indicate whether or not it was. + return [ + 'record' => $eisRecord, + 'status' => $status + ]; + } + + /** + * Map entity data returned from an EIS Backend to the CO. + * + * @since COmanage Registry v5.0.0 + * @param Pipeline $pipeline Pipeline + * @param array $eisAttributes Attributes provided by EIS Backend + * @return array Attributes adjusted for the CO + */ + + protected function mapAttributesToCO( + Pipeline $pipeline, + array $eisAttributes + ): array { + // We explicitly list the valid models, which will effectively filter + // out any unsupported noise from the backend. (Unsupported attributes + // will be ignored on save or throw errors.) + + $ret = [ + // Make sure source_key is a string + 'source_key' => (string)$eisAttributes['source_key'] + ]; + + if(!empty($eisAttributes['date_of_birth'])) { + // While we're here, make sure it's in YYYY-MM-DD format. This should fail + // if the inbound attribute is invalid. + $dob = \DateTimeImmutable::createFromFormat('Y-m-d', $eisAttributes['date_of_birth']); + + if($dob) { + $ret['date_of_birth'] = $dob->format('Y-m-d'); + } + } + + foreach([ + 'addresses', + 'email_addresses', + 'identifiers', + 'names', + 'pronouns', + 'telephone_numbers', + 'urls' + ] as $m) { + if(!empty($eisAttributes[$m])) { + foreach($eisAttributes[$m] as $attr) { + $copy = $attr; + + // Map the type string to a type ID. If we fail to map the string, + // log an error but keep going. + + try { + $copy['type_id'] = $this->Cos->Types + ->getTypeId( + $pipeline->co_id, + Inflector::camelize($m).".type", + $attr['type'] + ); + unset($copy['type']); + $ret[$m][] = $copy; + } + catch(\Exception $e) { + // If we can't find a type we can't insert this record + $this->llog('error', "Failed to map $attr type \"" . $attr['type'] . "\" to a valid Type ID for EIS record " . $eisAttributes['source_key'] . ", skipping"); + } + } + } + } + + // ad_hoc_attributes require no special handling + if(!empty($eisAttributes['ad_hoc_attributes'])) { + $ret['ad_hoc_attributes'] = $eisAttributes['ad_hoc_attributes']; + } + + if(!empty($eisAttributes['external_identity_roles'])) { + foreach($eisAttributes['external_identity_roles'] as $role) { + $rolecopy = []; + + // Start with the single value attributes + foreach($role as $attr => $val) { + if(is_array($val)) { + // This is a related model, skip it for now + continue; + } + + if($attr == 'role_key') { + // Make sure the role key is a string + $rolecopy['role_key'] = (string)$val; + } elseif($attr == 'affiliation') { + // Affiliation needs to be mapped + + $rolecopy['affiliation_type_id'] = $this->Cos->Types + ->getTypeId( + $pipeline->co_id, + 'PersonRoles.affiliation_type', + $val + ); + } elseif($attr == 'status') { + // Generally we'll let validation and recalcuation handle status, + // but if for some reason the backend asserts Deleted (which is used + // internally as a sync status, and so is not permitted to be asserted + // by the backend) we'll just convert it to Archived rather than futz + // around with context specific validation rules. + + // Strictly speaking this is not an Application Rule since backends + // shouldn't assert Deleted status so we don't need to document a + // behavior for what happens when they do. + + $rolecopy['status'] = + $val == ExternalIdentityStatusEnum::Deleted + ? ExternalIdentityStatusEnum::Archived + : $val; + } else { +// XXX need to add sponsor/manager mapping CFM-33; remove from duplicateFilterEntityData + // Just copy the attribute + $rolecopy[$attr] = $val; + } + } + + // If no affiliation type was provided by the backend, + // use the Pipeline's configuration + if(empty($rolecopy['affiliation_type_id'])) { + $rolecopy['affiliation_type_id'] = $pipeline->sync_affiliation_type_id; + } + + // Now handle related models + foreach([ + 'addresses', + 'telephone_numbers' + ] as $m) { + if(!empty($role[$m])) { + foreach($role[$m] as $attr) { + $copy = $attr; + + // Map the type string to a type ID. If we fail to map the string, + // log an error but keep going. + + try { + $copy['type_id'] = $this->Cos->Types + ->getTypeId( + $pipeline->co_id, + Inflector::camelize($m).".type", + $attr['type'] + ); + unset($copy['type']); + $rolecopy[$m][] = $copy; + } + catch(\Exception $e) { + $this->llog('error', "Failed to map $attr type \"" . $attr['type'] . "\" to a valid Type ID for EIS role record " . $role['role_key'] . ", skipping"); + } + } + } + } + + // And just copy ad hoc attributes + if(!empty($role['ad_hoc_attributes'])) { + $rolecopy['ad_hoc_attributes'] = $role['ad_hoc_attributes']; + } + + $ret['external_identity_roles'][] = $rolecopy; + } + } + + return $ret; + } + + /** + * Map an Identifier of the configured type to a Person ID. + * + * @since COmanage Registry v5.0.0 + * @param int $typeId Identifier Type ID + * @param string $identifier Identifier + * @return int Person ID + */ + + protected function mapIdentifier(int $typeId, string $identifier): ?int { + try { + $Identifiers = TableRegistry::getTableLocator()->get('Identifiers'); + + return $Identifiers->lookupPerson($typeId, $identifier); + } + catch(\Exception $e) { + return null; + } + } + + /** + * Pipeline step to obtain a Person associated with the $eisRecord, possibly + * by executing the Match Strategy. + * + * @since COmanage Registry v5.0.0 + * @param Pipeline $pipeline Pipeline + * @param ExternalIdentitySource $eis External Identity Source + * @param ExtIdentitySourceRecord $eisRecord External Identity Source Record + * @param array $eisAttributes Attributes provided by EIS Backend + * @return array 'person': Person object + * 'status': 'linked', 'created', 'matched' + * 'strategy': If status = 'matched', the MatchStrategy + */ + + protected function obtainPerson( + Pipeline $pipeline, + ExternalIdentitySource $eis, + ExtIdentitySourceRecord $eisRecord, + array $eisAttributes + ): array { + // Shorthand... + $sourceKey = $eisRecord->source_key; + + // If the $eisRecord has an External Identity attached to it, there must + // also be a Person, and we can just return that. + + if(!empty($eisRecord->external_identity_id)) { + $this->llog('trace', "Using previously linked Person " . $eisRecord->external_identity->person->id . " for EIS " . $eis->description . " (" . $eis->id . ") source key $sourceKey"); + return [ + 'person' => $eisRecord->external_identity->person, + 'status' => 'linked' + ]; + } + + // There isn't a Person associated with the request, run the configured + // Match Strategy to see if one exists + + $person = null; + $referenceId = null; + + $this->llog('trace', "Using Match Strategy " . $pipeline->match_strategy . " for EIS " . $eis->description . " (" . $eis->id . ") source key $sourceKey"); + + switch($pipeline->match_strategy) { + case MatchStrategyEnum::EmailAddress: + case MatchStrategyEnum::Identifier: + $person = $this->searchByAttribute( + $eis, + $eisRecord, + $pipeline->match_strategy, + ($pipeline->match_strategy == MatchStrategyEnum::EmailAddress + ? $pipeline->match_email_address_type_id + : $pipeline->match_identifier_type_id), + $eisAttributes + ); + break; + case MatchStrategyEnum::External: +// XXX If we get a reference ID, attach it to the $eisRecord here CFM-33 + throw new \RuntimeException('NOT IMPLEMENTED'); + break; + case MatchStrategyEnum::NoMatching: + // No matching configured, so just fall through and create a new Person + break; + } + + if(!$person) { + // We didn't find an existing Person, so create a new one + $this->llog('trace', "No existing Person found, creating new Person record for EIS " . $eis->description . " (" . $eis->id . ") source key $sourceKey"); + + return [ + 'person' => $this->createPersonFromEIS($pipeline, $eis, $eisRecord, $eisAttributes), + 'status' => 'created' + ]; + } + + return [ + 'person' => $person, + 'status' => 'matched', + 'strategy' => $pipeline->match_strategy + ]; + } + + /** + * Search for an existing Person using an attribute provided in the EIS Record. + * + * @since COmanage Registry v5.0.0 + * @param ExternalIdentitySource $eis External Identity Source + * XXX params/return + * @return Person Person if found, null otherwise + * @throws InvalidArgumentException + */ + + protected function searchByAttribute( + ExternalIdentitySource $eis, + ExtIdentitySourceRecord $eisRecord, + string $matchStrategy, + int $attributeTypeId, + array $attributes + ): ?Person { + // By the time the Pipeline is called, $attributes (while an array) should be + // normalized to the Registry data model (though we haven't yet called + // mapAttributesToCO). + + // First map the search type ID from the configuration to the expected API string + + $Types = TableRegistry::getTableLocator()->get('Types'); + + $typeLabel = $Types->getTypeLabel($attributeTypeId); + + // Make sure we have a valid search item + + $searchValue = null; + $searchString = null; + $SearchTable = null; + + if($matchStrategy == MatchStrategyEnum::EmailAddress) { + $SearchTable = TableRegistry::getTableLocator()->get('EmailAddresses'); + $searchValue = Hash::extract($attributes, "email_addresses.{n}[type=$typeLabel]"); + + if(!empty($searchValue)) { + $searchString = $searchValue[0]['mail']; + } + } elseif($matchStrategy == MatchStrategyEnum::Identifier) { + $SearchTable = TableRegistry::getTableLocator()->get('Identifiers'); + $searchValue = Hash::extract($attributes, "identifiers.{n}[type=$typeLabel]"); + + if(!empty($searchValue)) { + $searchString = $searchValue[0]['identifier']; + } + } else { + throw new \InvalidArgumentException("Unknown Match Strategy '" . $matchStrategy . "' in PipelinesTable::searchByAttribute()"); + } + + if(empty($searchString)) { + $this->llog('trace', "No attribute found of type $typeLabel for Match Strategy, creating new Person record for EIS " . $eis->description . " (" . $eis->id . ") source key " . $eisRecord->source_key); + return null; + } + + // Perform the search + + $personId = null; + + try { + $personId = $SearchTable->lookupPerson($attributeTypeId, $searchString); + } + catch(\Cake\Datasource\Exception\RecordNotFoundException $e) { + // No match + } + + if(!empty($personId)) { + // For consistency with createPersonFromEIS, we retrieve the Person and Names. + // syncExternalIdentity will pull whatever Person attributes it actually needs. + + // AR-Pipeline-2 Pipeline Person Matching ignores the existing Person status. + $person = $SearchTable->People->get($personId, ['contain' => ['Names']]); + + // We can't record history yet since we don't have an External Identity + // (we'll do that in execute()), but we can at least log + + $this->llog('trace', "Matched to existing Person ID $personId using Match Strategy $matchStrategy and search string '$searchString' for EIS " . $eis->description . " (" . $eis->id . ") source key " . $eisRecord->source_key); + + return $person; + } + + return null; + } + + /** + * Sync an External Identity Source Record to an External Identity. + * + * @since COmanage Registry v5.0.0 + * @param Pipeline $pipeline Pipeline + * @param Person $person Person + * @param ExternalIdentitySource $eis External Identity Source + * @param ExtIdentitySourceRecord $eisRecord External Identity Source Record + * @param array $eisAttributes Attributes provided by EIS Backend + * @return External Identity External Identity, possibly newly created + */ + + protected function syncExternalIdentity( + Pipeline $pipeline, + Person $person, + ExternalIdentitySource $eis, + ExtIdentitySourceRecord $eisRecord, + array $eisAttributes + ): ExternalIdentity { + if(empty($eisRecord->external_identity_id)) { + $this->llog('trace', "Creating new External Identity for Person " . $person->id . " from EIS " . $eis->description . " (" . $eis->id . ")"); + + // We can substantially just save the backend attributes, with a little + // bit of preprocessing + + $mapped = $this->mapAttributesToCO($pipeline, $eisAttributes); + + // We also need to add the Person ID + $mapped['person_id'] = $person->id; + + $entity = $this->Cos->People->ExternalIdentities->newEntity( + $mapped, + ['associated' => [ + 'Addresses', + 'AdHocAttributes', + 'EmailAddresses', + 'Identifiers', + 'Names', + 'Pronouns', + 'TelephoneNumbers', + 'Urls', + 'ExternalIdentityRoles', + 'ExternalIdentityRoles.AdHocAttributes', + 'ExternalIdentityRoles.Addresses', + 'ExternalIdentityRoles.TelephoneNumbers' + ]] + ); + + $this->Cos->People->ExternalIdentities->saveOrFail( + $entity, + ['associated' => [ + 'Addresses', + 'AdHocAttributes', + 'EmailAddresses', + 'Identifiers', + 'Names', + 'Pronouns', + 'TelephoneNumbers', + 'Urls', + 'ExternalIdentityRoles', + 'ExternalIdentityRoles.AdHocAttributes', + 'ExternalIdentityRoles.Addresses', + 'ExternalIdentityRoles.TelephoneNumbers' + ]] + ); + + // Update $eisRecord with the new external_entity_id + $eisRecord->external_identity_id = $entity->id; + $this->ExternalIdentitySources->ExtIdentitySourceRecords->saveOrFail($eisRecord); + + $this->Cos->People->ExternalIdentities->recordHistory( + entity: $entity, + action: ActionEnum::PersonAddedPipeline, + comment: __d('result', + 'Pipelines.ei.added', + [$pipeline->description, + $pipeline->id, + $eis->description, + $eis->id, + $eisRecord->source_key]) + ); + + return $entity; + } else { + $this->llog('trace', "Updating existing External Identity " . $eisRecord->external_identity_id . " for Person " . $person->id . " from EIS " . $eis->description . " (" . $eis->id . ")"); + + // Start by pulling the current ExternalIdentity with its associated models. + + $externalIdentity = $this->Cos->People->ExternalIdentities->get( + $eisRecord->external_identity_id, + ['contain' => [ + 'Addresses', + 'AdHocAttributes', + 'EmailAddresses', + 'Identifiers', + 'Names', + 'Pronouns', + 'TelephoneNumbers', + 'Urls', + 'ExternalIdentityRoles' => [ + 'AdHocAttributes', + 'Addresses', + 'TelephoneNumbers' + ] + ]] + ); + + // Map the current backend record... + $mapped = $this->mapAttributesToCO($pipeline, $eisAttributes, $externalIdentity); + // and try to correlate its record keys. + $mapped = $this->correlateRecordKeys($externalIdentity, $mapped); + + // Track any new entities so we don't immediately delete them + $newEntities = []; + + // To avoid complications with patching, we work with individual records, + // not associated models. + $externalIdentityEntity = $this->Cos->People->ExternalIdentities->get( + $eisRecord->external_identity_id + ); + + // Now start the actual diff check with the External Identity itself. + $this->Cos->People->ExternalIdentities->patchEntity( + $externalIdentityEntity, + // is_scalar will keep strings and ints but not arrays (or nulls) + array_filter($mapped, 'is_scalar') + ); + + if($externalIdentityEntity->isDirty()) { + $this->llog('trace', "External Identity " . $externalIdentityEntity->id . " updated"); + $this->Cos->People->ExternalIdentities->saveOrFail($externalIdentityEntity); + } + + // Walk through the top level associated models. + foreach([ + 'Addresses', + 'AdHocAttributes', + 'EmailAddresses', + 'Identifiers', + 'Names', + 'Pronouns', + 'TelephoneNumbers', + 'Urls', + 'ExternalIdentityRoles' + ] as $model) { + $amodel = Inflector::underscore($model); + + if(!empty($mapped[$amodel])) { + // We have one or more of this model in the mapped backend data, + // check for update vs insert. Delete is handled below. + + foreach($mapped[$amodel] as $arecord) { + if(!empty($arecord['id'])) { + // We successfully mapped the backend record to an entity, + // so this is an update. Find the entity retrieved above + // with the matching record ID. + + // Note that generally we _won't_ actually perform an update + // because if a backend record changes it won't successfully map + // to a record ID in correlateRecordKeys. (While this code will + // run, isDirty() will generally return false.) Instead, we'll + // add the "new" record (meaning the changed record) and + // delete the "old" record (meaning the database copy), + // resulting in a new ID being assigned. However, if we're + // ever able to add persistant record keys to the Backend + // interface this code should "just work". + + // We do rely on this block to process EIR related models. + + foreach($externalIdentity->$amodel as $aentity) { + if($aentity->id == $arecord['id']) { + // This is the record we mapped in the backend data + $this->Cos->People->ExternalIdentities->$model->patchEntity( + $aentity, + // We only need to filter out related models for + // ExternalIdentityRoles since the others don't have them + array_filter($arecord, 'is_scalar'), + ['associated' => []] + ); + + if($aentity->isDirty()) { + $this->Cos->People->ExternalIdentities->$model->saveOrFail($aentity, ['associated' => false]); + $this->llog('trace', "Updated $model " . $aentity->id . " for External Identity " . $externalIdentityEntity->id); + } + + // ----- Process this model's related models ----- // + if($model == 'ExternalIdentityRoles') { + // We also need to sync the related models. This is just + // different enough that it's not worth trying to abstract + // this code as a separate function. + + foreach([ + 'Addresses', + 'AdHocAttributes', + 'TelephoneNumbers' + ] as $eirmodel) { + $aeirmodel = Inflector::underscore($eirmodel); + + if(!empty($arecord[$aeirmodel])) { + // We have one or more Role related model is the mapped + // backend data, check for update vs insert. + + foreach($arecord[$aeirmodel] as $aeirrecord) { + if(!empty($aeirrecord['id'])) { + // This is an update since we successfully mapped + // the backend entityrecord, but see note above + // about updates not really happening. + + foreach($aentity->$aeirmodel as $aeirentity) { + if($aeirentity->id == $aeirrecord['id']) { + // This is the record we want to work with + $this->Cos->People->ExternalIdentities->$model->$eirmodel->patchEntity( + $aeirentity, + $aeirrecord + ); + + if($aeirentity->isDirty()) { + $this->Cos->People->ExternalIdentities->$model->$eirmodel->saveOrFail($aeirentity); + $this->llog('trace', "Updated $eirmodel " . $aeirentity->id . " for $model " . $aentity->id); + } + + break; // $aeirentity + } + } + } else { + // This is the insertion of a new record to an + // _existing_ External Identity Role. + + $newentity = $this->Cos->People->ExternalIdentities->$model->$eirmodel->newEntity($aeirrecord); + + $this->Cos->People->ExternalIdentities->$model->$eirmodel->saveOrFail($newentity); + $this->llog('trace', "Added $eirmodel " . $newentity->id . " for $model " . $aentity->id); + + // Inject the new entity so syncPerson sees it + $externalIdentity->$model->$eirmodel[] = $newentity; + $newEntities[$amodel][] = $newentity->id; + } + } + } + + // Handle deleted records attached to a _still existing_ + // External Identity Role. + + if(!empty($aentity->$aeirmodel)) { + foreach($aentity->$aeirmodel as $aeirentity) { + $found = false; + + if(!empty($arecord[$aeirmodel])) { + $found = Hash::extract($arecord[$aeirmodel], '{n}[id='.$aeirentity->id.']'); + } + + if(!$found) { + $this->llog('trace', "Deleted $eirmodel " . $aeirentity->id . " for $model " . $aentity->id); + $this->Cos->People->ExternalIdentities->$model->$eirmodel->deleteOrFail($aeirentity); + // Note deleted related models remain on the ExternalIdentity in case they + // are needed later in the Pipeline. + } + } + } + } + } + // ----- End of related model processing ----- // + + // Done processing existing parent record, break $aentity loop + break; + } + } + } else { + // This is the insertion of a new record. For ExternalIdentityRoles + // $arecord should include the associated models, so we don't need + // to do any special handling for them. + + $associated = []; + + if($model == 'ExternalIdentityRoles') { + $associated = ['Addresses', 'AdHocAttributes', 'TelephoneNumbers']; + } + + $newentity = $this->Cos->People->ExternalIdentities->$model->newEntity( + $arecord, + ['associated' => $associated] + ); + + $this->Cos->People->ExternalIdentities->$model->saveOrFail( + $newentity, + ['associated' => $associated] + ); + + $this->llog('trace', "Added $model " . $newentity->id . " for External Identity " . $externalIdentityEntity->id); + + // Inject the new entity so syncPerson sees it + $externalIdentity->$amodel[] = $newentity; + $newEntities[$amodel][] = $newentity->id; + } + } + } + + // Now handled deleted records, for which we only need to check + // $externalIdentity not being empty. If $mapped is empty we'll + // simply remove all the related model entities from the + // $externalIdentity. + + // Deleting an ExternalIdentityRole will delete its associated + // model entities. + + if(!empty($externalIdentity->$amodel)) { + foreach($externalIdentity->$amodel as $aentity) { + $found = false; + + if(!empty($mapped[$amodel])) { + // Is this an existing entity in the mapped data? + $found = (bool)Hash::extract($mapped[$amodel], '{n}[id='.$aentity->id.']'); + + if(!$found + && !empty($newEntities[$amodel]) + && in_array($aentity->id, $newEntities[$amodel])) { + // This is a new entity we just added + $found = true; + } + } + + if(!$found) { + if($model == 'ExternalIdentityRoles') { + // We have to handle the link to PersonRoles a bit carefully. + // First, we'll set the status of the Role in accordance with the + // Pipeline configuration. We do this here because + // ExternalIdentityRolesTable::beforeDelete() will set the Person + // Role foreign key to null to avoid problems with cascading deletes, + // but then when we sync the Person record later we won't see this + // PersonRole since the foreign key was nulled out. + + // We don't set the foreign key to null here because we want it + // to be cleared regardless of how the ExternalIdentity was deleted. + // eg: If an admin deletes it, the delete should complete but there + // is no Pipeline context so the PersonRole status won't be updated. + + $prole = $this->Cos->People->PersonRoles->find() + ->where(['PersonRoles.source_external_identity_role_id' => $aentity->id]) + ->contain(['AdHocAttributes', 'Addresses', 'TelephoneNumbers']) + ->first(); + + if(!empty($prole)) { + if(isset($prole->frozen) && $prole->frozen) { + $this->llog('trace', "Refusing to update frozen Person Role " . $prole->id . " from deleted External Identity Role " . $aentity->id); + } else { + // Update the status in accordance with the Pipeline configuration + $this->llog('trace', "Updating status on PersonRole " . $prole->id . " to " . $pipeline->sync_status_on_delete . " following deletion of source ExternalIdentityRole " . $aentity->id); + + $prole->status = $pipeline->sync_status_on_delete; + $this->Cos->People->PersonRoles->saveOrFail($prole); + + // Delete the MVEAs associated with this Person Role. We do this here + // rather than in syncPerson since we're doing all the other work here. + foreach([ + 'Addresses', + 'AdHocAttributes', + 'TelephoneNumbers' + ] as $eirmodel) { + $aeirmodel = Inflector::underscore($eirmodel); + + if(!empty($prole->$aeirmodel)) { + foreach($prole->$aeirmodel as $aeirentity) { + $this->llog('trace', "Deleted $aeirmodel " . $aeirentity->id . " for Person Role " . $prole->id); + $this->Cos->People->PersonRoles->$eirmodel->deleteOrFail($aeirentity); + } + } + } + } + } + } + + $this->llog('trace', "Deleted $model " . $aentity->id . " for External Identity " . $externalIdentityEntity->id); + $this->Cos->People->ExternalIdentities->$model->deleteOrFail($aentity); + // Note deleted related models remain on the ExternalIdentity in case they + // are needed later in the Pipeline. + } + } + } + } + + // Note $externalIdentity may include deleted related models. + + return $externalIdentity; + } + } + + /** + * Sync an External Identity to a Person. + * + * @since COmanage Registry v5.0.0 + * @param Pipeline $pipeline Pipeline + * @param ExternalIdentity $externalIdentity External Identity + * @param Person $person Person + * @return Person Person + */ + + protected function syncPerson( + Pipeline $pipeline, + ExternalIdentity $externalIdentity, + Person $person + ): Person { + // Because ExternalIdentities belongTo People, we can assume we have at least + // a Person object here (it would have been created by obtainPerson if there + // wasn't one at the start of the process). + + // Start with the directly related models + + foreach([ + 'Addresses', + 'AdHocAttributes', + 'EmailAddresses', + 'Identifiers', + 'Names', + 'Pronouns', + 'TelephoneNumbers', + 'Urls' + ] as $model) { + $amodel = Inflector::underscore($model); + // sourcefk = eg source_name_id + $sourcefk = $this->Cos->People->$model->sourceForeignKey(); + + // Pull the current set of associated records for this model. + // We can filter down to those that came from _any_ source (ie + // the source attribute is not null), but filtering only those + // from _this_ source requires a JOIN that isn't really worth the + // effort. + + $curentities = $this->Cos->People->$model + ->find() + ->where([ + $model.'.person_id' => $person->id, + $model.'.'.$sourcefk." IS NOT" => null + ]) + ->all(); + + // Track which IDs we've seen to facilitate deletes. + $seenIds = []; + + if(!empty($externalIdentity->$amodel)) { + // Walk through the ExternalIdentity's entities, adding or updating as + // appropriate. + + foreach($externalIdentity->$amodel as $eientity) { + if($eientity->deleted) { + // We ignore entities flagged as deleted, we'll calculate deletions + // separately in case we need to fix manually mucked up data. + continue; + } + + // Convert the ExternalIdentity attribute to an array and filter it + $newdata = $this->duplicateFilterEntityData($eientity); + + // Add the foreign keys + $newdata[$sourcefk] = $eientity->id; + $newdata['person_id'] = $person->id; + + // Do we have a corresponding record on the Person? + $found = $curentities->firstMatch([$sourcefk => $eientity->id]); + + if($found) { + // There is an existing record, update it (if it changed) _unless_ + // the attribute record is frozen. + + if($model == 'Names' && $found->primary_name) { + // Preserve the primary name flag, if set + $newdata['primary_name'] = true; + } + + $this->Cos->People->$model->patchEntity($found, $newdata); + + if($found->isDirty()) { + if(isset($found->frozen) && $found->frozen) { + $this->llog('trace', "Refusing to update frozen $model " . $found->id . " to Person from External Identity " . $externalIdentity->id); + } else { + $this->Cos->People->$model->saveOrFail($found); + $this->llog('trace', "Updated $model " . $found->id . " to Person from External Identity " . $externalIdentity->id); + } + } + } else { + // This is a new record. We have to convert the External Identity to + // an array to create the new Person entity anyway, so we use that + // as an opportunity to set the foreign key. + + // Note that certain application logic (eg: no primary names for + // EIS data copied to People) is implemented in beforeMarshal on + // the appropriate Table. + + // Default the new attribute to not frozen + $newdata['frozen'] = false; + + $newentity = $this->Cos->People->$model->newEntity($newdata); + $this->Cos->People->$model->saveOrFail($newentity); + + $this->llog('trace', "Added $model " . $newentity->id . " to Person from External Identity " . $externalIdentity->id); + } + } + + $seenIds[] = $eientity->id; + } + + // Now walk through the Person entities, and delete any that we didn't see. + // In theory we could make Cake do this automatically via a HasOne + // relation, but it's a bit tricky to make Cake handle relations within + // the same object correctly to cascade the delete. + + if(!empty($curentities)) { + foreach($curentities as $aentity) { + // $aentity is an entity attached to the Person, we search through the + // source attributes for one with a corresponding source key ID + $found = Hash::extract($externalIdentity[$amodel], '{n}[id='.$aentity->$sourcefk.']'); + + if(!$found) { + if(isset($aentity->frozen) && $aentity->frozen) { + $this->llog('trace', "Refusing to delete frozen $model " . $aentity->id . " on Person from External Identity " . $externalIdentity->id); + } else { + $this->llog('trace', "Deleted $model " . $aentity->id . " for Person " . $person->id); + $this->Cos->People->$model->deleteOrFail($aentity); + } + } + } + } + } + + // Next sync External Identity Roles to Person Roles. + // **Be careful to note the different terminology for EIR vs PR** + // Track which person roles we've seen to remove any deleted ones. + $seenRoleIds = []; + + if(!empty($externalIdentity->external_identity_roles)) { + // $sourcefk = 'source_external_identity_role_id' + $sourcefk = $this->Cos->People->PersonRoles->sourceForeignKey(); + + // Pull the current Person Roles + $curentities = $this->Cos->People->PersonRoles + ->find() + ->where([ + 'PersonRoles.person_id' => $person->id, + "PersonRoles.$sourcefk IS NOT" => null + ]) + ->contain(['AdHocAttributes', 'Addresses', 'TelephoneNumbers']) + ->all(); + + foreach($externalIdentity->external_identity_roles as $eirentity) { + if($eirentity->deleted) { + // We ignore entities flagged as deleted, we'll calculate deletions + // separately in case we need to fix manually mucked up data. + continue; + } + + // Convert the ExternalIdentityRole to an array and filter it + $newdata = $this->duplicateFilterEntityData($eirentity); + + // Insert foreign keys + $newdata[$sourcefk] = $eirentity->id; + $newdata['person_id'] = $person->id; + + // And set the COU, if configured. Currently all Roles from a given + // External Identity sync to the same COU. + if(!empty($pipeline->sync_cou_id)) { + $newdata['cou_id'] = $pipeline->sync_cou_id; + } + + // Map Manager and Sponsor identifiers, if set, to corresponding People. + // If not found, we'll log a warning but otherwise proceed. + // Also, we need a configured Identifier type. + + foreach(['manager', 'sponsor'] as $f) { + $eirField = $f . "_identifier"; + $prField = $f . "_person_id"; + + // Populate a null value by default, in case an existing foreign key + // is removed + $newdata[$prField] = null; + + if(!empty($eirentity->$eirField)) { + if(!empty($pipeline->sync_identifier_type_id)) { + $newdata[$prField] = $this->mapIdentifier( + $pipeline->sync_identifier_type_id, + $eirentity->$eirField + ); + + if(empty($newdata[$prField])) { + $this->llog('trace', "Unable to map $eirField for External Identity Role " . $eirentity->id . " because no Person with the specified identifier was found"); + } + } else { + $this->llog('trace', "Unable to map $eirField for External Identity Role " . $eirentity->id . " because there is no Sync Identifier Type configured for Pipeline " . $pipeline->id); + } + } + } + + // duplicateFilterEntityData() will remove status, but we need to + // set it back (if asserted) or set a default (if not). + if(!empty($eirentity->status)) { + if($eirentity->status == ExternalIdentityStatusEnum::Archived) { + // The EI Role was flagged as Archived, update the Person Role to + // the status configured in the Pipeline. In this scenario, we don't + // otherwise remove associated MVEAs. + $newdata['status'] = $pipeline->sync_status_on_delete; + } else { + $newdata['status'] = $eirentity->status; + } + } else { + // Default to Active status for this Role (subject to validity date recalculation) + $newdata['status'] = StatusEnum::Active; + } + + // Do we have a corresponding record on the Person? + $found = $curentities->firstMatch([$sourcefk => $eirentity->id]); + + if($found) { + // There is an existing record, update it (if it changed) _unless_ + // the role record is frozen. + + $this->Cos->People->PersonRoles->patchEntity($found, $newdata, ['associated' => []]); + + if($found->isDirty()) { + if(isset($found->frozen) && $found->frozen) { + $this->llog('trace', "Refusing to update frozen Person Role " . $found->id . " to Person from External Identity " . $externalIdentity->id); + } else { + $this->Cos->People->PersonRoles->saveOrFail($found, ['associated' => false]); + $this->llog('trace', "Updated PersonRole " . $found->id . " to Person from External Identity " . $externalIdentity->id); + } + } + } else { + // Default the new attribute to not frozen + $newdata['frozen'] = false; + + $newentity = $this->Cos->People->PersonRoles->newEntity($newdata, ['associated' => []]); + $this->Cos->People->PersonRoles->saveOrFail($newentity, ['associated' => false]); + + $this->llog('trace', "Added PersonRole " . $newentity->id . " to Person from External Identity " . $externalIdentity->id); + } + + // Now handle related models + + foreach([ + 'ad_hoc_attributes' => 'AdHocAttributes', + 'addresses' => 'Addresses', + 'telephone_numbers' => 'TelephoneNumbers' + ] as $m => $t) { + $seenRelatedModelIds = []; + + if(!empty($eirentity->$m)) { + foreach($eirentity->$m as $relatedEntity) { + // Convert the related entity to an array and filter it + $newdata = $this->duplicateFilterEntityData($relatedEntity); + + // Insert foreign keys + $rsourcefk = $this->Cos->People->PersonRoles->$t->sourceForeignKey(); + $newdata[$rsourcefk] = $relatedEntity->id; + $newdata['person_role_id'] = $found->id ?? $newentity->id; + + // See if we have a correponding Person Role entity, but only if + // we're working with an existing Person Role + + $relatedFound = null; + + if(!empty($found->$m)) { + $relatedFound = Hash::extract($found->$m, '{n}['.$rsourcefk.'='.$relatedEntity->id.']'); + + if($relatedFound) { + // Hash returns an array, but we want the first object in it + + $relatedFound = $relatedFound[0]; + + // There is an existing record, update it (if it changed) _unless_ + // the record is frozen + + $this->Cos->People->PersonRoles->$t->patchEntity($relatedFound, $newdata, ['associated' => []]); + + if($relatedFound->isDirty()) { + if(isset($relatedFound->frozen) && $relatedFound->frozen) { + $this->llog('trace', "Refusing to update frozen $t " . $relatedFound->id . " to Person Role from External Identity Role $t " . $relatedEntity->id); + } else { + $this->Cos->People->PersonRoles->$t->saveOrFail($relatedFound, ['associated' => false]); + $this->llog('trace', "Updated $t " . $relatedFound->id . " to Person Role from External Identity Role $t " . $relatedEntity->id); + } + } + + $seenRelatedModelIds[] = $relatedFound->id; + } + } + + // We need to use empty() because Hash might return an empty array + if(empty($relatedFound)) { + // We have a new related entity on an existing Person Role, or a new + // Person Role (and therefore all related entities are new) + + // Default the new attribute to not frozen + $newdata['frozen'] = false; + + $newrentity = $this->Cos->People->PersonRoles->$t->newEntity($newdata, ['associated' => []]); + $this->Cos->People->PersonRoles->$t->saveOrFail($newrentity, ['associated' => false]); + + $this->llog('trace', "Added PersonRole $t " . $newrentity->id . " to Person Role from External Identity Role $t " . $relatedEntity->id); + + $seenRelatedModelIds[] = $newrentity->id; + } + } + } + + // Delete any related models we didn't see in the source EI Role + if(!empty($found->$m)) { + foreach($found->$m as $curRelatedEntity) { + if(!in_array($curRelatedEntity->id, $seenRelatedModelIds)) { + if(isset($curRelatedEntity->frozen) && $curRelatedEntity->frozen) { + $this->llog('trace', "Refusing to delete frozen $t " . $curRelatedEntity->id . " from Person Role $t " . $relatedEntity->id); + } else { + $this->llog('trace', "Deleted $t " . $curRelatedEntity->id . " for Person Role " . $found->id); + $this->Cos->People->PersonRoles->$t->deleteOrFail($curRelatedEntity); + } + } + } + } + } + + $seenRoleIds[] = $eirentity->id; + } + + // For any roles we didn't see, we don't actually delete them, instead + // we set them to the configured status. This allows Expiration Policies + // to be applied, and also allows us to reactive a role if it comes back + // with the same Role Key. + + // Under what circumstances would we have a Person Role with a foreign key + // to an EI Role, but we didn't see that EI Role when walking the loop, + // above? + // - If the backend changed the status to Suspended or Archived, the EIR + // would still be valid, and we would see it above. + // - If the backend deleted the role entirely, syncExternalIdentity would + // notice, and explicitly change the PersonRole status to $delete_status + // while ExternalIdentityRoles::beforeDelete would update the PR foreign + // key to no longer point to the source EIR, so we wouldn't see the PR + // at all. + // - A manually deleted EIR would behave similarly. +/* + if(!empty($curentities->person_roles)) { + foreach($curentities->person_roles as $currole) { + if(!in_array($currole->id, $seenRoleIds)) { + } + } + }*/ + } + + return $person; + } + + /** + * Update Person status upon completion of Pipeline syncing. + * + * @since COmanage Registry v5.0.0 + * @param Pipeline $pipeline Pipeline + * @param ExternalIdentity $externalIdentity External Identity + * @param Person $person Person + * @return Person Person + * + + protected function updatePersonStatus( + Pipeline $pipeline, + ExternalIdentity $externalIdentity, + Person $person + ): Person { + // Role status is set during syncPerson, so all we need to do is update + // the Person status, and then only if the current status is Pending. + + if($person->status == StatusEnum::Pending) { + $person->status = StatusEnum::Active; + + $this->Cos->People->saveOrFail($person, ['associated' => false]); + $this->llog('trace', "Pipeline " . $pipeline->id . " updating Person " . $person->id . " status to Active"); + } + + return $person; + }*/ + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('co_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('co_id'); + + $this->registerStringValidation($validator, $schema, 'description', false); + + $validator->add('status', [ + 'content' => ['rule' => ['inList', SuspendableStatusEnum::getConstValues()]] + ]); + $validator->notEmptyString('status'); + + $validator->add('match_strategy', [ + 'content' => ['rule' => ['inList', MatchStrategyEnum::getConstValues()]] + ]); + $validator->notEmptyString('match_strategy'); + + $validator->add('match_email_address_type_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString( + field: 'match_email_address_type_id', + when: function ($context) { + return (!empty($context['data']['match_strategy']) + && ($context['data']['match_strategy'] == MatchStrategyEnum::EmailAddress)); + } + ); + + $validator->add('match_identifier_type_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString( + field: 'match_identifier_type_id', + when: function ($context) { + return (!empty($context['data']['match_strategy']) + && ($context['data']['match_strategy'] == MatchStrategyEnum::Identifier)); + } + ); + + $validator->add('match_server_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('match_server_id'); + + $validator->add('sync_affiliation_type_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('sync_affiliation_type_id'); + + $validator->add('sync_cou_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('sync_cou_id'); + + $validator->add('sync_replace_cou_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('sync_replace_cou_id'); + + $validator->add('sync_status_on_delete', [ + 'content' => ['rule' => ['inList', DeletedRoleStatusEnum::getConstValues()]] + ]); + $validator->notEmptyString('sync_status_on_delete'); + + $validator->add('sync_identifier_type_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('sync_identifier_type_id'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/PluginsTable.php b/app/src/Model/Table/PluginsTable.php new file mode 100644 index 000000000..e9c69c419 --- /dev/null +++ b/app/src/Model/Table/PluginsTable.php @@ -0,0 +1,547 @@ + [ + 'path' => ROOT . DS . 'plugins', + 'status' => SuspendableStatusEnum::Active + ], + PluginLocationEnum::Available => [ + 'path' => ROOT . DS . 'availableplugins', + 'status' => SuspendableStatusEnum::Suspended + ], + PluginLocationEnum::Local => [ + 'path' => LOCAL . DS . 'plugins', + 'status' => SuspendableStatusEnum::Suspended + ] + ]; + + /** + * Perform Cake Model initialization. + * + * @since COmanage Registry v5.0.0 + * @param array $config Configuration options passed to constructor + */ + + public function initialize(array $config): void { + // $this->addBehavior('Changelog'); + // $this->addBehavior('Log'); + // Timestamp behavior handles created/modified updates + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Metadata); + + $this->setDisplayField('plugin'); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'activate' => ['platformAdmin'], + 'applySchema' => ['platformAdmin'], + 'deactivate' => ['platformAdmin'], + 'delete' => false, + 'edit' => false, + 'view' => false + ], + // Actions that are permitted on readonly entities (besides view) + 'readOnly' => ['applySchema'], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + // Plugins are added automatically + 'add' => false, + 'index' => ['platformAdmin'] + ] + ]); + } + + /** + * Activate a plugin. + * + * @since COmanage Registry v5.0.0 + * @param int $id Plugin ID + * @return bool True on success + */ + + public function activate(int $id): bool { + $plugin = $this->get($id); + + if($plugin->canActivate()) { + $plugin->status = SuspendableStatusEnum::Active; + $plugin->comment = __d('information', 'plugin.active'); + $this->saveOrFail($plugin); + + // AR-Plugin-6 If a plugin is activated, apply its schema. Note it's possible + // the schema was previously applied, but that's OK, this will just bring it + // up to date (which might imply no changes). + + $pSchemaConfig = $this->getPluginSchema($plugin); + + if($pSchemaConfig) { + $SchemaManager = new SchemaManager(); + + $SchemaManager->applySchemaObject($pSchemaConfig); + } + } + + return true; + } + + /** + * Apply the database schema for a plugin. + * + * @since COmanage Registry v5.0.0 + * @param int $id Plugin ID + * @return bool True on success + */ + + public function applySchema(int $id): bool { + $plugin = $this->get($id); + + $pSchemaConfig = $this->getPluginSchema($plugin); + + if($pSchemaConfig) { + $SchemaManager = new SchemaManager(); + + $SchemaManager->applySchemaObject($pSchemaConfig); + } else { + $this->llog('debug', "Plugin $plugin->plugin does not define a database schema"); + } + + return true; + } + + /** + * Define business rules. + * + * @since COmanage Registry v5.0.0 + * @param RulesChecker $rules RulesChecker object + * @return RulesChecker + */ + + public function buildRules(RulesChecker $rules): RulesChecker { + // AR-Plugin-8 A Plugin cannot be suspended or deleted if it is in use (referenced in + // a configuration object). (Strictly speaking we don't need to do this for create/add + // since nothing should reference the pluing at that point.) + $rules->addUpdate([$this, 'ruleInUse'], + 'inUse', + ['errorField' => 'status']); + + $rules->addDelete([$this, 'ruleInUse'], + 'inUse', + ['errorField' => 'status']); + + return $rules; + } + + /** + * Deactivate a plugin. + * + * @since COmanage Registry v5.0.0 + * @param int $id Plugin ID + * @return bool True on success + */ + + public function deactivate(int $id): bool { + $plugin = $this->get($id); + + if($plugin->canDeactivate()) { + $plugin->status = SuspendableStatusEnum::Suspended; + $plugin->comment = __d('information', 'plugin.inactive'); + $this->saveOrFail($plugin); + + // AR-Plugin-7 If a plugin is suspended, its associated database schema is NOT removed + } + + return true; + } + + /** + * Find the set of active Plugins. + * + * @since COmanage Registry v5.0.0 + * @param Query $query Cake Query object + * @return Query $query Cake Query object + */ + + public function findActive(Query $query): Query { + return $query->where(['Plugins.status' => SuspendableStatusEnum::Active]) + ->order(['plugin' => 'ASC']); + } + + /** + * Obtain the set of active plugin models of a given type. + * + * @since COmanage Registry v5.0.0 + * @param string $type Plugin type + * @return array Array of plugin models implementing $type + */ + + public function getActivePluginModels(string $type): array { + // First get the list of enabled plugins + + $plugins = $this->find('active')->all(); + + $active = []; + + foreach($plugins as $p) { + // Interrogate each plugin for its models implementing $type + $active = array_merge($active, + array_map(function($v) use ($p) { + return $p->plugin . "." . $v; + }, $this->getPluginModelsByType($p, $type))); + } + + // For use in populating the select, we want the keys and values to be the same + return array_combine($active, $active); + } + + /** + * Read the value for a configuration key for a plugin, which must be Active. + * + * @since COmanage Registry v5.0.0 + * @param string $plugin Plugin name + * @param string $key Configuration key + * @param array Array of configuration information + */ + + public function getPluginConfig(string $plugin, string $key) { + // While most calls to this table accept a plugin object, this one takes + // a string to simplify code that needs a value out of plugin.json. + $pObj = $this->find() + ->where([ + 'plugin' => $plugin, + 'status' => SuspendableStatusEnum::Active + ]) + ->firstOrFail(); + + return $this->readPluginConfig($pObj, $key); + } + + /** + * Obtain the Entry Point Models implemented by a plugin for a specific plugin type. + * + * @since COmanage Registry v5.0.0 + * @param Plugin $plugin Cake Plugin object + * @param string $type Plugin type + * @return array Array of Entry Point Models + */ + + public function getPluginModelsByType(\App\Model\Entity\Plugin $plugin, string $type): array { + // Plugins can implement multiple plugin types, and multiple "entry point" models + // into each type. The index for this configuration is maintained in the plugin's + // src/config/plugin.json file. + + $pConfig = $this->readPluginConfig(plugin: $plugin, key: "types"); + + if(!empty($pConfig)) { + if(isset($pConfig->$type) && is_array($pConfig->$type)) { + return $pConfig->$type; + } else { + $this->llog('debug', "Plugin $plugin->plugin does not have a valid types configuration"); + } + } else { + $this->llog('debug', "Plugin $plugin->plugin does not have a plugin.json file"); + } + + return []; + } + + /** + * Obtain the database schema defined for a plugin. + * + * @since COmanage Registry v5.0.0 + * @param Plugin $plugin Cake Plugin object + * @return object Database schema in object form + */ + + public function getPluginSchema(\App\Model\Entity\Plugin $plugin): ?object { + return $this->readPluginConfig($plugin, 'schema'); + } + + /** + * Determine the filesystem path to a file within a plugin. + * + * @since COmanage Registry v5.0.0 + * @param Plugin $plugin Plugin object + * @param string $file File name + * @return string Path to file + * @throws InvalidArgumentException + */ + + public function pluginPath(\App\Model\Entity\Plugin $plugin, string $file): string { + $fileName = $this->paths[$plugin->location]['path'] . DS . $plugin->plugin . DS . $file; + + if(is_readable($fileName)) { + // This is the plugin we're looking for + $this->llog('debug', "Found plugin $plugin->plugin in $plugin->location directory"); + + return $fileName; + } + + $this->llog('error', "Could not find $fileName"); + + throw new \InvalidArgumentException("Could not find $fileName"); + } + + /** + * Read the plugin configuration. + * + * @since COmanage Registry v5.0.0 + * @param Plugin $plugin Plugin object + * @param string $key Configuration key + * @return object Configuration, as a parsed json object + */ + + protected function readPluginConfig(\App\Model\Entity\Plugin $plugin, string $key): ?object { + $cfg = $this->pluginPath($plugin, 'src' . DS . 'config' . DS . 'plugin.json');; + + $json = file_get_contents($cfg); + + if(!empty($json)) { + $jcfg = json_decode($json); + + if(isset($jcfg->$key)) { + return $jcfg->$key; + } + } + + return null; + } + + /** + * Application Rule to determine if the current entity is in use (is referenced + * by a configuration object). + * + * @param Entity $entity Entity to be validated + * @param array $options Application rule options + * + * @return string|bool true if the Rule check passes, false otherwise + * @since COmanage Registry v5.0.0 + */ + + public function ruleInUse($entity, array $options): string|bool { + // This rule only applies if the Plugin was suspended. + if(!$entity->isDirty('status') || $entity->status != SuspendableStatusEnum::Suspended) { + return true; + } + + // $entity->plugin is the physical plugin directory on the filesystem + // (the Registry Plugin), which can implement multiple Entry Points, + // each of which can be a different type. First, pull that configuration. + + $pConfig = $this->readPluginConfig(plugin: $entity, key: "types"); + + // We now need to query each Pluggable Model ("ReportsTable" for plugins + // of type "report") to see if this Plugin is in use for that Model. + // If any Pluggable Model returns true, we fail validation. + + foreach($pConfig as $type => $entryPoints) { + if(!empty($entryPoints)) { + // Convert $type to a table class (eg: "report" to "Reports") + $tableName = Inflector::pluralize(Inflector::classify($type)); + $table = TableRegistry::getTableLocator()->get($tableName); + + // This rule only applies to _configuration_ objects, so (eg) Jobs + // (which are artifacts) can continue to reference a Plugin after + // it has been suspended. Note that if a Plugin is polymorphic, this + // rule still applies to the Configuration based Entry Point Models. + if($table->isConfigurationTable()) { + // We don't actually need $entryPoints here, we just wanted to make sure + // the plugin implements at least one Entry Point for this $type. + $r = $table->pluginInUse($entity->plugin); + + if(!empty($r) && count($r) > 0) { + // There could be other plugin types in use, but that's probably the + // exception and anyway just returning a single error will be sufficient + // for now + + $displayField = $table->getDisplayField(); + + return __d('error', 'Plugins.inuse', [count($r), $type, $r->first()->$displayField, $r->first()->co_id]); + } + } + } + } + + return true; + } + + /** + * Examine the available set of plugins and update the global Plugins table appropriately. + * + * @since COmanage Registry v5.0.0 + */ + + public function syncPluginRegistry() { + // Determine which plugins are on the filesystem in the various supported locations + + // The plugins we've found + $plugins = [ + PluginLocationEnum::Core => [], + PluginLocationEnum::Available => [], + PluginLocationEnum::Local => [] + ]; + + $pluginIndex = []; + + // First pull the set of available plugins + + foreach($this->paths as $t => $cfg) { + if(!file_exists($cfg['path'])) { + continue; + } + + $dh = opendir($cfg['path']); + + while(($d = readdir($dh)) !== false) { + if($d == "." || $d == ".." || $d == ".DS_Store") { + continue; + } + + $plugins[$t][] = $d; + + if(empty($pluginIndex[$d])) { + // We want the first path we find for a given plugin so as not to violate AR-Plugin-5 + $pluginIndex[$d] = $t; + } + } + + closedir($dh); + } + + // Pull our current Plugin configuration + + $registeredIndex = []; + + $registered = $this->find()->all(); + + // Create an array of the already registered plugins + foreach($registered as $rp) { + $registeredIndex[$rp->plugin] = $rp; + } + + // Insert rows for any plugin not currently in the Registry. + // Core plugins are inserted as active, others as suspended. + + $newPlugins = []; + + foreach(array_keys($plugins) as $pluginType) { + foreach($plugins[$pluginType] as $p) { + if(!isset($registeredIndex[$p])) { + // This is a new plugin + $obj = $this->newEntity([ + 'plugin' => $p, + 'location' => $pluginType, + 'status' => $this->paths[$pluginType]['status'], + 'comment' => __d('information', + $this->paths[$pluginType]['status'] == SuspendableStatusEnum::Active + ? 'plugin.active.only' + : 'plugin.inactive') + ]); + + $this->saveOrFail($obj); + } elseif($registeredIndex[$p]->location != $pluginIndex[$p]) { + // The plugin location moved. This won't typically happen, but might + // if a developer moves a plugin around. + + $rp = $registeredIndex[$p]; + + if($rp->location == PluginLocationEnum::Core) { + // If the old location was core, update the comment but leave the plugin as active + $rp->comment = __d('information', 'plugin.active'); + } elseif($pluginIndex[$p] == PluginLocationEnum::Core) { + // If the new location is core, make sure the plugin is active + $rp->status = SuspendableStatusEnum::Active; + $rp->comment = __d('information', 'plugin.active.only'); + } + + $rp->location = $pluginIndex[$p]; + + $this->saveOrFail($rp); + } + } + } + + // Remove rows for any plugin that no longer exists on disk + foreach($registered as $rp) { + if(!isset($pluginIndex[$rp->plugin])) { + $this->deleteOrFail($rp); + } + } + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return $validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $this->registerStringValidation($validator, $schema, 'plugin', true); + + $validator->add('location', [ + 'content' => ['rule' => ['inList', PluginLocationEnum::getConstValues()]] + ]); + $validator->notEmptyString('location'); + + $validator->add('status', [ + 'content' => ['rule' => ['inList', SuspendableStatusEnum::getConstValues()]] + ]); + $validator->notEmptyString('status'); + + $this->registerStringValidation($validator, $schema, 'comment', false); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/PronounsTable.php b/app/src/Model/Table/PronounsTable.php new file mode 100644 index 000000000..b380170d0 --- /dev/null +++ b/app/src/Model/Table/PronounsTable.php @@ -0,0 +1,178 @@ + [ + 'default' + ] + ]; + + /** + * Provide the default layout + * + * @since COmanage Registry v5.0.0 + * @return string Type of redirect + */ + public function getLayout(): string { + return "iframe"; + } + + /** + * Perform Cake Model initialization. + * + * @since COmanage Registry v5.0.0 + * @param array $config Configuration options passed to constructor + */ + + public function initialize(array $config): void { + // Timestamp behavior handles created/modified updates + $this->addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Secondary); + + // Define associations + $this->belongsTo('People'); + $this->belongsTo('ExternalIdentities'); + $this->belongsTo('Types'); + + $this->setDisplayField('pronouns'); + + $this->setPrimaryLink(['external_identity_id', 'person_id']); + $this->setRequiresCO(true); + $this->setRedirectGoal('self'); + $this->setAllowLookupPrimaryLink(['unfreeze']); + + $this->setAutoViewVars([ + 'languages' => [ + 'type' => 'enum', + 'class' => 'LanguageEnum' + ], + 'types' => [ + 'type' => 'type', + 'attribute' => 'Pronouns.type' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'unfreeze' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that are permitted on readonly entities (besides view) + 'readOnly' => ['unfreeze'], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Callback after model save. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Event + * @param EntityInterface $entity Entity (ie: Co) + * @param ArrayObject $options Save options + * @return bool True on success + */ + + public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { + $this->recordHistory($entity); + + return true; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + * @throws InvalidArgumentException + * @throws RecordNotFoundException + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $this->registerPrimaryKeyValidation($validator, $this->getPrimaryLinks()); + + $this->registerStringValidation($validator, $schema, 'pronouns', true); + + $validator->add('type_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('type_id'); + + $validator->add('language', [ + 'content' => ['rule' => ['inList', LanguageEnum::getConstValues()]] + ]); + $validator->allowEmptyString('language'); + + $validator->add('frozen', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('frozen'); + + $validator->add('source_pronoun_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('source_pronoun_id'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/ProvisioningHistoryRecordsTable.php b/app/src/Model/Table/ProvisioningHistoryRecordsTable.php new file mode 100644 index 000000000..006049a1e --- /dev/null +++ b/app/src/Model/Table/ProvisioningHistoryRecordsTable.php @@ -0,0 +1,238 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Artifact); + + // Define associations + $this->belongsTo('ProvisioningTargets'); + $this->belongsTo('People'); + $this->belongsTo('Groups'); + + $this->setDisplayField('comment'); + + // We list provisioning_target_id last so breadcrumbs don't try to use it + $this->setPrimaryLink(['person_id', 'group_id', 'provisioning_target_id']); + //$this->setAllowLookupPrimaryLink(['primary']); + $this->setRequiresCO(true); + + $this->setAutoViewVars([ + 'statuses' => [ + 'type' => 'enum', + 'class' => 'ProvisioningStatusEnum' + ] + ]); + + $this->setIndexContains(['ProvisioningTargets']); + + $this->setViewContains([ + 'People' => ['PrimaryName'], + 'Groups' + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, + 'edit' => false, + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Perform actions while marshaling data, before validation. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Event + * @param ArrayObject $data Object data, in array format + * @param ArrayObject $options Entity save options + */ + + public function beforeMarshal(EventInterface $event, \ArrayObject $data, \ArrayObject $options) + { + if(!empty($data['comment'])) { + // Truncate the comment to fit the column width + $column = $this->getSchema()->getColumn('comment'); + + $data['comment'] = substr($data['comment'], 0, $column['length']); + } + } + + /** + * Table specific logic to generate a display field. + * + * @since COmanage Registry v5.0.0 + * @param JobHistoryRecord $entity Entity to generate display field for + * @return string Display field + */ + + public function generateDisplayField(\App\Model\Entity\ProvisioningHistoryRecord $entity): string { + // Comments may be too long to render, so we just use the model name + // (which will get appended with the record ID) + + return __d('controller', 'ProvisioningHistoryRecords', [1]); + } + + /** + * Record a Provisioning History Record. + * + * @since COmanage Registry v5.0.0 + * @param int $provisioningTargetId Provisioning Target ID + * @param string $comment Comment + * @param string $status ProvisioningStatusEnum + * @param string $subjectModel The provisioned model + * @param int $subjectId The provisioned entity id (of type $subjectModel) + * @return int Provisioning History Record ID + */ + + public function record(int $provisioningTargetId, + string $comment, + string $status, + string $subjectModel, + int $subjectId): int { + // We record all models and foreign keys, but only select (primary) models + // have database level foreign key relations (for viewing history) so we + // populate the correct foreign key if supported + + $personId = null; + $groupId = null; + + switch($subjectModel) { + case 'Groups': + $groupId = $subjectId; + break; + case 'People': + $personId = $subjectId; + break; + default: + break; + } + + $obj = $this->newEntity([ + 'provisioning_target_id' => $provisioningTargetId, + 'comment' => $comment, + 'status' => $status, + 'subject_model' => $subjectModel, + 'subjectid' => $subjectId, + 'person_id' => $personId, + 'group_id' => $groupId + ]); + + $this->saveOrFail($obj); + +// XXX trace this too? (below is copy/paste from Job History) + // For now, always trace log Job History. We might do something more complicated later. + // eg: Make it configurable whether we create Job History, log, or both? + // This is documented at https://spaces.at.internet2.edu/display/COmanage/Registry+PE+Jobs#RegistryPEJobs-RegistryJobHistory +// $this->llog('trace', $comment, "{$jobId}:{$recordKey}"); + + return $obj->id; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + * @throws InvalidArgumentException + * @throws RecordNotFoundException + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('provisioning_target_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('provisioning_target_id'); + + $this->registerStringValidation($validator, $schema, 'comment', true); + + $validator->add('status', [ + 'content' => ['rule' => ['inList', ProvisioningStatusEnum::getConstValues()]] + ]); + $validator->notEmptyString('status'); + + $this->registerStringValidation($validator, $schema, 'subject_model', true); + + $validator->add('subjectid', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('subjectid'); + + $validator->add('person_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('person_id'); + + $validator->add('group', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('group'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/ProvisioningTargetsTable.php b/app/src/Model/Table/ProvisioningTargetsTable.php new file mode 100644 index 000000000..9dcc5b2ed --- /dev/null +++ b/app/src/Model/Table/ProvisioningTargetsTable.php @@ -0,0 +1,342 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Orderable'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('Cos'); + $this->belongsTo('ProvisioningGroups') + ->setClassName('Groups') + ->setForeignKey('provisioning_group_id') + // Property is set so ruleValidateCO can find it. We don't use the + // _id suffix to match Cake's default pattern. + ->setProperty('provisioning_group'); + + $this->hasMany('ProvisioningHistoryRecords') + ->setDependent(true) + ->setCascadeCallbacks(true); + + $this->setPluginRelations(); + + $this->setDisplayField('description'); + + $this->setPrimaryLink(['co_id', 'group_id', 'person_id']); + $this->setRequiresCO(true); + $this->setAllowUnkeyedPrimaryLink(['status']); + + $this->setAutoViewVars([ + 'plugins' => [ + 'type' => 'plugin', + 'pluginType' => 'provisioner' + ], + 'provisioningGroups' => [ + 'type' => 'select', + 'model' => 'ProvisioningGroups' + ], + 'statuses' => [ + 'type' => 'enum', + 'class' => 'ProvisionerModeEnum' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'configure' => ['platformAdmin', 'coAdmin'], + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'], + 'status' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Invoke provisioning. This function is intended to be called via ProvisionableTrait. + * + * @since COmanage Registry v5.0.0 + * @param mixed $data Provisioning object data, eg as returned by Table::get() + * @param ProvisioningEligibilityEnum $eligibility Provisioning eligibility + * @param ProvisioningContextEnum $context Provisioning context + * @param int $id Provisioning Target ID, or null to provision all targets + */ + + public function provision( + mixed $data, + string $eligibility, + string $context, + ?int $id=null + ) { + // Convert the primary data object to the primary provisioned object name + // (eg: People or Cous) + $provisionedModel = StringUtilities::entityToClassName($data); + + $query = $this->find() + ->where([ + 'ProvisioningTargets.co_id' => $data->co_id, +// XXX how do we know which mode's worth of provisioners we want? + 'ProvisioningTargets.status <>' => ProvisionerModeEnum::Disabled + ]); + + if($id) { + $query = $query->where(['ProvisioningTargets.id' => $id]); + } + + $targets = $query->order(['ProvisioningTargets.ordr' => 'ASC']) + ->contain($this->getPluginRelations()) + ->all(); + + foreach($targets as $t) { + // Compare our $context against the target's $status. There are three possible + // contexts, with their corresponding provisionable statuses: + // Automatic: Immediate, Queue, QuueOnError + // Enrollment: Enrollment, Immediate, Queue, QueueOnError + // Manual: Enrollment, Immediate, Manual, Queue, QueueOnError +// XXX do we need ARs or PARs for this? add appropriate logging along with ARs + + switch($context) { + case ProvisioningContextEnum::Automatic: + if(!in_array($t->status, [ + ProvisionerModeEnum::Immediate, + ProvisionerModeEnum::Queue, + ProvisionerModeEnum::QueueOnError + ])) { + $this->llog('trace', "Skipping Provisioning Target with mode " . $t->status . " (automatic context)", $t->id); + continue 2; + } + break; + case ProvisioningContextEnum::Enrollment: + if($t->status == ProvisionerModeEnum::Manual) { + $this->llog('trace', "Skipping Provisioning Target with mode " . $t->status . " (enrollment context)", $t->id); + continue 2; + } + break; + case ProvisioningContextEnum::Manual: + // Manual provisioning is permitted regardless of target status + break; + } + + $pluginModel = StringUtilities::pluginModel($t->plugin); + // The model in underscore format, eg file_provisioner + $uPluginModel = Inflector::underscore(Inflector::singularize($pluginModel)); + + // Does this plugin support this model? + if(!$this->$pluginModel->isProvisionableModel($provisionedModel)) { + $this->llog('trace', "Skipping $provisionedModel for $pluginModel (not supported)", $t->id); + continue; + } + + try { + $this->llog('trace', "Provisioning $provisionedModel for $pluginModel (context: $context)", $t->id); + + $result = $this->$pluginModel->provision($t->$uPluginModel, $provisionedModel, $data, $eligibility); + + $this->ProvisioningHistoryRecords->record( + provisioningTargetId: $t->id, + comment: $result['comment'], + status: $result['status'], + subjectModel: $provisionedModel, + subjectId: $data->id + ); + } + catch(\Exception $e) { + $this->ProvisioningHistoryRecords->record( + provisioningTargetId: $t->id, + comment: $e->getMessage(), + status: ProvisioningStatusEnum::NotProvisioned, + subjectModel: $provisionedModel, + subjectId: $data->id + ); + } + } + } + + /** + * Obtain provisioning status. (Either $groupId or $personId must be requested.) + * + * @since COmanage Registry v5.0.0 + * @param int $coId CO ID + * @param int $groupId Group ID + * @param int $personId Person ID + */ + + public function status(int $coId, int $groupId=null, int $personId=null): array { + $ret = []; + + // Start by pulling the set of active provisioning targets + + $targets = $this->find() + ->where([ + 'ProvisioningTargets.co_id' => $coId, + 'ProvisioningTargets.status <>' => ProvisionerModeEnum::Disabled + ]) + ->all(); + + if(!empty($targets)) { + foreach($targets as $t) { + // For each target, get the status of the target for the requested subject. + // If the plugin implements a status() function we'll call it, otherwise + // we'll get the status from ProvisioningHistory. + + $pluginModel = StringUtilities::pluginModel($t->plugin); + + if(method_exists($this->$pluginModel, 'status')) { + // XXX define interface and call (implement with SqlProvisioner) + throw new \RuntimeException('NOT IMPLEMENTED'); + } else { + $subjectFK = null; + $subjectID = null; + + if(!empty($personId)) { + $subjectFK = 'person_id'; + $subjectID = $personId; + } elseif(!empty($groupId)) { + $subjectFK = 'group_id'; + $subjectID = $groupId; + } else { + throw new \InvalidArgumentException("NOT IMPKEMENTED"); + } + + $rec = $this->ProvisioningHistoryRecords->find() + ->where([ + 'provisioning_target_id' => $t->id, + $subjectFK => $subjectID + ]) + ->order(['id' => 'DESC']) + ->first(); + + if(!empty($rec)) { + $ret[] = [ + 'target' => $t, + 'status' => $rec->status, + 'comment' => $rec->comment, + // XXX where does identifier come from? + //'identifier' => '?', + 'timestamp' => $rec->created + ]; + } else { + $ret[] = [ + 'target' => $t, + 'status' => ProvisioningStatusEnum::NotProvisioned, + 'comment' => __d('enumeration', 'ProvisioningStatusEnum.'.ProvisioningStatusEnum::NotProvisioned) + ]; + } + } + } + } + + return $ret; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('co_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('co_id'); + + $this->registerStringValidation($validator, $schema, 'description', false); + + $validator->add('status', [ + 'content' => ['rule' => ['inList', ProvisionerModeEnum::getConstValues()]] + ]); + $validator->notEmptyString('status'); + + $this->registerStringValidation($validator, $schema, 'plugin', true); + + $validator->add('provisioning_group_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('provisioning_group_id'); + + $validator->add('retry_interval', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('retry_interval'); + + $validator->add('ordr', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('ordr'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/ServersTable.php b/app/src/Model/Table/ServersTable.php new file mode 100644 index 000000000..6f29d004c --- /dev/null +++ b/app/src/Model/Table/ServersTable.php @@ -0,0 +1,169 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Primary); + + // Define associations + $this->belongsTo('Cos'); + + // In general, we don't want to propagate deletes of a Server to its + // hasMany dependents since we want to throw an error for the administrator + // first. (For deleting a CO, the dependent objects should be deleted first.) + $this->hasMany('Pipelines') + ->setForeignKey('match_server_id'); + +// XXX Note this will bind to (eg) CoreServer but not (eg) SqlProvisioner + $this->setPluginRelations(); + + $this->setDisplayField('description'); + + $this->setPrimaryLink('co_id'); + $this->setRequiresCO(true); + + $this->setAutoViewVars([ + 'plugins' => [ + 'type' => 'plugin', + 'pluginType' => 'server' + ], + 'statuses' => [ + 'type' => 'enum', + 'class' => 'SuspendableStatusEnum' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'configure' => ['platformAdmin', 'coAdmin'], + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Define business rules. + * + * @since COmanage Registry v5.0.0 + * @param RulesChecker $rules RulesChecker object + * @return RulesChecker + */ + + public function buildRules(RulesChecker $rules): RulesChecker { + // AR-Server-1 A Server cannot be deleted if it is referenced from + // a Configuration object (including plugins). + + $rules->addDelete([$this, 'ruleInUse'], + 'serverInUse', + ['errorField' => 'status']); + + return $rules; + } + + /** + * Application Rule to determine if the server is in use. + * + * @since COmanage Registyr v5.0.0 + * @param Entity $entity Entity to be validated + * @param array $options Application rule options + * @return boolean true if the Rule check passes, false otherwise + */ + + public function ruleInUse($entity, $options) { + // XXX CFM-281 we need to do something here + + return true; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('co_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('co_id'); + + $this->registerStringValidation($validator, $schema, 'description', false); + + $validator->add('status', [ + 'content' => ['rule' => ['inList', SuspendableStatusEnum::getConstValues()]] + ]); + $validator->notEmptyString('status'); + + $this->registerStringValidation($validator, $schema, 'plugin', true); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/TelephoneNumbersTable.php b/app/src/Model/Table/TelephoneNumbersTable.php new file mode 100644 index 000000000..78f3013fc --- /dev/null +++ b/app/src/Model/Table/TelephoneNumbersTable.php @@ -0,0 +1,219 @@ + [ + 'campus', + 'fax', + 'home', + 'mobile', + 'office' + ] + ]; + + /** + * Provide the default layout + * + * @since COmanage Registry v5.0.0 + * @return string Type of redirect + */ + public function getLayout(): string { + return "iframe"; + } + + /** + * Perform Cake Model initialization. + * + * @since COmanage Registry v5.0.0 + * @param array $config Configuration options passed to constructor + */ + + public function initialize(array $config): void { + // Timestamp behavior handles created/modified updates + $this->addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Secondary); + + // Define associations + $this->belongsTo('People'); + $this->belongsTo('PersonRoles'); + $this->belongsTo('ExternalIdentities'); + $this->belongsTo('ExternalIdentityRoles'); + $this->belongsTo('Types'); + + $this->setDisplayField('number'); + + $this->setPrimaryLink(['external_identity_id', 'external_identity_role_id', 'person_id', 'person_role_id']); + $this->setRequiresCO(true); + // Models that AcceptCoId should be expicitly added to StandardApiController::initialize() + $this->setAcceptsCoId(true); + $this->setRedirectGoal('self'); + $this->setAllowLookupPrimaryLink(['unfreeze']); + + $this->setAutoViewVars([ + 'types' => [ + 'type' => 'type', + 'attribute' => 'TelephoneNumbers.type' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'unfreeze' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that are permitted on readonly entities (besides view) + 'readOnly' => ['unfreeze'], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Callback after model save. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Event + * @param EntityInterface $entity Entity (ie: Co) + * @param ArrayObject $options Save options + * @return bool True on success + */ + + public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { + $this->recordHistory($entity); + + return true; + } + + /** + * Perform a keyword search. + * + * @since COmanage Registry v5.0.0 + * @param int $coId CO ID to constrain search to + * @param string $q String to search for + * @param int $limit Search limit + * @return Array Array of search results, as from find('all) + */ + + public function search(int $coId, string $q, int $limit) { + return $this->find() + ->where(['TelephoneNumbers.number' => $q]) + ->limit($limit) + ->contain([ + 'People' => 'PrimaryName', + 'PersonRoles' => [ + 'People' => 'PrimaryName' + ] + ]) + ->all(); + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + * @throws InvalidArgumentException + * @throws RecordNotFoundException + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + // We need the current CO ID to dynamically set validation rules according + // to CoSettings. + + if(!$this->curCoId) { + throw new \InvalidArgumentException(__d('error', 'coid')); + } + + $CoSettings = TableRegistry::getTableLocator()->get('CoSettings'); + + $settings = $CoSettings->find()->where(['co_id' => $this->curCoId])->firstOrFail(); + + $permittedFields = $settings->telephone_number_permitted_fields_array(); + + $this->registerPrimaryKeyValidation($validator, $this->getPrimaryLinks()); + + foreach(['country_code', 'area_code', 'number', 'extension'] as $f) { + if(in_array($f, $permittedFields)) { + $this->registerStringValidation($validator, $schema, $f, ($f == 'number')); + } + } + + $this->registerStringValidation($validator, $schema, 'description', false); + + $validator->add('type_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('type_id'); + + $validator->add('frozen', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('frozen'); + + $validator->add('source_telephone_number_id', [ + 'content' => [ 'rule' => 'isInteger' ] + ]); + $validator->allowEmptyString('source_telephone_number_id'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/TypesTable.php b/app/src/Model/Table/TypesTable.php new file mode 100644 index 000000000..7af7e94a5 --- /dev/null +++ b/app/src/Model/Table/TypesTable.php @@ -0,0 +1,415 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('Cos'); + $this->hasMany('CoSettings') + ->setForeignKey('default_name_type_id'); + $this->hasMany('Addresses'); + $this->hasMany('EmailAddresses'); + $this->hasMany('Identifiers'); + $this->hasMany('Names'); + $this->hasMany('PersonRoles') + ->setForeignKey('affiliation_type_id'); + $this->hasMany('PipelineMatchTypes') + ->setClassName('Pipelines') + ->setForeignKey('match_type_id'); + $this->hasMany('PipelineSyncAffiliationTypes') + ->setClassName('Pipelines') + ->setForeignKey('sync_affiliation_type_id'); + $this->hasMany('PipelineSyncIdentifierTypes') + ->setClassName('Pipelines') + ->setForeignKey('sync_identifier_type_id'); + $this->hasMany('Pronouns'); + $this->hasMany('TelephoneNumbers'); + $this->hasMany('Urls'); + + $this->setDisplayField('display_name'); + + $this->setPrimaryLink('co_id'); + $this->setRequiresCO(true); + $this->setAllowUnkeyedPrimaryLink(['restore']); + + $this->setAutoViewVars([ + 'attributes' => [ + 'type' => 'array', + 'array' => $this->supportedAttributes + ], + 'edupersonaffiliations' => [ + 'type' => 'enum', + 'class' => 'EduPersonAffiliationEnum' + ], + 'statuses' => [ + 'type' => 'enum', + 'class' => 'SuspendableStatusEnum' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'], + 'restore' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Add the default types for an attribute. + * + * @since COmanage Registry v0.9.2 + * @param int $coId CO ID + * @param string $attribute Attribute, of the form Model.attribute + * @return bool True on success + * @throws InvalidArgumentException + * @throws PersistenceFailedException + */ + + public function addDefault(int $coId, string $attribute) { + // Make sure $attribute is valid + + if(!in_array($attribute, $this->supportedAttributes)) { + throw new \InvalidArgumentException(__d('error', 'unknown', [$attribute])); + } + + // Split $attribute (eg: Names.type) + $attr = explode('.', $attribute, 2); + + // We need the appropriate model for $attribute to manipulate the default types + // $table = (eg) NamesTable + $table = TableRegistry::getTableLocator()->get($attr[0]); + + // The current set of types for this model, of the form value => display_name + $current = $table->availableTypes($coId, $attribute); + + // The default types for this model, of the same form + $modelDefault = $table->defaultTypes($attr[1]); + + // Construct a set of arrays that we'll convert to entities to save + $records = []; + + foreach($modelDefault as $value => $displayName) { + if(!array_key_exists($value, $current)) { + $records[] = [ + 'co_id' => $coId, + 'attribute' => $attribute, + 'display_name' => $displayName, + 'value' => $value, + 'status' => SuspendableStatusEnum::Active + ]; + } + } + + // Convert the arrays to entities + $entities = $this->newEntities($records); + + // throws PersistenceFailedException on failure + $this->saveManyOrFail($entities); + + return true; + } + + /** + * Add all default values for extended types for the specified CO. + * + * @since COmanage Registry v0.9.2 + * @param int $coId CO ID + * @return bool True on success + * @throws RuntimeException + */ + + public function addDefaults(int $coId) { + foreach(array_values($this->supportedAttributes) as $t) { + try { + $this->addDefault($coId, $t); + } + catch(\Exception $e) { + throw new \RuntimeException($e->getMessage()); + } + } + + return true; + } + + /** + * Define business rules. + * + * @since COmanage Registry v5.0.0 + * @param RulesChecker $rules RulesChecker object + * @return RulesChecker + */ + + public function buildRules(RulesChecker $rules): RulesChecker { + // AR-Type-2 A Type cannot be deleted once used at least one time + $rules->addDelete([$this, 'ruleTypeInUse'], + 'typeInUse', + ['errorField' => 'type_id']); + + return $rules; + } + + /** + * Get the ID for a Type. + * + * @since COmanage Registry v5.0.0 + * @param int $coId CO ID + * @param string $attribute Attribute, in Models.attribute form + * @param string $value Value + * @return int Type ID + */ + + public function getTypeId(int $coId, string $attribute, string $value): int { + $t = $this->find() + ->where([ + 'Types.co_id' => $coId, + 'Types.attribute' => $attribute, + 'Types.value' => $value + ]) + ->firstOrFail(); + + return $t->id; + } + + /** + * Obtain the type label for a given type entity. + * + * @since COmanage Registry v5.0.0 + * @param int $id Type ID + * @return string Type value (label) + */ + + public function getTypeLabel(int $id): string { + $type = $this->get($id); + + return $type->value; + } + + /** + * Marshal object data for provisioning. + * + * @since COmanage Registry v5.0.0 + * @param int $id Entity ID + * @return array An array of provisionable data and eligibility + */ + + public function marshalProvisioningData(int $id): array { + $ret = []; + // We need the archived record on delete to properly deprovision + $ret['data'] = $this->get($id, ['archived' => true]); + + // Provisioning Eligibility is + // - Deleted if the changelog deleted flag is true + // - Eligible if status is Active + // - Ineligible otherwise + + $ret['eligibility'] = ProvisioningEligibilityEnum::Ineligible; + + if($ret['data']->deleted) { + $ret['eligibility'] = ProvisioningEligibilityEnum::Deleted; + } elseif($ret['data']->status == SuspendableStatusEnum::Active) { + $ret['eligibility'] = ProvisioningEligibilityEnum::Eligible; + } + + return $ret; + } + + /** + * Determine if this type is in use. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Entity to be validated + * @return boolean true if the entity is in use, false otherwise + */ + + public function typeInUse($entity) { + $attr = explode('.', $entity->attribute, 2); + + // Pull the table for this attribute, then see if there are any records + // where the column matches the requested type ID + + $table = TableRegistry::getTableLocator()->get($attr[0]); + + // We include changelog-archived records for referential integrity... if a + // record was created that references this type and then was subsequently + // deleted, it is still considered "in use". + + $count = $table->find('all', ['archived' => true]) + ->where([$attr[1]."_id" => $entity->id]) + ->count(); + + return $count != 0; + } + + /** + * Determine if the provided Type is in use as a default. + * + * @since COmanage Registry v5.0.0 + * @param Type $entity Type + * @return bool true if the Type is in use as a default, false otherwise + */ + + public function typeIsDefault(\App\Model\Entity\Type $entity): bool { + $attr = explode('.', $entity->attribute, 2); + + $CoSettings = TableRegistry::getTableLocator()->get('CoSettings'); + + return $CoSettings->typeIsDefault($entity->id); + } + + /** + * Application Rule to determine if the requested type is in use. + * + * @since COmanage Registyr v5.0.0 + * @param Entity $entity Entity to be validated + * @param array $options Application rule options + * @return boolean true if the Rule check passes, false otherwise + */ + + public function ruleTypeInUse($entity, $options) { + // First check that there are no operational references to the type + if($this->typeInUse($entity)) { + return __d('error', 'Types.inuse', [$entity->value]); + } + + // Also check that the type is not a default type in the CO Setting. + if($this->typeIsDefault($entity)) { + return __d('error', 'Types.isdefault', [$entity->value]); + } + + return true; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('co_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('co_id'); + + $validator->add('attribute', [ + 'content' => ['rule' => ['inList', $this->supportedAttributes]] + ]); + $validator->notEmptyString('attribute'); + + $validator->add('value', [ + 'length' => ['rule' => ['validateMaxLength', ['column' => $schema->getColumn('value')]], + 'provider' => 'table'], + 'value' => ['rule' => ['custom', '/^[a-zA-Z0-9\-\.]+$/'], + 'message' => __d('error', 'input.invalid')] + ]); + $validator->notEmptyString('value'); + + $this->registerStringValidation($validator, $schema, 'display_name', true); + + $validator->add('edupersonaffiliation', [ + 'content' => ['rule' => ['inList', EduPersonAffiliationEnum::getConstValues()]] + ]); + $validator->allowEmptyString('edupersonaffiliation'); + + $validator->add('status', [ + 'content' => ['rule' => ['inList', SuspendableStatusEnum::getConstValues()]] + ]); + $validator->notEmptyString('status'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/UrlsTable.php b/app/src/Model/Table/UrlsTable.php new file mode 100644 index 000000000..9c4fe052f --- /dev/null +++ b/app/src/Model/Table/UrlsTable.php @@ -0,0 +1,194 @@ + [ + 'official', + 'personal' + ] + ]; + + /** + * Provide the default layout + * + * @since COmanage Registry v5.0.0 + * @return string Type of redirect + */ + public function getLayout(): string { + return "iframe"; + } + + /** + * Perform Cake Model initialization. + * + * @since COmanage Registry v5.0.0 + * @param array $config Configuration options passed to constructor + */ + + public function initialize(array $config): void { + // Timestamp behavior handles created/modified updates + $this->addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Secondary); + + // Define associations + $this->belongsTo('People'); + $this->belongsTo('ExternalIdentities'); + $this->belongsTo('Types'); + + $this->setDisplayField('url'); + + $this->setPrimaryLink(['external_identity_id', 'person_id']); + $this->setRequiresCO(true); + $this->setRedirectGoal('self'); + $this->setAllowLookupPrimaryLink(['unfreeze']); + + $this->setAutoViewVars([ + 'types' => [ + 'type' => 'type', + 'attribute' => 'Urls.type' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'unfreeze' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that are permitted on readonly entities (besides view) + 'readOnly' => ['unfreeze'], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Callback after model save. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Event + * @param EntityInterface $entity Entity (ie: Co) + * @param ArrayObject $options Save options + * @return bool True on success + */ + + public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { + $this->recordHistory($entity); + + return true; + } + + /** + * Perform a keyword search. + * + * @since COmanage Registry v5.0.0 + * @param int $coId CO ID to constrain search to + * @param string $q String to search for + * @param int $limit Search limit + * @return Array Array of search results, as from find('all) + */ + + public function search(int $coId, string $q, int $limit) { + return $this->find() + ->where(['Urls.url' => $q]) + ->limit($limit) + ->contain(['People' => 'PrimaryName']) + ->all(); + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + * @throws InvalidArgumentException + * @throws RecordNotFoundException + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $this->registerPrimaryKeyValidation($validator, $this->getPrimaryLinks()); + + $validator->add('url', [ + 'content' => ['rule' => ['url'], + 'message' => __d('error', 'input.invalid.url')] + ]); + + $this->registerStringValidation($validator, $schema, 'url', true); + + $this->registerStringValidation($validator, $schema, 'description', false); + + $validator->add('type_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('type_id'); + + $validator->add('frozen', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('frozen'); + + $validator->add('source_url_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('source_url_id'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/View/AjaxView.php b/app/src/View/AjaxView.php new file mode 100644 index 000000000..dd9d7d984 --- /dev/null +++ b/app/src/View/AjaxView.php @@ -0,0 +1,46 @@ +response = $this->response->withType('ajax'); + } +} diff --git a/app/src/View/AppView.php b/app/src/View/AppView.php new file mode 100644 index 000000000..7ccc7c2e9 --- /dev/null +++ b/app/src/View/AppView.php @@ -0,0 +1,41 @@ +loadHelper('Html');` + * + * @return void + */ + public function initialize(): void + { + } +} diff --git a/app/src/View/Cell/.gitkeep b/app/src/View/Cell/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/app/src/View/Helper/AlertHelper.php b/app/src/View/Helper/AlertHelper.php new file mode 100644 index 000000000..d653759df --- /dev/null +++ b/app/src/View/Helper/AlertHelper.php @@ -0,0 +1,96 @@ + + + + '; + $dismissableClass = ' alert-dismissible'; + } + + $titleMarkup = ''; + if(!empty($title)) { + $titleMarkup = '' . $title . ''; + } + + return ' + + '; + } + + public function getAlertIcon(string $type) { + switch($type) { + case('success'): return 'check_circle'; + case('information'): return 'info'; + default: return 'report_problem'; + } + } + +} \ No newline at end of file diff --git a/app/src/View/Helper/BadgeHelper.php b/app/src/View/Helper/BadgeHelper.php new file mode 100644 index 000000000..9cf7c7d95 --- /dev/null +++ b/app/src/View/Helper/BadgeHelper.php @@ -0,0 +1,127 @@ +'; + } + + return $this->Html->tag( + 'span', + $fa_element . $title, + [ + 'class' => 'mr-1 badge ' . implode(' ', $badge_classes), + 'escape' => false, + ] + ); + } + + /** + * Get the Badge Color + * + * @param string $color + * @return string|null + * + * @since COmanage Registry v5.0.0 + */ + public function getBadgeColor(string $color): ?string { + if(empty($color)) { + return null; + } + + $color_map = [ + 'Success' => 'success', + 'Danger' => 'danger', + 'Warning' => 'warning', + 'Primary' => 'primary', + 'Secondary' => 'secondary', + 'Light' => 'light', + 'Info' => 'info', + 'Dark' => 'dark', + ]; + + if(!isset($color_map[$color])) { + return null; + } + + return $color_map[$color]; + } + +} \ No newline at end of file diff --git a/app/src/View/Helper/FieldHelper.php b/app/src/View/Helper/FieldHelper.php new file mode 100644 index 000000000..961eaf862 --- /dev/null +++ b/app/src/View/Helper/FieldHelper.php @@ -0,0 +1,671 @@ +' . + $this->Alert->alert($info, 'warning') + . ''; + } + + /** + * Emit a form control. + * + * @since COmanage Registry v5.0.0 + * @param string $fieldName Form field + * @param array $options FormHelper control options + * @param string $labelText Label text (fieldName language key used by default) + * @param string $ctrlCode Control code passed in from wrapper functions + * @param string $cssClass Start li css class passed in from wrapper functions + * @param string $beforeField Markup to be placed before/above the field + * @param string $afterField Markup to be placed after/below the field + * @param string $prefix Field prefix - used for API Usernames + * @param bool $labelIsTextOnly For fields that should not include