diff --git a/app/config/schema/schema.json b/app/config/schema/schema.json index b99afbe73..2bd2f450c 100644 --- a/app/config/schema/schema.json +++ b/app/config/schema/schema.json @@ -94,6 +94,19 @@ } }, + "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": {}, diff --git a/app/resources/locales/en_US/command.po b/app/resources/locales/en_US/command.po index 6ed37b16c..52dc5f041 100644 --- a/app/resources/locales/en_US/command.po +++ b/app/resources/locales/en_US/command.po @@ -53,3 +53,12 @@ msgstr "Setup appears to have already run" msgid "se.salt" msgstr "Generating salt file" + +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 index d6b7ec4bd..48d41f678 100644 --- a/app/resources/locales/en_US/controller.po +++ b/app/resources/locales/en_US/controller.po @@ -33,6 +33,9 @@ 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}}" diff --git a/app/resources/locales/en_US/enumeration.po b/app/resources/locales/en_US/enumeration.po index 6e78f662a..a973c25e2 100644 --- a/app/resources/locales/en_US/enumeration.po +++ b/app/resources/locales/en_US/enumeration.po @@ -24,6 +24,12 @@ # Enumerations +msgid "AuthenticationEventEnum.AI" +msgstr "API Login" + +msgid "AuthenticationEventEnum.IN" +msgstr "Registry Login" + msgid "BooleanEnum.0" msgstr "False" diff --git a/app/resources/locales/en_US/error.po b/app/resources/locales/en_US/error.po index 22d03e879..bbd916b16 100644 --- a/app/resources/locales/en_US/error.po +++ b/app/resources/locales/en_US/error.po @@ -115,6 +115,9 @@ msgstr "Group cannot be nested into itself" msgid "Groups.nested" msgstr "Group is nested or has nestings, and cannot be suspended or deleted" +msgid "Identifiers.login" +msgstr "Only Identifiers attached to a Person may be flagged for login" + msgid "input.blank" msgstr "Value cannot consist of only blank characters" @@ -136,6 +139,9 @@ 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}\"" diff --git a/app/resources/locales/en_US/field.po b/app/resources/locales/en_US/field.po index ddbcde816..61c272bb5 100644 --- a/app/resources/locales/en_US/field.po +++ b/app/resources/locales/en_US/field.po @@ -44,6 +44,12 @@ msgstr "Area Code" msgid "attribute" msgstr "Attribute" +msgid "AuthenticationEvents.authenticated_identifier" +msgstr "Authenticated Identifier" + +msgid "AuthenticationEvents.authentication_event" +msgstr "Authentication Event" + msgid "comment" msgstr "Comment" diff --git a/app/src/Command/TransmogrifyCommand.php b/app/src/Command/TransmogrifyCommand.php index 71fd84eb3..73058b6fa 100644 --- a/app/src/Command/TransmogrifyCommand.php +++ b/app/src/Command/TransmogrifyCommand.php @@ -102,6 +102,10 @@ class TransmogrifyCommand extends Command { 'person_picker_display_types' => null ] ], + 'authentication_events' => [ + 'source' => 'cm_authentication_events', + 'displayField' => 'authenticated_identifier' + ], 'api_users' => [ 'source' => 'cm_api_users', 'displayField' => 'username', @@ -270,7 +274,8 @@ class TransmogrifyCommand extends Command { 'co_department_id' => null, 'co_provisioning_target_id' => null, 'organization_id' => null - ] + ], + 'preRow' => 'map_login_identifiers' ], 'telephone_numbers' => [ 'source' => 'cm_telephone_numbers', @@ -323,8 +328,8 @@ class TransmogrifyCommand extends Command { // Cache the driver for ease of workarounds protected $outdriver = null; - // Our noise level ('quiet', 'verbose', or 'default') - protected $noise = 'default'; + // Shell arguments, for easier access + protected $args = null; protected $io = null; /** @@ -336,7 +341,16 @@ class TransmogrifyCommand extends Command { */ protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser { - $parser->setEpilog('An optional, space separated list of tables to transmogrify may be specified'); + $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; } @@ -418,6 +432,7 @@ protected function check_group_memberships(array $origRow, array $row) { */ public function execute(Arguments $args, ConsoleIo $io) { + $this->args = $args; $this->io = $io; // Load data from the inbound "transmogrify" database to a newly created @@ -477,12 +492,6 @@ public function execute(Arguments $args, ConsoleIo $io) { $this->outconn = DriverManager::getConnection($cargs, $outconfig); $this->outdriver = $cargs['driver']; - if($args->getOption('quiet')) { - $this->noise = 'quiet'; - } elseif($args->getOption('verbose')) { - $this->noise = 'verbose'; - } - // We accept a list of table names, mostly for testing purposes $atables = $args->getArguments(); @@ -551,7 +560,6 @@ public function execute(Arguments $args, ConsoleIo $io) { $this->fixBooleans($t, $row); $this->mapFields($t, $row); - $this->outconn->insert($schemaPrefix.$t, $row); @@ -571,29 +579,37 @@ public function execute(Arguments $args, ConsoleIo $io) { // 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 record " . $row['id'] . " due to invalid foreign key: " . $e->getMessage()); + $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 record " . $row['id'] . ": " . $e->getMessage()); + $io->warning("Skipping $t record " . $row['id'] . ": " . $e->getMessage()); } catch(\Exception $e) { $err++; - $io->error("Record " . $row['id'] . ": " . $e->getMessage()); + $io->error("$t record " . $row['id'] . ": " . $e->getMessage()); } $tally++; - if($this->noise == 'default') { + 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); } } - $max = $this->inconn->fetchOne('SELECT MAX(id) FROM ' . $this->tables[$t]['source']); + // Run any post processing functions for the table. + + if(!empty($this->tables[$t]['postTable'])) { + $p = $this->tables[$t]['postTable']; + + $this->$p(); + } + + $max = $this->outconn->fetchOne('SELECT MAX(id) FROM ' . $t); $max++; $stdout_msg = "(New max: " . $max . ")"; if($warns > 0) { @@ -605,6 +621,8 @@ public function execute(Arguments $args, ConsoleIo $io) { $io->out($stdout_msg); + $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) @@ -614,14 +632,6 @@ public function execute(Arguments $args, ConsoleIo $io) { $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(); - } } } @@ -656,6 +666,21 @@ protected function findCoId(array $row) { 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'); } @@ -861,6 +886,67 @@ protected function map_extended_type(array $row) { 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. * @@ -941,7 +1027,15 @@ protected function map_org_identity_co_person_id(array $row) { while($r = $stmt->fetch()) { if(!empty($r['org_identity_id'])) { - $this->cache['org_identities']['co_people'][ $r['org_identity_id'] ][ $r['revision'] ] = $r['co_person_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']; + } } } } diff --git a/app/src/Controller/ApiV2Controller.php b/app/src/Controller/ApiV2Controller.php index dca6f4f9e..1db599fa5 100644 --- a/app/src/Controller/ApiV2Controller.php +++ b/app/src/Controller/ApiV2Controller.php @@ -281,6 +281,23 @@ public function index() { $query = $query->where([$this->$modelsName->getAlias().'.'.$link->attr => $link->value]); } + 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->Paginator->paginate($query)); diff --git a/app/src/Controller/AppController.php b/app/src/Controller/AppController.php index 6412e67ec..d5306fa75 100644 --- a/app/src/Controller/AppController.php +++ b/app/src/Controller/AppController.php @@ -68,8 +68,6 @@ public function initialize(): void { $this->loadComponent('RequestHandler'); // Add a detector so we can tell restful from non-restful calls - // Add a detector so we can call request->is('restful') (though note we no longer - // really support XML format...) $request = $this->getRequest(); $request->addDetector('restful', function($request) { @@ -160,127 +158,6 @@ public function beforeRender(\Cake\Event\EventInterface $event) { return parent::beforeRender($event); } - /** - * Default implementation for calculating permissions for standard controllers, - * intended to be overridden by controllers with more specific requirements. - * - * @since COmanage Registry v5.0.0 - * @param int $id Record ID if relevant, or null - * @return array Array of permissions - */ - - public function calculatePermissions(?int $id): array { - $ret = []; - - // $this->name = Models (ie: from ModelsTable) - $modelsName = $this->name; - // $table = the actual table object - $table = $this->$modelsName; - - // Do we have an authenticated user? - $authenticatedUser = (bool)$this->RegistryAuth->getAuthenticatedUser(); - - // Is this user a Platform Administrator? - $platformAdmin = $this->RegistryAuth->isPlatformAdmin(); - - // Is this user a CO Administrator? - $coAdmin = $this->RegistryAuth->isCoAdmin($this->getCOID()); - - // Is this record read only? - $readOnly = false; - - // Can this record be deleted? - $canDelete = true; - - // Pull the controller permissions - $permissions = $table->getPermissions(); - - 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)) { - foreach($roles as $role) { - // eg: $role = "platformAdmin", which corresponds to the variables set, above - if($$role) { - $ok = true; - break; - } - } - } - } - - $ret[$action] = $ok; - } - - if(!empty($permissions['related'])) { - foreach($permissions['related'] as $rtable) { - $rpermissions = $table->$rtable->getPermissions(); - - foreach($rpermissions['table'] as $action => $roles) { - $ok = false; - - if(is_array($roles)) { - foreach($roles as $role) { - // eg: $role = "platformAdmin", which corresponds to the variables set, above - if($$role) { - $ok = true; - break; - } - } - } - - $ret[$rtable][$action] = $ok; - } - } - } - } else { - // Permissions for actions that operate over tables - - foreach($permissions['table'] as $action => $roles) { - $ok = false; - - if(is_array($roles)) { - foreach($roles as $role) { - // eg: $role = "platformAdmin", which corresponds to the variables set, above - if($$role) { - $ok = true; - break; - } - } - } - - $ret[$action] = $ok; - } - } - - return $ret; - } - /** * Get the current CO. * @@ -620,7 +497,6 @@ protected function setCO() { if($this->cur_co->status === TemplateableStatusEnum::Active) { $this->set('vv_cur_co', $this->cur_co); - } // We store the CO ID in Configuration to facilitate its access from diff --git a/app/src/Controller/AuthenticationEventsController.php b/app/src/Controller/AuthenticationEventsController.php new file mode 100644 index 000000000..5900ec234 --- /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/Component/RegistryAuthComponent.php b/app/src/Controller/Component/RegistryAuthComponent.php index 0221846ae..bdf87b51c 100644 --- a/app/src/Controller/Component/RegistryAuthComponent.php +++ b/app/src/Controller/Component/RegistryAuthComponent.php @@ -57,8 +57,9 @@ use \Cake\Http\Exception\UnauthorizedException; use \Cake\ORM\ResultSet; use \Cake\ORM\TableRegistry; -use App\Lib\Enum\SuspendableStatusEnum; -use App\Lib\Enum\TemplateableStatusEnum; +use \App\Lib\Enum\AuthenticationEventEnum; +use \App\Lib\Enum\SuspendableStatusEnum; +use \App\Lib\Enum\TemplateableStatusEnum; class RegistryAuthComponent extends Component { @@ -133,8 +134,15 @@ public function beforeFilter(EventInterface $event) { try { if($this->authenticateApiUser()) { - if($this->calculatePermission($request->getParam('action'), $id)) { + if($this->calculatePermission(action: $request->getParam('action'), id: $id)) { // Authorization successful + + $AuthenticationEvents = TableRegistry::getTableLocator()->get('AuthenticationEvents'); + + $AuthenticationEvents->record(identifier: $this->authenticatedUser, + eventType: AuthenticationEventEnum::ApiLogin, + remoteIp: $_SERVER['REMOTE_ADDR']); + return true; } } @@ -216,10 +224,8 @@ public function beforeFilter(EventInterface $event) { */ protected function calculatePermission(string $action, ?int $id=null): bool { - $controller = $this->_registry->getController(); - - $perms = $controller->calculatePermissions($id); - + $perms = $this->calculatePermissions($id); + if(!isset($perms[$action])) { throw new UnauthorizedException('Invalid Request (RegistryAuthComponent)'); } @@ -227,6 +233,147 @@ protected function calculatePermission(string $action, ?int $id=null): bool { 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->name = Models (ie: from ModelsTable) + $modelsName = $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. * @@ -236,8 +383,6 @@ protected function calculatePermission(string $action, ?int $id=null): bool { */ public function calculatePermissionsForResultSet(ResultSet $rs): array { - $controller = $this->_registry->getController(); - // We return an array since this is intended to be passed to a view $ret = []; @@ -248,7 +393,7 @@ public function calculatePermissionsForResultSet(ResultSet $rs): array { while($rs->valid()) { $o = $rs->current(); - $ret[ $o->id ] = $controller->calculatePermissions($o->id); + $ret[ $o->id ] = $this->calculatePermissions($o->id); $rs->next(); } @@ -266,9 +411,7 @@ public function calculatePermissionsForResultSet(ResultSet $rs): array { */ public function calculatePermissionsForView(string $action, ?int $id=null): array { - $controller = $this->_registry->getController(); - - return $controller->calculatePermissions($id); + return $this->calculatePermissions($id); } /** @@ -311,6 +454,79 @@ public function getMenuPermissions(?int $coId): array { 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. * @@ -338,7 +554,7 @@ public function isCoAdmin(?int $coId): bool { return false; } - if(!isset($this->cache['isCoAdmin'])) { + if(!isset($this->cache['isCoAdmin'][$coId])) { $this->cache['isCoAdmin'][$coId] = false; if($this->authenticatedApiUser) { @@ -357,6 +573,59 @@ public function isCoAdmin(?int $coId): bool { 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->authenticateApiUser, + 'ApiUsers.co_id' => $coId, + 'ApiUsers.status' => SuspendableStatusEnum::Active + ]) + ->contain() + ->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. * @@ -385,7 +654,7 @@ protected function isIdentifierAdmin(string $identifier, int $coId): bool { foreach($identifiers as $i) { // Both the Person and the CO must be active - if($i->person->isActive() + 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 diff --git a/app/src/Controller/MVEAController.php b/app/src/Controller/MVEAController.php index a05490bcc..443814142 100644 --- a/app/src/Controller/MVEAController.php +++ b/app/src/Controller/MVEAController.php @@ -1,6 +1,6 @@ getPrimaryLink(true); if(!empty($link->value)) { + $this->set('vv_primary_link_attr', $link->attr); $this->set('vv_primary_link_id', $link->value); $Names = $this->getTableLocator()->get('Names'); diff --git a/app/src/Controller/StandardController.php b/app/src/Controller/StandardController.php index 280e311cf..9597e105e 100644 --- a/app/src/Controller/StandardController.php +++ b/app/src/Controller/StandardController.php @@ -462,6 +462,18 @@ public function index() { } } + // 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()); + } + } + // The Cake documents describe $this->paginate (which worked in Cake 2), // but it doesn't seem to work in Cake 4. So we just use $this->pagination // ourselves here. 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/Lib/Enum/AuthenticationEventEnum.php b/app/src/Lib/Enum/AuthenticationEventEnum.php new file mode 100644 index 000000000..9c49dd37b --- /dev/null +++ b/app/src/Lib/Enum/AuthenticationEventEnum.php @@ -0,0 +1,35 @@ +permissions; } @@ -51,7 +51,7 @@ public function getPermissions() { * @param array $vars Array of permissions */ - public function setPermissions(array $perms) { + public function setPermissions(array|\Closure $perms) { $this->permissions = $perms; } } diff --git a/app/src/Lib/Traits/QueryModificationTrait.php b/app/src/Lib/Traits/QueryModificationTrait.php index 4f74664a8..acdd704bb 100644 --- a/app/src/Lib/Traits/QueryModificationTrait.php +++ b/app/src/Lib/Traits/QueryModificationTrait.php @@ -39,6 +39,9 @@ trait QueryModificationTrait { // Containable models for index actions private $indexContains = null; + // Filter (where clause) for index actions + private $indexFilter = null; + // Array of associated models to save during a patch private $patchAssociated = []; @@ -78,6 +81,17 @@ 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. * @@ -133,6 +147,17 @@ 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. * diff --git a/app/src/Lib/Traits/ValidationTrait.php b/app/src/Lib/Traits/ValidationTrait.php index 9b25b7f68..d3a3862cc 100644 --- a/app/src/Lib/Traits/ValidationTrait.php +++ b/app/src/Lib/Traits/ValidationTrait.php @@ -50,14 +50,15 @@ public function registerPrimaryKeyValidation(Validator $validator, array $primar 'content' => ['rule' => 'isInteger'] ]); $validator->notEmptyString($pk, null, function($context) use ($pk, $primaryKeys) { - // This primary key must be populated if all other primary keys are empty + // 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 $othersEmpty; }); } diff --git a/app/src/Lib/Util/StringUtilities.php b/app/src/Lib/Util/StringUtilities.php new file mode 100644 index 000000000..45ddaf0e0 --- /dev/null +++ b/app/src/Lib/Util/StringUtilities.php @@ -0,0 +1,72 @@ + 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/Identifier.php b/app/src/Model/Entity/Identifier.php index df9af491d..da736bf40 100644 --- a/app/src/Model/Entity/Identifier.php +++ b/app/src/Model/Entity/Identifier.php @@ -40,4 +40,15 @@ class Identifier extends Entity { '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; + } } \ 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..5dbd5144b --- /dev/null +++ b/app/src/Model/Table/AuthenticationEventsTable.php @@ -0,0 +1,181 @@ +addBehavior('Log'); + $this->addBehavior('Timestamp'); + + // Authentication Events are not configuration + $this->setIsConfigurationTable(false); + + // 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/CosTable.php b/app/src/Model/Table/CosTable.php index b14db761a..138b924e0 100644 --- a/app/src/Model/Table/CosTable.php +++ b/app/src/Model/Table/CosTable.php @@ -193,8 +193,11 @@ public function getCosForIdentifier(string $loginIdentifier): array { // 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 - if($i->person->isActive() + // Both the Person and the CO must be active. Note that there may be an + // Active Identifier pointing to a Deleted 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; diff --git a/app/src/Model/Table/DashboardsTable.php b/app/src/Model/Table/DashboardsTable.php index 55b45fed8..58a9cac41 100644 --- a/app/src/Model/Table/DashboardsTable.php +++ b/app/src/Model/Table/DashboardsTable.php @@ -73,10 +73,9 @@ public function initialize(array $config): void { // Actions that operate over a table (ie: do not require an $id) 'table' => [ 'configuration' => ['platformAdmin', 'coAdmin'], - 'dashboard' => ['platformAdmin', 'coAdmin'] // XXX this is not the correct long term permission + 'dashboard' => ['coMember'] /* 'add' => ['platformAdmin', 'coAdmin'], - 'index' => ['platformAdmin', 'coAdmin'] - */ + 'index' => ['platformAdmin', 'coAdmin']*/ ] ]); } diff --git a/app/src/Model/Table/GroupsTable.php b/app/src/Model/Table/GroupsTable.php index 0d3a72815..353a7cb8c 100644 --- a/app/src/Model/Table/GroupsTable.php +++ b/app/src/Model/Table/GroupsTable.php @@ -274,6 +274,7 @@ public function buildRules(RulesChecker $rules): RulesChecker { 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 ]); diff --git a/app/src/Model/Table/HistoryRecordsTable.php b/app/src/Model/Table/HistoryRecordsTable.php index 027ea79bd..f5e0345c6 100644 --- a/app/src/Model/Table/HistoryRecordsTable.php +++ b/app/src/Model/Table/HistoryRecordsTable.php @@ -175,7 +175,7 @@ public function recordForGroup(int $groupId, * @return int History Record ID */ - public function recordForPerson(int $personId, + public function recordForPerson(?int $personId, string $action, string $comment, ?int $personRoleId=null, diff --git a/app/src/Model/Table/IdentifiersTable.php b/app/src/Model/Table/IdentifiersTable.php index fb098453c..be73002d9 100644 --- a/app/src/Model/Table/IdentifiersTable.php +++ b/app/src/Model/Table/IdentifiersTable.php @@ -121,6 +121,10 @@ public function initialize(array $config): void { 'table' => [ 'add' => ['platformAdmin', 'coAdmin'], 'index' => ['platformAdmin', 'coAdmin'] + ], + // Related models whose permissions we'll need, typically for table views + 'related' => [ + 'AuthenticationEvents' ] ]); } @@ -172,6 +176,18 @@ public function validationDefault(Validator $validator): Validator { $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', [ diff --git a/app/templates/AuthenticationEvents/columns.inc b/app/templates/AuthenticationEvents/columns.inc new file mode 100644 index 000000000..c9335dac7 --- /dev/null +++ b/app/templates/AuthenticationEvents/columns.inc @@ -0,0 +1,45 @@ + [ + 'type' => 'echo' + ], + 'authenticated_identifier' => [ + 'type' => 'echo' + ], + 'authentication_event' => [ + 'type' => 'enum', + 'class' => 'AuthenticationEventEnum' + ], + 'created' => [ + 'type' => 'datetime' + ], + 'remote_ip' => [ + 'type' => 'echo' + ] +]; diff --git a/app/templates/Identifiers/columns.inc b/app/templates/Identifiers/columns.inc index 1d1bac086..7f6b88ecc 100644 --- a/app/templates/Identifiers/columns.inc +++ b/app/templates/Identifiers/columns.inc @@ -33,3 +33,18 @@ $indexColumns = [ 'type' => 'fk' ] ]; + +$indexActions = [ + [ + 'controller' => 'authentication_events', + 'action' => 'index', + 'icon' => 'person', + 'if' => 'isLogin', + 'query' => function ($e) { + return [ + 'authenticated_identifier' => + \App\Lib\Util\StringUtilities::urlbase64encode($e->identifier) + ]; + } + ] +]; \ No newline at end of file diff --git a/app/templates/Identifiers/fields.inc b/app/templates/Identifiers/fields.inc index 9b95ac46f..17f7ea380 100644 --- a/app/templates/Identifiers/fields.inc +++ b/app/templates/Identifiers/fields.inc @@ -31,7 +31,12 @@ if($vv_action == 'add' || $vv_action == 'edit') { print $this->Field->control('type_id', ['default' => $vv_default_type]); - print $this->Field->control('login'); + if($vv_primary_link_attr == 'person_id') { + // AR-Identifier-1 Only Persons can have a login flag + print $this->Field->control('login'); + } else { + $hidden['login'] = false; + } print $this->Field->control('status', ['empty' => false]); } diff --git a/app/templates/Standard/api/v2/json/index.php b/app/templates/Standard/api/v2/json/index.php index c4e464f7e..ed6b61990 100644 --- a/app/templates/Standard/api/v2/json/index.php +++ b/app/templates/Standard/api/v2/json/index.php @@ -26,14 +26,13 @@ */ // We use this template for both /view (1 record) and /index (n records) -$action = $this->template; $responseMeta = [ 'resource' => $vv_model_name, 'version' => '2' ]; -if($action == 'index') { +if($this->request->getParam('action') == 'index') { $responseMeta['totalResults'] = $this->Paginator->counter('{{count}}'); $responseMeta['startIndex'] = $this->Paginator->counter('{{start}}'); $responseMeta['itemsPerPage'] = $this->Paginator->counter('{{current}}'); // confusingly this is different than ->current() diff --git a/app/templates/Standard/index.php b/app/templates/Standard/index.php index 582d498e7..3f9f3084a 100644 --- a/app/templates/Standard/index.php +++ b/app/templates/Standard/index.php @@ -473,7 +473,8 @@ function _column_key($modelsName, $c, $tz=null) { $actionUrl = $this->Url->build( ['controller' => $a['controller'], 'action' => $a['action'], - '?' => [ $tableFK => $entity->id] ] + // We support the use of closures for custom query strings + '?' => (!empty($a['query']) ? $a['query']($entity) : [ $tableFK => $entity->id ])] ); } else { $actionLabel = __d('operation', $a['action']); diff --git a/app/webroot/auth/login/login.php b/app/webroot/auth/login/login.php index a53afe51d..2bf19763e 100644 --- a/app/webroot/auth/login/login.php +++ b/app/webroot/auth/login/login.php @@ -42,10 +42,13 @@ } $_SESSION['Auth']['external']['user'] = $_SERVER['REMOTE_USER']; -$target = $_SESSION['Auth']['target'] ?? "/"; + +// After storing the user in the session, we redirect into the TrafficController +// which handles the rest of the login process. To construct the URL, we look at +// REQUEST_URI to figure out what our application prefix is (eg: /registry). $re = '/(.*)\/auth\/login\/login(?:.php)?(.*)/m'; -$subst = '$1' . $target . '$2'; +$subst = '$1' . '/traffic/process-login' . '$2'; $path = preg_replace($re, $subst, urldecode($_SERVER['REQUEST_URI']), 1); -header("Location: " . $path); +header("Location: " . $path); \ No newline at end of file