From 2f41db98e9af5d494f8363b1c3a8fec10cdbf5e0 Mon Sep 17 00:00:00 2001 From: Lukas Metzger Date: Fri, 30 Mar 2018 14:02:32 +0200 Subject: [PATCH] Implemented als CRUD operations for /records --- backend/src/config/ConfigDefault.php | 10 + backend/src/controllers/Records.php | 113 +++++++++ backend/src/operations/AccessControl.php | 32 +++ backend/src/operations/Records.php | 172 +++++++++++++- backend/src/public/index.php | 4 + backend/test/db.sql | 2 +- backend/test/tests/records-crud.js | 279 +++++++++++++++++++++++ 7 files changed, 610 insertions(+), 2 deletions(-) create mode 100644 backend/test/tests/records-crud.js diff --git a/backend/src/config/ConfigDefault.php b/backend/src/config/ConfigDefault.php index a85f3af..c60896c 100644 --- a/backend/src/config/ConfigDefault.php +++ b/backend/src/config/ConfigDefault.php @@ -22,6 +22,16 @@ $defaultConfig = [ 'plugin' => 'native', 'config' => null ] + ], + 'records' => [ + 'allowedTypes' => [ + 'A', 'A6', 'AAAA', 'AFSDB', 'ALIAS', 'CAA', 'CDNSKEY', 'CDS', 'CERT', 'CNAME', 'DHCID', + 'DLV', 'DNAME', 'DNSKEY', 'DS', 'EUI48', 'EUI64', 'HINFO', + 'IPSECKEY', 'KEY', 'KX', 'LOC', 'MAILA', 'MAILB', 'MINFO', 'MR', + 'MX', 'NAPTR', 'NS', 'NSEC', 'NSEC3', 'NSEC3PARAM', 'OPENPGPKEY', + 'OPT', 'PTR', 'RKEY', 'RP', 'RRSIG', 'SIG', 'SPF', + 'SRV', 'TKEY', 'SSHFP', 'TLSA', 'TSIG', 'TXT', 'WKS', 'MBOXFW', 'URL' + ] ] ]; diff --git a/backend/src/controllers/Records.php b/backend/src/controllers/Records.php index 34e8000..9604901 100644 --- a/backend/src/controllers/Records.php +++ b/backend/src/controllers/Records.php @@ -41,4 +41,117 @@ class Records 'results' => $results ], 200); } + + public function postNew(Request $req, Response $res, array $args) + { + $body = $req->getParsedBody(); + + if (!array_key_exists('name', $body) || + !array_key_exists('type', $body) || + !array_key_exists('content', $body) || + !array_key_exists('priority', $body) || + !array_key_exists('ttl', $body) || + !array_key_exists('domain', $body)) { + $this->logger->debug('One of the required fields is missing'); + return $res->withJson(['error' => 'One of the required fields is missing'], 422); + } + + $userId = $req->getAttribute('userId'); + $ac = new \Operations\AccessControl($this->c); + if (!$ac->canAccessDomain($userId, $body['domain'])) { + $this->logger->info('User tries to add record for domain without permission.'); + return $res->withJson(['error' => 'You have no permissions for the given domain.'], 403); + } + + $records = new \Operations\Records($this->c); + + try { + $result = $records->addRecord($body['name'], $body['type'], $body['content'], $body['priority'], $body['ttl'], $body['domain']); + return $res->withJson($result, 201); + } catch (\Exceptions\NotFoundException $e) { + $this->logger->debug('User tries to add record for invalid domain.'); + return $res->withJson(['error' => 'The domain does not exist or is neighter MASTER nor NATIVE.'], 404); + } catch (\Exceptions\SemanticException $e) { + $this->logger->debug('User tries to add record with invalid type.', ['type' => $type]); + return $res->withJson(['error' => 'The provided type is invalid.'], 400); + } + } + + public function delete(Request $req, Response $res, array $args) + { + $userId = $req->getAttribute('userId'); + $recordId = intval($args['recordId']); + $ac = new \Operations\AccessControl($this->c); + if (!$ac->canAccessRecord($userId, $recordId)) { + $this->logger->info('User tries to delete record without permissions.'); + return $res->withJson(['error' => 'You have no permission to delete this record'], 403); + } + + $records = new \Operations\Records($this->c); + + try { + $records->deleteRecord($recordId); + + $this->logger->info('Deleted record', ['id' => $recordId]); + return $res->withStatus(204); + } catch (\Exceptions\NotFoundException $e) { + return $res->withJson(['error' => 'No record found for id ' . $recordId], 404); + } + } + + public function getSingle(Request $req, Response $res, array $args) + { + $userId = $req->getAttribute('userId'); + $recordId = intval($args['recordId']); + + $ac = new \Operations\AccessControl($this->c); + if (!$ac->canAccessRecord($userId, $recordId)) { + $this->logger->info('Non admin user tries to get record without permission.'); + return $res->withJson(['error' => 'You have no permissions for this record.'], 403); + } + + $records = new \Operations\Records($this->c); + + try { + $result = $records->getRecord($recordId); + + $this->logger->debug('Get record info', ['id' => $recordId]); + return $res->withJson($result, 200); + } catch (\Exceptions\NotFoundException $e) { + return $res->withJson(['error' => 'No record found for id ' . $domainId], 404); + } + } + + public function put(Request $req, Response $res, array $args) + { + $userId = $req->getAttribute('userId'); + $recordId = intval($args['recordId']); + + $ac = new \Operations\AccessControl($this->c); + if (!$ac->canAccessRecord($userId, $recordId)) { + $this->logger->info('Non admin user tries to update record without permission.'); + return $res->withJson(['error' => 'You have no permissions for this record.'], 403); + } + + $body = $req->getParsedBody(); + + $name = array_key_exists('name', $body) ? $body['name'] : null; + $type = array_key_exists('type', $body) ? $body['type'] : null; + $content = array_key_exists('content', $body) ? $body['content'] : null; + $priority = array_key_exists('priority', $body) ? $body['priority'] : null; + $ttl = array_key_exists('ttl', $body) ? $body['ttl'] : null; + + $records = new \Operations\Records($this->c); + + try { + $records->updateRecord($recordId, $name, $type, $content, $priority, $ttl); + return $res->withStatus(204); + } catch (\Exceptions\NotFoundException $e) { + $this->logger->debug('User tries to update not existing record.'); + return $res->withJson(['error' => 'The record does not exist.'], 404); + } catch (\Exceptions\SemanticException $e) { + $this->logger->debug('User tries to update record with invalid type.', ['type' => $type]); + return $res->withJson(['error' => 'The provided type is invalid.'], 400); + } + } } diff --git a/backend/src/operations/AccessControl.php b/backend/src/operations/AccessControl.php index 8ce1607..c9ce1a8 100644 --- a/backend/src/operations/AccessControl.php +++ b/backend/src/operations/AccessControl.php @@ -71,4 +71,36 @@ class AccessControl return true; } } + + /** + * Check if a given user has permissons for a given record. + * + * @param $userId User id of the user + * @param $recordId Record to check + * + * @return bool true if access is granted, false otherwise + */ + public function canAccessRecord(int $userId, int $recordId) : bool + { + if ($this->isAdmin($userId)) { + return true; + } + + $query = $this->db->prepare(' + SELECT * FROM records R + LEFT OUTER JOIN permissions P ON P.domain_id=R.domain_id + WHERE R.id=:recordId AND P.user_id=:userId + '); + $query->bindValue(':userId', $userId, \PDO::PARAM_INT); + $query->bindValue(':recordId', $recordId, \PDO::PARAM_INT); + $query->execute(); + + $record = $query->fetch(); + + if ($record === false) { + return false; + } else { + return true; + } + } } diff --git a/backend/src/operations/Records.php b/backend/src/operations/Records.php index 1dbd2d9..e82540a 100644 --- a/backend/src/operations/Records.php +++ b/backend/src/operations/Records.php @@ -28,7 +28,7 @@ class Records } /** - * Get a list of domains according to filter criteria + * Get a list of records according to filter criteria * * @param $pi PageInfo object, which is also updated with total page number * @param $userId Id of the user for which the table should be retrieved @@ -127,4 +127,174 @@ class Records return $item; }, $data); } + + /** + * Add new record + * + * @param $name Name of the new record + * @param $type Type of the new record + * @param $content Content of the new record + * @param $priority Priority of the new record + * @param $ttl TTL of the new record + * @param $domain Domain id of the domain to add the record + * + * @return array New record entry + * + * @throws NotFoundException if the domain does not exist + * @throws SemanticException if the record type is invalid + */ + public function addRecord(string $name, string $type, string $content, int $priority, int $ttl, int $domain) : array + { + if (!in_array($type, $this->c['config']['records']['allowedTypes'])) { + throw new \Exceptions\SemanticException(); + } + + $this->db->beginTransaction(); + + $query = $this->db->prepare('SELECT id FROM domains WHERE id=:id AND type IN (\'MASTER\',\'NATIVE\')'); + $query->bindValue(':id', $domain, \PDO::PARAM_INT); + $query->execute(); + + $record = $query->fetch(); + + if ($record === false) { // Domain does not exist + $this->db->rollBack(); + throw new \Exceptions\NotFoundException(); + } + + $query = $this->db->prepare('INSERT INTO records (domain_id, name, type, content, ttl, prio, change_date) + VALUES (:domainId, :name, :type, :content, :ttl, :prio, :changeDate)'); + $query->bindValue(':domainId', $domain, \PDO::PARAM_INT); + $query->bindValue(':name', $name, \PDO::PARAM_STR); + $query->bindValue(':type', $type, \PDO::PARAM_STR); + $query->bindValue(':content', $content, \PDO::PARAM_STR); + $query->bindValue(':ttl', $ttl, \PDO::PARAM_INT); + $query->bindValue(':prio', $priority, \PDO::PARAM_INT); + $query->bindValue(':changeDate', time(), \PDO::PARAM_INT); + $query->execute(); + + $query = $this->db->prepare('SELECT id,name,type,content,prio AS priority,ttl,domain_id AS domain FROM records + ORDER BY id DESC LIMIT 1'); + $query->execute(); + + $record = $query->fetch(); + + $record['id'] = intval($record['id']); + $record['priority'] = intval($record['priority']); + $record['ttl'] = intval($record['ttl']); + $record['domain'] = intval($record['domain']); + + $this->db->commit(); + + return $record; + } + + /** + * Delete record + * + * @param $id Id of the record to delete + * + * @return void + * + * @throws NotFoundException if record does not exist + */ + public function deleteRecord(int $id) : void + { + $this->db->beginTransaction(); + + $query = $this->db->prepare('SELECT id FROM records 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 FROM records WHERE id=:id'); + $query->bindValue(':id', $id, \PDO::PARAM_INT); + $query->execute(); + } + + /** + * Get record + * + * @param $recordId Name of the record + * + * @return array Record entry + * + * @throws NotFoundException if the record does not exist + */ + public function getRecord(int $recordId) : array + { + $query = $this->db->prepare('SELECT id,name,type,content,prio AS priority,ttl,domain_id AS domain FROM records + WHERE id=:recordId'); + $query->bindValue(':recordId', $recordId, \PDO::PARAM_INT); + $query->execute(); + + $record = $query->fetch(); + + if ($record === false) { + throw new \Exceptions\NotFoundException(); + } + + $record['id'] = intval($record['id']); + $record['priority'] = intval($record['priority']); + $record['ttl'] = intval($record['ttl']); + $record['domain'] = intval($record['domain']); + + return $record; + } + + /** Update Record + * + * If params are null do not change + * + * @param $recordId Record to update + * @param $name New name + * @param $type New type + * @param $content New content + * @param $priority New priority + * @param $ttl New ttl + * + * @return void + * + * @throws NotFoundException The given record does not exist + * @throws SemanticException The given record type is invalid + */ + public function updateRecord(int $recordId, ? string $name, ? string $type, ? string $content, ? int $priority, ? int $ttl) + { + $this->db->beginTransaction(); + + $query = $this->db->prepare('SELECT id,name,type,content,prio,ttl FROM records WHERE id=:recordId'); + $query->bindValue(':recordId', $recordId); + $query->execute(); + + $record = $query->fetch(); + + if ($record === false) { + $this->db->rollBack(); + throw new \Exceptions\NotFoundException(); + } + + if ($type !== null && !in_array($type, $this->c['config']['records']['allowedTypes'])) { + throw new \Exceptions\SemanticException(); + } + + $name = $name === null ? $record['name'] : $name; + $type = $type === null ? $record['type'] : $type; + $content = $content === null ? $record['content'] : $content; + $priority = $priority === null ? intval($record['prio']) : $priority; + $ttl = $ttl === null ? intval($record['ttl']) : $ttl; + + $query = $this->db->prepare('UPDATE records SET name=:name,type=:type,content=:content,prio=:priority,ttl=:ttl'); + $query->bindValue(':name', $name); + $query->bindValue(':type', $type); + $query->bindValue(':content', $content); + $query->bindValue(':priority', $priority); + $query->bindValue(':ttl', $ttl); + $query->execute(); + + $this->db->commit(); + } } diff --git a/backend/src/public/index.php b/backend/src/public/index.php index d276b12..49dfb1d 100644 --- a/backend/src/public/index.php +++ b/backend/src/public/index.php @@ -37,6 +37,10 @@ $app->group('/v1', function () { $this->get('/domains/{domainId}/soa', '\Controllers\Domains:getSoa'); $this->get('/records', '\Controllers\Records:getList'); + $this->post('/records', '\Controllers\Records:postNew'); + $this->delete('/records/{recordId}', '\Controllers\Records:delete'); + $this->get('/records/{recordId}', '\Controllers\Records:getSingle'); + $this->put('/records/{recordId}', '\Controllers\Records:put'); })->add('\Middlewares\Authentication'); }); diff --git a/backend/test/db.sql b/backend/test/db.sql index 941c3ca..d2ea5e9 100644 --- a/backend/test/db.sql +++ b/backend/test/db.sql @@ -287,7 +287,7 @@ ALTER TABLE `domains` -- AUTO_INCREMENT for table `records` -- ALTER TABLE `records` - MODIFY `id` bigint(20) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=2; + MODIFY `id` bigint(20) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=5; -- -- AUTO_INCREMENT for table `tsigkeys` -- diff --git a/backend/test/tests/records-crud.js b/backend/test/tests/records-crud.js new file mode 100644 index 0000000..11116f5 --- /dev/null +++ b/backend/test/tests/records-crud.js @@ -0,0 +1,279 @@ +const test = require('../testlib'); + +test.run(async function () { + await test('admin', async function (assert, req) { + //Test missing fields + var res = await req({ + url: '/records', + method: 'post', + data: { + name: 'foo.abc.de', + type: 'A' + } + }); + + assert.equal(res.status, 422, 'Missing fields should trigger error.'); + + //Test invalid record type + var res = await req({ + url: '/records', + method: 'post', + data: { + name: "dns.example.com", + type: "FOOBARBAZ", + content: "1.2.3.4", + priority: 0, + ttl: 86400, + domain: 1 + } + }); + + assert.equal(res.status, 400, 'Invalid record type should trigger error.'); + + //Test adding for slave zone + var res = await req({ + url: '/records', + method: 'post', + data: { + name: "dns.example.com", + type: "A", + content: "1.2.3.4", + priority: 0, + ttl: 86400, + domain: 2 + } + }); + + assert.equal(res.status, 404, 'Adding record for slave should trigger error.'); + + //Test adding for not existing zone + var res = await req({ + url: '/records', + method: 'post', + data: { + name: "dns.example.com", + type: "A", + content: "1.2.3.4", + priority: 0, + ttl: 86400, + domain: 100 + } + }); + + assert.equal(res.status, 404, 'Adding record to not existing domain should trigger error.'); + + //Test adding of record + var res = await req({ + url: '/records', + method: 'post', + data: { + name: 'dns.example.com', + type: 'A', + content: '1.2.3.4', + priority: 0, + ttl: 86400, + domain: 1 + } + }); + + assert.equal(res.status, 201, 'Adding of record should succeed.'); + assert.equal(res.data, { + id: 5, + name: 'dns.example.com', + type: 'A', + content: '1.2.3.4', + priority: 0, + ttl: 86400, + domain: 1 + }, 'Adding record return data fail.'); + + //Get not existing record + var res = await req({ + url: '/records/100', + method: 'get' + }); + + assert.equal(res.status, 404, 'Get of not existing record should fail.'); + + //Get created record + var res = await req({ + url: '/records/5', + method: 'get' + }); + + assert.equal(res.status, 200, 'Get of created record should succeed.'); + assert.equal(res.data, { + id: 5, + name: 'dns.example.com', + type: 'A', + content: '1.2.3.4', + priority: 0, + ttl: 86400, + domain: 1 + }, 'Record data should be the same it was created with.'); + + + //Update record + var res = await req({ + url: '/records/5', + method: 'put', + data: { + name: 'foo.example.com' + } + }); + + assert.equal(res.status, 204, 'Updating record should succeed'); + + //Get updated record + var res = await req({ + url: '/records/5', + method: 'get' + }); + + assert.equal(res.status, 200, 'Get updated record should succeed.'); + assert.equal(res.data, { + id: 5, + name: 'foo.example.com', + type: 'A', + content: '1.2.3.4', + priority: 0, + ttl: 86400, + domain: 1 + }, 'Updated record has wrong data.'); + + //Delete not existing record + var res = await req({ + url: '/records/100', + method: 'delete' + }); + + assert.equal(res.status, 404, 'Deletion of not existing record should fail.'); + + //Delete existing record + var res = await req({ + url: '/records/5', + method: 'delete' + }); + + assert.equal(res.status, 204, 'Deletion of existing record should succeed.'); + + }); + + await test('user', async function (assert, req) { + //Test insufficient privileges for add + var res = await req({ + url: '/records', + method: 'post', + data: { + name: 'dns.example.com', + type: 'A', + content: '1.2.3.4', + priority: 0, + ttl: 86400, + domain: 3 + } + }); + + assert.equal(res.status, 403, 'Adding of record should fail for user.'); + + //Test insufficient privileges for delete + var res = await req({ + url: '/records/4', + method: 'delete' + }); + + assert.equal(res.status, 403, 'Deletion of record should fail for user.'); + + //Test insufficient privileges for update + var res = await req({ + url: '/records/4', + method: 'put', + data: { + name: 'foo.example.com', + ttl: 60 + } + }); + + assert.equal(res.status, 403, 'Updating record should succeed'); + + //Test adding of record + var res = await req({ + url: '/records', + method: 'post', + data: { + name: 'dns.example.com', + type: 'A', + content: '1.2.3.4', + priority: 0, + ttl: 86400, + domain: 1 + } + }); + + assert.equal(res.status, 201, 'Adding of record should succeed.'); + assert.equal(res.data, { + id: 6, + name: 'dns.example.com', + type: 'A', + content: '1.2.3.4', + priority: 0, + ttl: 86400, + domain: 1 + }, 'Adding record return data fail.'); + + //Get created record + var res = await req({ + url: '/records/6', + method: 'get' + }); + + assert.equal(res.status, 200, 'Get of created record should succeed.'); + assert.equal(res.data, { + id: 6, + name: 'dns.example.com', + type: 'A', + content: '1.2.3.4', + priority: 0, + ttl: 86400, + domain: 1 + }, 'Record data should be the same it was created with.'); + + + //Update record + var res = await req({ + url: '/records/6', + method: 'put', + data: { + name: 'foo.example.com', + ttl: 60 + } + }); + + assert.equal(res.status, 204, 'Updating record should succeed'); + + //Get updated record + var res = await req({ + url: '/records/6', + method: 'get' + }); + + assert.equal(res.status, 200, 'Get updated record should succeed.'); + assert.equal(res.data, { + id: 6, + name: 'foo.example.com', + type: 'A', + content: '1.2.3.4', + priority: 0, + ttl: 60, + domain: 1 + }, 'Updated record has wrong data.'); + + //Delete existing record + var res = await req({ + url: '/records/6', + method: 'delete' + }); + + assert.equal(res.status, 204, 'Deletion of existing record should succeed.'); + }); +}); \ No newline at end of file