From f48b0e8a11e973b28254bc81e5608d150b7c04f4 Mon Sep 17 00:00:00 2001 From: Lukas Metzger Date: Thu, 5 Apr 2018 16:23:55 +0200 Subject: [PATCH] Added GET /users --- backend/src/controllers/Users.php | 230 +++++++++++++++++++++++ backend/src/operations/Users.php | 298 ++++++++++++++++++++++++++++++ backend/src/public/index.php | 2 + backend/test/tests/users-get.js | 137 ++++++++++++++ 4 files changed, 667 insertions(+) create mode 100644 backend/src/controllers/Users.php create mode 100644 backend/src/operations/Users.php create mode 100644 backend/test/tests/users-get.js diff --git a/backend/src/controllers/Users.php b/backend/src/controllers/Users.php new file mode 100644 index 0000000..c7dc1f0 --- /dev/null +++ b/backend/src/controllers/Users.php @@ -0,0 +1,230 @@ +logger = $c->logger; + $this->c = $c; + } + + public function getList(Request $req, Response $res, array $args) + { + $ac = new \Operations\AccessControl($this->c); + if (!$ac->isAdmin($req->getAttribute('userId'))) { + $this->logger->info('Non admin user tries to get users'); + return $res->withJson(['error' => 'You must be admin to use this feature'], 403); + } + + $users = new \Operations\Users($this->c); + + $paging = new \Utils\PagingInfo($req->getQueryParam('page'), $req->getQueryParam('pagesize')); + $query = $req->getQueryParam('query'); + $sort = $req->getQueryParam('sort'); + $type = $req->getQueryParam('type'); + + $results = $users->getUsers($paging, $query, $type, $sort); + + return $res->withJson([ + 'paging' => $paging->toArray(), + 'results' => $results + ], 200); + } + + public function postNew(Request $req, Response $res, array $args) + { + $ac = new \Operations\AccessControl($this->c); + if (!$ac->isAdmin($req->getAttribute('userId'))) { + $this->logger->info('Non admin user tries to add domain'); + return $res->withJson(['error' => 'You must be admin to use this feature'], 403); + } + + $body = $req->getParsedBody(); + + if (!array_key_exists('name', $body) || + !array_key_exists('type', $body) || ($body['type'] === 'SLAVE' && !array_key_exists('master', $body))) { + $this->logger->debug('One of the required fields is missing'); + return $res->withJson(['error' => 'One of the required fields is missing'], 422); + } + + $name = $body['name']; + $type = $body['type']; + $master = isset($body['master']) ? $body['master'] : null; + + $domains = new \Operations\Domains($this->c); + + try { + $result = $domains->addDomain($name, $type, $master); + + $this->logger->info('Created domain', $result); + return $res->withJson($result, 201); + } catch (\Exceptions\AlreadyExistentException $e) { + $this->logger->debug('Zone with name ' . $name . ' already exists.'); + return $res->withJson(['error' => 'Zone with name ' . $name . ' already exists.'], 409); + } catch (\Exceptions\SemanticException $e) { + $this->logger->info('Invalid type for new domain', ['type' => $type]); + return $res->withJson(['error' => 'Invalid type allowed are MASTER, NATIVE and SLAVE'], 400); + } + } + + public function delete(Request $req, Response $res, array $args) + { + $ac = new \Operations\AccessControl($this->c); + if (!$ac->isAdmin($req->getAttribute('userId'))) { + $this->logger->info('Non admin user tries to delete domain'); + return $res->withJson(['error' => 'You must be admin to use this feature'], 403); + } + + $domains = new \Operations\Domains($this->c); + + $domainId = intval($args['domainId']); + + try { + $domains->deleteDomain($domainId); + + $this->logger->info('Deleted domain', ['id' => $domainId]); + return $res->withStatus(204); + } catch (\Exceptions\NotFoundException $e) { + return $res->withJson(['error' => 'No domain found for id ' . $domainId], 404); + } + } + + public function getSingle(Request $req, Response $res, array $args) + { + $userId = $req->getAttribute('userId'); + $domainId = intval($args['domainId']); + + $ac = new \Operations\AccessControl($this->c); + if (!$ac->canAccessDomain($userId, $domainId)) { + $this->logger->info('Non admin user tries to get domain without permission.'); + return $res->withJson(['error' => 'You have no permissions for this domain.'], 403); + } + + $domains = new \Operations\Domains($this->c); + + try { + $result = $domains->getDomain($domainId); + + $this->logger->debug('Get domain info', ['id' => $domainId]); + return $res->withJson($result, 200); + } catch (\Exceptions\NotFoundException $e) { + return $res->withJson(['error' => 'No domain found for id ' . $domainId], 404); + } + } + + public function put(Request $req, Response $res, array $args) + { + $ac = new \Operations\AccessControl($this->c); + if (!$ac->isAdmin($req->getAttribute('userId'))) { + $this->logger->info('Non admin user tries to delete domain'); + return $res->withJson(['error' => 'You must be admin to use this feature'], 403); + } + + $body = $req->getParsedBody(); + + if (!array_key_exists('master', $body)) { + $this->logger->debug('One of the required fields is missing'); + return $res->withJson(['error' => 'One of the required fields is missing'], 422); + } + + $domainId = $args['domainId']; + $master = $body['master']; + + $domains = new \Operations\Domains($this->c); + + try { + $result = $domains->updateSlave($domainId, $master); + + $this->logger->debug('Update master', ['id' => $domainId]); + return $res->withStatus(204); + } catch (\Exceptions\NotFoundException $e) { + $this->logger->debug('Trying to update non existing slave zone', ['id' => $domainId]); + return $res->withJson(['error' => 'No domain found for id ' . $domainId], 404); + } catch (\Exceptions\SemanticException $e) { + $this->logger->debug('Trying to update non slave zone', ['id' => $domainId]); + return $res->withJson(['error' => 'Domain is not a slave zone'], 405); + } + } + + public function putSoa(Request $req, Response $res, array $args) + { + $userId = $req->getAttribute('userId'); + $domainId = $args['domainId']; + + $ac = new \Operations\AccessControl($this->c); + if (!$ac->canAccessDomain($userId, $domainId)) { + $this->logger->info('Non admin user tries to get domain without permission.'); + return $res->withJson(['error' => 'You have no permissions for this domain.'], 403); + } + + $body = $req->getParsedBody(); + + if (!array_key_exists('primary', $body) || + !array_key_exists('email', $body) || + !array_key_exists('refresh', $body) || + !array_key_exists('retry', $body) || + !array_key_exists('expire', $body) || + !array_key_exists('ttl', $body)) { + $this->logger->debug('One of the required fields is missing'); + return $res->withJson(['error' => 'One of the required fields is missing'], 422); + } + + $soa = new \Operations\Soa($this->c); + + try { + $soa->setSoa( + intval($domainId), + $body['email'], + $body['primary'], + intval($body['refresh']), + intval($body['retry']), + intval($body['expire']), + intval($body['ttl']) + ); + + return $res->withStatus(204); + } catch (\Exceptions\NotFoundException $e) { + $this->logger->warning('Trying to set soa for not existing domain.', ['domainId' => $domainId]); + return $res->withJson(['error' => 'No domain found for id ' . $domainId], 404); + } catch (\Exceptions\SemanticException $e) { + $this->logger->warning('Trying to set soa for slave domain.', ['domainId' => $domainId]); + return $res->withJson(['error' => 'SOA can not be set for slave domains'], 405); + } + } + + public function getSoa(Request $req, Response $res, array $args) + { + $userId = $req->getAttribute('userId'); + $domainId = $args['domainId']; + + $ac = new \Operations\AccessControl($this->c); + if (!$ac->canAccessDomain($userId, $domainId)) { + $this->logger->info('Non admin user tries to get domain without permission.'); + return $res->withJson(['error' => 'You have no permissions for this domain.'], 403); + } + + $soa = new \Operations\Soa($this->c); + + try { + $soaArray = $soa->getSoa($domainId); + + return $res->withJson($soaArray, 200); + } catch (\Exceptions\NotFoundException $e) { + $this->logger->debug('User tried to get non existing soa.', ['domainId' => $domainId]); + return $res->withJson(['error' => 'This domain has no soa record.'], 404); + } + } +} diff --git a/backend/src/operations/Users.php b/backend/src/operations/Users.php new file mode 100644 index 0000000..d24c6f9 --- /dev/null +++ b/backend/src/operations/Users.php @@ -0,0 +1,298 @@ +logger = $c->logger; + $this->db = $c->db; + $this->c = $c; + } + + /** + * Get a list of users according to filter criteria + * + * @param $pi PageInfo object, which is also updated with total page number + * @param $nameQuery Search query, may be null + * @param $type Type of the user, comma separated, null for no filter + * @param $sorting Sort string in format 'field-asc,field2-desc', null for default + * + * @return array Array with matching users + */ + public function getUsers(\Utils\PagingInfo &$pi, ? string $nameQuery, ? string $type, ? string $sorting) : array + { + $config = $this->c['config']['authentication']; + + $this->db->beginTransaction(); + + $nameQuery = $nameQuery !== null ? '%' . $nameQuery . '%' : '%'; + + //Count elements + if ($pi->pageSize === null) { + $pi->totalPages = 1; + } else { + $query = $this->db->prepare(' + SELECT COUNT(*) AS total + FROM users U + WHERE (U.name LIKE :nameQuery) AND + (U.type IN ' . \Services\Database::makeSetString($this->db, $type) . ' OR :noTypeFilter) + '); + + $query->bindValue(':nameQuery', $nameQuery, \PDO::PARAM_STR); + $query->bindValue(':noTypeFilter', intval($type === null), \PDO::PARAM_INT); + + $query->execute(); + $record = $query->fetch(); + + $pi->totalPages = ceil($record['total'] / $pi->pageSize); + } + + //Query and return result + $ordStr = \Services\Database::makeSortingString($sorting, [ + 'id' => 'U.id', + 'name' => 'U.name', + 'type' => 'U.type' + ]); + $pageStr = \Services\Database::makePagingString($pi); + + $query = $this->db->prepare(' + SELECT id, name, type, backend + FROM users U + WHERE (U.name LIKE :nameQuery) AND + (U.type IN ' . \Services\Database::makeSetString($this->db, $type) . ' OR :noTypeFilter)' + . $ordStr . $pageStr); + + $query->bindValue(':nameQuery', $nameQuery, \PDO::PARAM_STR); + $query->bindValue(':noTypeFilter', intval($type === null), \PDO::PARAM_INT); + + $query->execute(); + + $data = $query->fetchAll(); + + $this->db->commit(); + + $dataTransformed = array_map( + function ($item) use ($config) { + if (!array_key_exists($item['backend'], $config)) { + return null; + } + if (!array_key_exists('prefix', $config[$item['backend']])) { + return null; + } + + $prefix = $config[$item['backend']]['prefix']; + + if ($prefix === 'default') { + $name = $item['name']; + } else { + $name = $prefix . '/' . $item['name']; + } + + return [ + 'id' => intval($item['id']), + 'name' => $name, + 'type' => $item['type'], + 'native' => $item['backend'] === 'native' + ]; + }, + $data + ); + + return array_filter($dataTransformed, function ($v) { + return $v !== null; + }); + } + + /** + * Add new domain + * + * @param $name Name of the new zone + * @param $type Type of the new zone + * @param $master Master for slave zones, otherwise null + * + * @return array New domain entry + * + * @throws AlreadyExistenException it the domain exists already + */ + public function addDomain(string $name, string $type, ? string $master) : array + { + if (!in_array($type, [' MASTER ', ' SLAVE ', ' NATIVE '])) { + throw new \Exceptions\SemanticException(); + } + + $this->db->beginTransaction(); + + $query = $this->db->prepare(' SELECT id FROM domains WHERE name = : name '); + $query->bindValue(' : name ', $name, \PDO::PARAM_STR); + $query->execute(); + + $record = $query->fetch(); + + if ($record !== false) { // Domain already exists + $this->db->rollBack(); + throw new \Exceptions\AlreadyExistentException(); + } + + if ($type === ' SLAVE ') { + $query = $this->db->prepare(' INSERT INTO domains (name, type, master) VALUES(: name, : type, : master) '); + $query->bindValue(' : master ', $master, \PDO::PARAM_STR); + } else { + $query = $this->db->prepare(' INSERT INTO domains (name, type) VALUES (: name, : type) '); + } + $query->bindValue(' : name ', $name, \PDO::PARAM_STR); + $query->bindValue(' : type ', $type, \PDO::PARAM_STR); + $query->execute(); + + + $query = $this->db->prepare(' SELECT id, name, type, master FROM domains WHERE name = : name '); + $query->bindValue(' : name ', $name, \PDO::PARAM_STR); + $query->execute(); + + $record = $query->fetch(); + $record[' id '] = intval($record[' id ']); + if ($type !== ' SLAVE ') { + unset($record[' master ']); + } + + $this->db->commit(); + + return $record; + } + + /** + * Delete domain + * + * @param $id Id of the domain to delete + * + * @return void + * + * @throws NotFoundException if domain does not exist + */ + public function deleteDomain(int $id) : void + { + $this->db->beginTransaction(); + + $query = $this->db->prepare(' SELECT id FROM domains WHERE id = : id '); + $query->bindValue(' : id ', $id, \PDO::PARAM_INT); + $query->execute(); + + if ($query->fetch() === false) { //Domain does not exist + $this->db->rollBack(); + throw new \Exceptions\NotFoundException(); + } + + $query = $this->db->prepare(' + DELETE E FROM remote E + LEFT OUTER JOIN records R ON R . id = E . record + WHERE R . domain_id = : id '); + $query->bindValue(' : id ', $id, \PDO::PARAM_INT); + $query->execute(); + + $query = $this->db->prepare(' DELETE FROM records WHERE domain_id = : id '); + $query->bindValue(' : id ', $id, \PDO::PARAM_INT); + $query->execute(); + + $query = $this->db->prepare(' DELETE FROM domains WHERE id = : id '); + $query->bindValue(' : id ', $id, \PDO::PARAM_INT); + $query->execute(); + + $this->db->commit(); + } + + /** + * Get domain + * + * @param $id Id of the domain to get + * + * @return array Domain data + * + * @throws NotFoundException if domain does not exist + */ + public function getDomain(int $id) : array + { + $query = $this->db->prepare(' + SELECT D . id, D . name, D . type, D . master, COUNT (R . domain_id) as records FROM domains D + LEFT OUTER JOIN records R ON D . id = R . domain_id + WHERE D . id = : id + GROUP BY D . id, D . name, D . type, D . master + '); + $query->bindValue(' : id ', $id, \PDO::PARAM_INT); + $query->execute(); + + $record = $query->fetch(); + + if ($record === false) { + throw new \Exceptions\NotFoundException(); + } + + $record[' id '] = intval($record[' id ']); + $record[' records '] = intval($record[' records ']); + if ($record[' type '] !== ' SLAVE ') { + unset($record[' master ']); + } + + return $record; + } + + /** + * Get type of given domain + * + * @param int Domain id + * + * @return string Domain type + * + * @throws NotFoundException if domain does not exist + */ + public function getDomainType(int $id) : string + { + $query = $this->db->prepare(' SELECT type FROM domains WHERE id = : id '); + $query->bindValue(' : id ', $id, \PDO::PARAM_INT); + $query->execute(); + $record = $query->fetch(); + + if ($record === false) { + throw new \Exceptions\NotFoundException(); + } + + return $record[' type ']; + } + + /** + * Update master for slave zone + * + * @param int Domain id + * @param string New master + * + * @return void + * + * @throws NotFoundException if domain does not exist + * @throws SemanticException if domain is no slave zone + */ + public function updateSlave(int $id, string $master) + { + if ($this->getDomainType($id) !== ' SLAVE ') { + throw new \Exceptions\SemanticException(); + } + + $query = $this->db->prepare(' UPDATE domains SET master = : master WHERE id = : id '); + $query->bindValue(' : id ', $id, \PDO::PARAM_INT); + $query->bindValue(' : master', $master, \PDO::PARAM_STR); + $query->execute(); + } +} diff --git a/backend/src/public/index.php b/backend/src/public/index.php index 4dd1c8f..d2ffbda 100644 --- a/backend/src/public/index.php +++ b/backend/src/public/index.php @@ -48,6 +48,8 @@ $app->group('/v1', function () { $this->get('/records/{recordId}/credentials/{credentialId}', '\Controllers\Credentials:getSingle'); $this->put('/records/{recordId}/credentials/{credentialId}', '\Controllers\Credentials:put'); + $this->get('/users', '\Controllers\Users:getList'); + $this->get('/users/{user}/permissions', '\Controllers\Permissions:getList'); $this->post('/users/{user}/permissions', '\Controllers\Permissions:postNew'); $this->delete('/users/{user}/permissions/{domainId}', '\Controllers\Permissions:delete'); diff --git a/backend/test/tests/users-get.js b/backend/test/tests/users-get.js new file mode 100644 index 0000000..64668eb --- /dev/null +++ b/backend/test/tests/users-get.js @@ -0,0 +1,137 @@ +const test = require('../testlib'); +const cartesianProduct = require('cartesian-product'); + +test.run(async function () { + await test('admin', async function (assert, req) { + //Test sorting in all combinations + const sortCombinations = cartesianProduct([ + ['', 'id-asc', 'id-desc'], + ['', 'name-asc', 'name-desc'], + ['', 'type-asc', 'type-desc'], + ]); + + for (list of sortCombinations) { + list = list.filter((str) => str.length > 0); + var sortQuery = list.join(','); + + var res = await req({ + url: '/users?sort=' + sortQuery, + method: 'get' + }); + + assert.equal(res.status, 200); + + var sortedData = res.data.results.slice(); + sortedData.sort(function (a, b) { + for (sort of list) { + var spec = sort.split('-'); + if (a[spec[0]] < b[spec[0]]) { + return spec[1] == 'asc' ? -1 : 1; + } else if (a[spec[0]] > b[spec[0]]) { + return spec[1] == 'asc' ? 1 : -1; + } + } + return 0; + }); + + assert.equal(res.data.results, sortedData, 'Sort failed for ' + res.config.url); + } + + //Test paging + var res = await req({ + url: '/users?pagesize=2', + method: 'get' + }); + + assert.equal(res.status, 200, 'Status should be OK'); + assert.equal(res.data.paging, { + page: 1, + total: 2, + pagesize: 2 + }, 'Paging data fail for ' + res.config.url); + assert.equal(res.data.results.length, 2, "Should be 2 results."); + + var res = await req({ + url: '/users?pagesize=2&page=2', + method: 'get' + }); + + assert.equal(res.status, 200, 'Status should be OK'); + assert.equal(res.data.paging, { + page: 2, + total: 2, + pagesize: 2 + }, 'Paging data fail for ' + res.config.url); + assert.equal(res.data.results.length, 1, "Should be 2 results."); + + //Test query name + var res = await req({ + url: '/users?query=user&sort=id-asc', + method: 'get' + }); + + assert.equal(res.status, 200, 'Status should be OK'); + assert.equal(res.data.results, [ + { + id: 2, + name: 'user', + type: 'user', + native: true + }, + { + id: 3, + name: 'config/configuser', + type: 'user', + native: false + } + ], 'Result fail for ' + res.config.url); + + //Type filter + var res = await req({ + url: '/users?type=admin,user', + method: 'get' + }); + + assert.equal(res.status, 200, 'Status should be OK'); + assert.equal(res.data.results.length, 3, 'Result fail for ' + res.config.url); + + //Type filter + var res = await req({ + url: '/users?type=admin', + method: 'get' + }); + + assert.equal(res.status, 200, 'Status should be OK'); + assert.equal(res.data.results, [ + { + id: 1, + name: 'admin', + type: 'admin', + native: true + } + ], 'Result fail for ' + res.config.url); + + //Query all check for format + var res = await req({ + url: '/users?sort=id-asc', + method: 'get' + }); + + assert.equal(res.status, 200, 'Status should be OK'); + assert.equal(res.data.results, [ + { id: 1, name: 'admin', type: 'admin', native: true }, + { id: 2, name: 'user', type: 'user', native: true }, + { id: 3, name: 'config/configuser', type: 'user', native: false } + ], 'Result fail for ' + res.config.url); + }); + + await test('user', async function (assert, req) { + //Type filter + var res = await req({ + url: '/users', + method: 'get' + }); + + assert.equal(res.status, 403, 'Get should fail for user'); + }); +}); \ No newline at end of file