diff --git a/modules/api/src/main/scala/vinyldns/api/domain/membership/MembershipService.scala b/modules/api/src/main/scala/vinyldns/api/domain/membership/MembershipService.scala index 4c3471e59..e5fc50c69 100644 --- a/modules/api/src/main/scala/vinyldns/api/domain/membership/MembershipService.scala +++ b/modules/api/src/main/scala/vinyldns/api/domain/membership/MembershipService.scala @@ -256,7 +256,7 @@ class MembershipService( _ <- isGroupChangePresent(result).toResult _ <- canSeeGroup(result.get.newGroup.id, authPrincipal).toResult allUserIds = getGroupUserIds(Seq(result.get)) - allUserMap <- getUsers(allUserIds).map(_.users.map(x => x.id -> x.userName).toMap) + allUserMap <- getUsers(allUserIds).map(_.users.map(x => x.id -> x.userName).toMap.withDefaultValue("unknown user")) groupChangeMessage <- determineGroupDifference(Seq(result.get), allUserMap) groupChanges = (groupChangeMessage, Seq(result.get)).zipped.map{ (a, b) => b.copy(groupChangeMessage = Some(a)) } userIds = Seq(result.get).map(_.userId).toSet @@ -276,7 +276,7 @@ class MembershipService( .getGroupChanges(groupId, startFrom, maxItems) .toResult[ListGroupChangesResults] allUserIds = getGroupUserIds(result.changes) - allUserMap <- getUsers(allUserIds).map(_.users.map(x => x.id -> x.userName).toMap) + allUserMap <- getUsers(allUserIds).map(_.users.map(x => x.id -> x.userName).toMap.withDefaultValue("unknown user")) groupChangeMessage <- determineGroupDifference(result.changes, allUserMap) groupChanges = (groupChangeMessage, result.changes).zipped.map{ (a, b) => b.copy(groupChangeMessage = Some(a)) } userIds = result.changes.map(_.userId).toSet @@ -316,7 +316,8 @@ class MembershipService( sb.append(s"Group email changed to '${change.newGroup.email}'. ") } if (change.oldGroup.get.description != change.newGroup.description) { - sb.append(s"Group description changed to '${change.newGroup.description.get}'. ") + val description = if(change.newGroup.description.isEmpty) "" else change.newGroup.description.get + sb.append(s"Group description changed to '$description'. ") } val adminAddDifference = change.newGroup.adminUserIds.diff(change.oldGroup.get.adminUserIds) if (adminAddDifference.nonEmpty) { diff --git a/modules/api/src/main/scala/vinyldns/api/domain/record/ListRecordSetChangesResponse.scala b/modules/api/src/main/scala/vinyldns/api/domain/record/ListRecordSetChangesResponse.scala index a1ef7cd53..26136d102 100644 --- a/modules/api/src/main/scala/vinyldns/api/domain/record/ListRecordSetChangesResponse.scala +++ b/modules/api/src/main/scala/vinyldns/api/domain/record/ListRecordSetChangesResponse.scala @@ -42,6 +42,29 @@ object ListRecordSetChangesResponse { ) } +case class ListRecordSetHistoryResponse( + zoneId: Option[String], + recordSetChanges: List[RecordSetChangeInfo] = Nil, + nextId: Option[Int], + startFrom: Option[Int], + maxItems: Int + ) + +object ListRecordSetHistoryResponse { + def apply( + zoneId: Option[String], + listResults: ListRecordSetChangesResults, + info: List[RecordSetChangeInfo] + ): ListRecordSetHistoryResponse = + ListRecordSetHistoryResponse( + zoneId, + info, + listResults.nextId, + listResults.startFrom, + listResults.maxItems + ) +} + case class ListFailedRecordSetChangesResponse( failedRecordSetChanges: List[RecordSetChange] = Nil, nextId: Int, diff --git a/modules/api/src/main/scala/vinyldns/api/domain/record/RecordSetService.scala b/modules/api/src/main/scala/vinyldns/api/domain/record/RecordSetService.scala index 285a4a1cf..43b1047ad 100644 --- a/modules/api/src/main/scala/vinyldns/api/domain/record/RecordSetService.scala +++ b/modules/api/src/main/scala/vinyldns/api/domain/record/RecordSetService.scala @@ -576,20 +576,38 @@ class RecordSetService( } yield change def listRecordSetChanges( - zoneId: String, + zoneId: Option[String] = None, startFrom: Option[Int] = None, maxItems: Int = 100, + fqdn: Option[String] = None, + recordType: Option[RecordType] = None, authPrincipal: AuthPrincipal ): Result[ListRecordSetChangesResponse] = + for { + zone <- getZone(zoneId.get) + _ <- canSeeZone(authPrincipal, zone).toResult + recordSetChangesResults <- recordChangeRepository + .listRecordSetChanges(Some(zone.id), startFrom, maxItems, fqdn, recordType) + .toResult[ListRecordSetChangesResults] + recordSetChangesInfo <- buildRecordSetChangeInfo(recordSetChangesResults.items) + } yield ListRecordSetChangesResponse(zoneId.get, recordSetChangesResults, recordSetChangesInfo) + + def listRecordSetChangeHistory( + zoneId: Option[String] = None, + startFrom: Option[Int] = None, + maxItems: Int = 100, + fqdn: Option[String] = None, + recordType: Option[RecordType] = None, + authPrincipal: AuthPrincipal + ): Result[ListRecordSetHistoryResponse] = for { - zone <- getZone(zoneId) - _ <- canSeeZone(authPrincipal, zone).toResult recordSetChangesResults <- recordChangeRepository - .listRecordSetChanges(zone.id, startFrom, maxItems) + .listRecordSetChanges(zoneId, startFrom, maxItems, fqdn, recordType) .toResult[ListRecordSetChangesResults] recordSetChangesInfo <- buildRecordSetChangeInfo(recordSetChangesResults.items) - } yield ListRecordSetChangesResponse(zoneId, recordSetChangesResults, recordSetChangesInfo) - + _ <- if(recordSetChangesResults.items.nonEmpty) canSeeZone(authPrincipal, recordSetChangesInfo.map(_.zone).head).toResult else ().toResult + zoneId = if(recordSetChangesResults.items.nonEmpty) Some(recordSetChangesResults.items.map(x => x.zone.id).head) else None + } yield ListRecordSetHistoryResponse(zoneId, recordSetChangesResults, recordSetChangesInfo) def listFailedRecordSetChanges( authPrincipal: AuthPrincipal, diff --git a/modules/api/src/main/scala/vinyldns/api/domain/record/RecordSetServiceAlgebra.scala b/modules/api/src/main/scala/vinyldns/api/domain/record/RecordSetServiceAlgebra.scala index a422b167c..aa8dae9a9 100644 --- a/modules/api/src/main/scala/vinyldns/api/domain/record/RecordSetServiceAlgebra.scala +++ b/modules/api/src/main/scala/vinyldns/api/domain/record/RecordSetServiceAlgebra.scala @@ -101,12 +101,23 @@ trait RecordSetServiceAlgebra { ): Result[RecordSetChange] def listRecordSetChanges( - zoneId: String, + zoneId: Option[String], startFrom: Option[Int], maxItems: Int, + fqdn: Option[String], + recordType: Option[RecordType], authPrincipal: AuthPrincipal ): Result[ListRecordSetChangesResponse] + def listRecordSetChangeHistory( + zoneId: Option[String], + startFrom: Option[Int], + maxItems: Int, + fqdn: Option[String], + recordType: Option[RecordType], + authPrincipal: AuthPrincipal + ): Result[ListRecordSetHistoryResponse] + def listFailedRecordSetChanges( authPrincipal: AuthPrincipal, startFrom: Int, diff --git a/modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneProtocol.scala b/modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneProtocol.scala index 6d0a8f613..faf2b16be 100644 --- a/modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneProtocol.scala +++ b/modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneProtocol.scala @@ -78,6 +78,28 @@ object ZoneInfo { ) } +case class ZoneDetails( + name: String, + email: String, + status: ZoneStatus, + adminGroupId: String, + adminGroupName: String, + ) + +object ZoneDetails { + def apply( + zone: Zone, + groupName: String, + ): ZoneDetails = + ZoneDetails( + name = zone.name, + email = zone.email, + status = zone.status, + adminGroupId = zone.adminGroupId, + adminGroupName = groupName, + ) +} + case class ZoneSummaryInfo( name: String, email: String, diff --git a/modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneService.scala b/modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneService.scala index 24b0dc2df..f2080410b 100644 --- a/modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneService.scala +++ b/modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneService.scala @@ -153,6 +153,12 @@ class ZoneService( accessLevel = getZoneAccess(auth, zone) } yield ZoneInfo(zone, aclInfo, groupName, accessLevel) + def getCommonZoneDetails(zoneId: String, auth: AuthPrincipal): Result[ZoneDetails] = + for { + zone <- getZoneOrFail(zoneId) + groupName <- getGroupName(zone.adminGroupId) + } yield ZoneDetails(zone, groupName) + def getZoneByName(zoneName: String, auth: AuthPrincipal): Result[ZoneInfo] = for { zone <- getZoneByNameOrFail(ensureTrailingDot(zoneName)) diff --git a/modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneServiceAlgebra.scala b/modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneServiceAlgebra.scala index b2ce6af63..01a15ace5 100644 --- a/modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneServiceAlgebra.scala +++ b/modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneServiceAlgebra.scala @@ -35,6 +35,8 @@ trait ZoneServiceAlgebra { def getZone(zoneId: String, auth: AuthPrincipal): Result[ZoneInfo] + def getCommonZoneDetails(zoneId: String, auth: AuthPrincipal): Result[ZoneDetails] + def getZoneByName(zoneName: String, auth: AuthPrincipal): Result[ZoneInfo] def listZones( diff --git a/modules/api/src/main/scala/vinyldns/api/route/RecordSetRouting.scala b/modules/api/src/main/scala/vinyldns/api/route/RecordSetRouting.scala index 1d6690c27..42ebe904c 100644 --- a/modules/api/src/main/scala/vinyldns/api/route/RecordSetRouting.scala +++ b/modules/api/src/main/scala/vinyldns/api/route/RecordSetRouting.scala @@ -223,8 +223,8 @@ class RecordSetRoute( } ~ path("zones" / Segment / "recordsetchanges") { zoneId => (get & monitor("Endpoint.listRecordSetChanges")) { - parameters("startFrom".as[Int].?, "maxItems".as[Int].?(DEFAULT_MAX_ITEMS)) { - (startFrom: Option[Int], maxItems: Int) => + parameters("startFrom".as[Int].?, "maxItems".as[Int].?(DEFAULT_MAX_ITEMS), "fqdn".as[String].?, "recordType".as[String].?) { + (startFrom: Option[Int], maxItems: Int, fqdn: Option[String], _: Option[String]) => handleRejections(invalidQueryHandler) { validate( check = 0 < maxItems && maxItems <= DEFAULT_MAX_ITEMS, @@ -233,7 +233,34 @@ class RecordSetRoute( ) { authenticateAndExecute( recordSetService - .listRecordSetChanges(zoneId, startFrom, maxItems, _) + .listRecordSetChanges(Some(zoneId), startFrom, maxItems, fqdn, None, _) + ) { changes => + complete(StatusCodes.OK, changes) + } + } + } + } + } + } ~ + path("recordsetchange" / "history") { + (get & monitor("Endpoint.listRecordSetChangeHistory")) { + parameters("startFrom".as[Int].?, "maxItems".as[Int].?(DEFAULT_MAX_ITEMS), "fqdn".as[String].?, "recordType".as[String].?) { + (startFrom: Option[Int], maxItems: Int, fqdn: Option[String], recordType: Option[String]) => + handleRejections(invalidQueryHandler) { + val errorMessage = if(fqdn.isEmpty || recordType.isEmpty) { + "recordType and fqdn cannot be empty" + } else { + s"maxItems was $maxItems, maxItems must be between 0 exclusive " + + s"and $DEFAULT_MAX_ITEMS inclusive" + } + val isValid = (0 < maxItems && maxItems <= DEFAULT_MAX_ITEMS) && (fqdn.nonEmpty && recordType.nonEmpty) + validate( + check = isValid, + errorMsg = errorMessage + ){ + authenticateAndExecute( + recordSetService + .listRecordSetChangeHistory(None, startFrom, maxItems, fqdn, RecordType.find(recordType.get), _) ) { changes => complete(StatusCodes.OK, changes) } diff --git a/modules/api/src/main/scala/vinyldns/api/route/ZoneRouting.scala b/modules/api/src/main/scala/vinyldns/api/route/ZoneRouting.scala index ef9314e45..5ba1b0aef 100644 --- a/modules/api/src/main/scala/vinyldns/api/route/ZoneRouting.scala +++ b/modules/api/src/main/scala/vinyldns/api/route/ZoneRouting.scala @@ -29,6 +29,7 @@ import vinyldns.core.domain.zone._ import scala.concurrent.duration._ case class GetZoneResponse(zone: ZoneInfo) +case class GetZoneDetailsResponse(zone: ZoneDetails) case class ZoneRejected(zone: Zone, errors: List[String]) class ZoneRoute( @@ -142,6 +143,13 @@ class ZoneRoute( } } } ~ + path("zones" / Segment / "details") { id => + (get & monitor("Endpoint.getCommonZoneDetails")) { + authenticateAndExecute(zoneService.getCommonZoneDetails(id, _)) { zone => + complete(StatusCodes.OK, GetZoneDetailsResponse(zone)) + } + } + } ~ path("zones" / Segment / "sync") { id => (post & monitor("Endpoint.syncZone")) { authenticateAndExecute(zoneService.syncZone(id, _)) { chg => diff --git a/modules/api/src/test/functional/tests/recordsets/list_recordset_changes_test.py b/modules/api/src/test/functional/tests/recordsets/list_recordset_changes_test.py index 19422afae..bd3e7e463 100644 --- a/modules/api/src/test/functional/tests/recordsets/list_recordset_changes_test.py +++ b/modules/api/src/test/functional/tests/recordsets/list_recordset_changes_test.py @@ -30,6 +30,38 @@ def check_changes_response(response, recordChanges=False, nextId=False, startFro assert_that(change["userName"], is_("history-user")) +def check_change_history_response(response, fqdn, type, recordChanges=False, nextId=False, startFrom=False, maxItems=100): + """ + :param type: type of the record + :param fqdn: fqdn of the record + :param response: return value of list_recordset_changes() + :param recordChanges: true if not empty or False if empty, cannot check exact values because don't have access to all attributes + :param nextId: true if exists, false if doesn't, wouldn't be able to check exact value + :param startFrom: the string for startFrom or false if doesnt exist + :param maxItems: maxItems is defined as an Int by default so will always return an Int + """ + assert_that(response, has_key("zoneId")) # always defined as random string + if recordChanges: + assert_that(response["recordSetChanges"], is_not(has_length(0))) + else: + assert_that(response["recordSetChanges"], has_length(0)) + if nextId: + assert_that(response, has_key("nextId")) + else: + assert_that(response, is_not(has_key("nextId"))) + if startFrom: + assert_that(response["startFrom"], is_(startFrom)) + else: + assert_that(response, is_not(has_key("startFrom"))) + assert_that(response["maxItems"], is_(maxItems)) + + for change in response["recordSetChanges"]: + assert_that(change["userName"], is_("history-user")) + for recordset in change["recordSet"]: + assert_that(change["recordSet"]["type"], is_(type)) + assert_that(change["recordSet"]["name"] + "." + response["recordSetChanges"][0]["zone"]["name"], is_(fqdn)) + + def test_list_recordset_changes_no_authorization(shared_zone_test_context): """ Test that recordset changes without authorization fails @@ -174,3 +206,101 @@ def test_list_recordset_changes_max_items_boundaries(shared_zone_test_context): assert_that(too_large, is_("maxItems was 101, maxItems must be between 0 exclusive and 100 inclusive")) assert_that(too_small, is_("maxItems was 0, maxItems must be between 0 exclusive and 100 inclusive")) + + +def test_list_recordset_history_no_authorization(shared_zone_test_context): + """ + Test that recordset history without authorization fails + """ + client = shared_zone_test_context.history_client + fqdn = "test-create-cname-ok.system-test-history1." + type = "CNAME" + client.list_recordset_change_history(fqdn, type, sign_request=False, status=401) + + +def test_list_recordset_history_member_auth_success(shared_zone_test_context): + """ + Test recordset history succeeds with membership auth for member of admin group + """ + client = shared_zone_test_context.history_client + fqdn = "test-create-cname-ok.system-test-history1." + type = "CNAME" + response = client.list_recordset_change_history(fqdn, type, status=200) + check_change_history_response(response, fqdn, type, recordChanges=True, startFrom=False, nextId=False) + + +def test_list_recordset_history_member_auth_no_access(shared_zone_test_context): + """ + Test recordset history fails for user not in admin group with no acl rules + """ + client = shared_zone_test_context.ok_vinyldns_client + fqdn = "test-create-cname-ok.system-test-history1." + type = "CNAME" + client.list_recordset_change_history(fqdn, type, status=403) + + +def test_list_recordset_history_success(shared_zone_test_context): + """ + Test recordset history succeeds with membership auth for member of admin group + """ + client = shared_zone_test_context.history_client + fqdn = "test-create-cname-ok.system-test-history1." + type = "CNAME" + response = client.list_recordset_change_history(fqdn, type, status=200) + check_change_history_response(response, fqdn, type, recordChanges=True, startFrom=False, nextId=False) + + +def test_list_recordset_history_paging(shared_zone_test_context): + """ + Test paging for recordset history can use previous nextId as start key of next page + """ + client = shared_zone_test_context.history_client + + fqdn = "test-create-cname-ok.system-test-history1." + type = "CNAME" + + response_1 = client.list_recordset_change_history(fqdn, type, start_from=None, max_items=1) + response_2 = client.list_recordset_change_history(fqdn, type, start_from=response_1["nextId"], max_items=1) + + check_change_history_response(response_1, fqdn, type, recordChanges=True, nextId=True, startFrom=False, maxItems=1) + check_change_history_response(response_2, fqdn, type, recordChanges=True, nextId=True, startFrom=response_1["nextId"], maxItems=1) + + +def test_list_recordset_history_returning_no_changes(shared_zone_test_context): + """ + Pass in startFrom of "2000" should return empty list because start key exceeded number of recordset change history + """ + client = shared_zone_test_context.history_client + fqdn = "test-create-cname-ok.system-test-history1." + type = "CNAME" + response = client.list_recordset_change_history(fqdn, type, start_from=2000, max_items=None) + assert_that(response["recordSetChanges"], has_length(0)) + assert_that(response["startFrom"], is_(2000)) + assert_that(response["maxItems"], is_(100)) + + +def test_list_recordset_history_default_max_items(shared_zone_test_context): + """ + Test default max items is 100 + """ + client = shared_zone_test_context.history_client + fqdn = "test-create-cname-ok.system-test-history1." + type = "CNAME" + + response = client.list_recordset_change_history(fqdn, type, start_from=None, max_items=None) + check_change_history_response(response, fqdn, type, recordChanges=True, startFrom=False, nextId=False, maxItems=100) + + +def test_list_recordset_history_max_items_boundaries(shared_zone_test_context): + """ + Test 0 < max_items <= 100 + """ + client = shared_zone_test_context.history_client + fqdn = "test-create-cname-ok.system-test-history1." + type = "CNAME" + + too_large = client.list_recordset_change_history(fqdn, type, start_from=None, max_items=101, status=400) + too_small = client.list_recordset_change_history(fqdn, type, start_from=None, max_items=0, status=400) + + assert_that(too_large, is_("maxItems was 101, maxItems must be between 0 exclusive and 100 inclusive")) + assert_that(too_small, is_("maxItems was 0, maxItems must be between 0 exclusive and 100 inclusive")) diff --git a/modules/api/src/test/functional/tests/zones/get_zone_test.py b/modules/api/src/test/functional/tests/zones/get_zone_test.py index 0068c2bfa..1ff676855 100644 --- a/modules/api/src/test/functional/tests/zones/get_zone_test.py +++ b/modules/api/src/test/functional/tests/zones/get_zone_test.py @@ -46,6 +46,7 @@ def test_get_zone_shared_by_id_non_owner(shared_zone_test_context): assert_that(retrieved["shared"], is_(True)) assert_that(retrieved["accessLevel"], is_("Read")) + def test_get_zone_private_by_id_fails_without_access(shared_zone_test_context): """ Test get an existing zone by id without access @@ -72,6 +73,29 @@ def test_get_zone_by_id_no_authorization(shared_zone_test_context): client.get_zone("123456", sign_request=False, status=401) +def test_get_common_zone_details_by_id(shared_zone_test_context): + """ + Test get an existing zone's common details by id + """ + client = shared_zone_test_context.ok_vinyldns_client + + result = client.get_common_zone_details(shared_zone_test_context.system_test_zone["id"], status=200) + retrieved = result["zone"] + + assert_that(retrieved["name"], is_(shared_zone_test_context.system_test_zone["name"])) + assert_that(retrieved["email"], is_(shared_zone_test_context.system_test_zone["email"])) + assert_that(retrieved["adminGroupName"], is_(shared_zone_test_context.ok_group["name"])) + assert_that(retrieved["adminGroupId"], is_(shared_zone_test_context.ok_group["id"])) + + +def test_get_common_zone_details_by_id_returns_404_when_not_found(shared_zone_test_context): + """ + Test get an existing zone returns a 404 when the zone is not found + """ + client = shared_zone_test_context.ok_vinyldns_client + client.get_common_zone_details(str(uuid.uuid4()), status=404) + + @pytest.mark.serial def test_get_zone_by_id_includes_acl_display_name(shared_zone_test_context): """ diff --git a/modules/api/src/test/functional/vinyldns_python.py b/modules/api/src/test/functional/vinyldns_python.py index 87c7f8866..61c6f0648 100644 --- a/modules/api/src/test/functional/vinyldns_python.py +++ b/modules/api/src/test/functional/vinyldns_python.py @@ -51,7 +51,8 @@ class VinylDNSClient(object): self.session.close() self.session_not_found_ok.close() - def requests_retry_not_found_ok_session(self, retries=20, backoff_factor=0.1, status_forcelist=(500, 502, 504), session=None): + def requests_retry_not_found_ok_session(self, retries=20, backoff_factor=0.1, status_forcelist=(500, 502, 504), + session=None): session = session or requests.Session() retry = Retry( total=retries, @@ -79,7 +80,8 @@ class VinylDNSClient(object): session.mount("https://", adapter) return session - def make_request(self, url, method="GET", headers=None, body_string=None, sign_request=True, not_found_ok=False, **kwargs): + def make_request(self, url, method="GET", headers=None, body_string=None, sign_request=True, not_found_ok=False, + **kwargs): # pull out status or None status_code = kwargs.pop("status", None) @@ -100,13 +102,15 @@ class VinylDNSClient(object): for k, v in query.items()) if sign_request: - signed_headers, signed_body = self.sign_request(method, path, body_string, query, with_headers=headers or {}, **kwargs) + signed_headers, signed_body = self.sign_request(method, path, body_string, query, + with_headers=headers or {}, **kwargs) else: signed_headers = headers or {} signed_body = body_string if not_found_ok: - response = self.session_not_found_ok.request(method, url, data=signed_body, headers=signed_headers, **kwargs) + response = self.session_not_found_ok.request(method, url, data=signed_body, headers=signed_headers, + **kwargs) else: response = self.session.request(method, url, data=signed_body, headers=signed_headers, **kwargs) @@ -388,6 +392,17 @@ class VinylDNSClient(object): return data + def get_common_zone_details(self, zone_id, **kwargs): + """ + Gets common zone details which can be seen by all users for the given zone id + :param zone_id: the id of the zone to retrieve + :return: the zone, or will 404 if not found + """ + url = urljoin(self.index_url, "/zones/{0}/details".format(zone_id)) + response, data = self.make_request(url, "GET", self.headers, not_found_ok=True, **kwargs) + + return data + def get_zone_by_name(self, zone_name, **kwargs): """ Gets a zone for the given zone name @@ -445,7 +460,29 @@ class VinylDNSClient(object): response, data = self.make_request(url, "GET", self.headers, not_found_ok=True, **kwargs) return data - def list_zones(self, name_filter=None, start_from=None, max_items=None, search_by_admin_group=False, ignore_access=False, **kwargs): + def list_recordset_change_history(self, fqdn, record_type, start_from=None, max_items=None, **kwargs): + """ + Gets the record's change history for the given record fqdn and record type + :param fqdn: the record's fqdn + :param record_type: the record's type + :param start_from: the start key of the page + :param max_items: the page limit + :return: the zone, or will 404 if not found + """ + args = [] + if start_from: + args.append("startFrom={0}".format(start_from)) + if max_items is not None: + args.append("maxItems={0}".format(max_items)) + args.append("fqdn={0}".format(fqdn)) + args.append("recordType={0}".format(record_type)) + url = urljoin(self.index_url, "recordsetchange/history") + "?" + "&".join(args) + + response, data = self.make_request(url, "GET", self.headers, not_found_ok=True, **kwargs) + return data + + def list_zones(self, name_filter=None, start_from=None, max_items=None, search_by_admin_group=False, + ignore_access=False, **kwargs): """ Gets a list of zones that currently exist :return: a list of zones @@ -535,7 +572,8 @@ class VinylDNSClient(object): response, data = self.make_request(url, "GET", self.headers, None, not_found_ok=True, **kwargs) return data - def list_recordsets_by_zone(self, zone_id, start_from=None, max_items=None, record_name_filter=None, record_type_filter=None, name_sort=None, **kwargs): + def list_recordsets_by_zone(self, zone_id, start_from=None, max_items=None, record_name_filter=None, + record_type_filter=None, name_sort=None, **kwargs): """ Retrieves all recordsets in a zone :param zone_id: the zone to retrieve @@ -618,7 +656,8 @@ class VinylDNSClient(object): _, data = self.make_request(url, "POST", self.headers, **kwargs) return data - def list_batch_change_summaries(self, start_from=None, max_items=None, ignore_access=False, approval_status=None, **kwargs): + def list_batch_change_summaries(self, start_from=None, max_items=None, ignore_access=False, approval_status=None, + **kwargs): """ Gets list of user's batch change summaries :return: the content of the response @@ -660,7 +699,8 @@ class VinylDNSClient(object): :return: the content of the response """ url = urljoin(self.index_url, "/zones/{0}/acl/rules".format(zone_id)) - response, data = self.make_request(url, "PUT", self.headers, json.dumps(acl_rule), sign_request=sign_request, **kwargs) + response, data = self.make_request(url, "PUT", self.headers, json.dumps(acl_rule), sign_request=sign_request, + **kwargs) return data @@ -804,7 +844,8 @@ class VinylDNSClient(object): while change["status"] != expected_status and retries > 0: time.sleep(RETRY_WAIT) retries -= 1 - latest_change = self.get_recordset_change(change["recordSet"]["zoneId"], change["recordSet"]["id"], change["id"], status=(200, 404)) + latest_change = self.get_recordset_change(change["recordSet"]["zoneId"], change["recordSet"]["id"], + change["id"], status=(200, 404)) if type(latest_change) != str: change = latest_change diff --git a/modules/api/src/test/scala/vinyldns/api/domain/record/RecordSetServiceSpec.scala b/modules/api/src/test/scala/vinyldns/api/domain/record/RecordSetServiceSpec.scala index 4ca4781e5..a54131655 100644 --- a/modules/api/src/test/scala/vinyldns/api/domain/record/RecordSetServiceSpec.scala +++ b/modules/api/src/test/scala/vinyldns/api/domain/record/RecordSetServiceSpec.scala @@ -184,6 +184,9 @@ class RecordSetServiceSpec doReturn(IO.pure(ListUsersResults(Seq(), None))) .when(mockUserRepo) .getUsers(Set.empty, None, None) + doReturn(IO.pure(Some(okGroup))) + .when(mockGroupRepo) + .getGroup(okGroup.id) val result: RecordSetChange = underTest.addRecordSet(record, okAuth).map(_.asInstanceOf[RecordSetChange]).value.unsafeRunSync().toOption.get @@ -468,7 +471,7 @@ class RecordSetServiceSpec result.recordSet.ownerGroupId shouldBe Some(okGroup.id) } - "fail if user is in not owner group" in { + "fail if user is not in owner group" in { val record = aaaa.copy(ownerGroupId = Some(dummyGroup.id)) doReturn(IO.pure(List())) @@ -583,6 +586,9 @@ class RecordSetServiceSpec doReturn(IO.pure(ListUsersResults(listOfDummyUsers.toSeq, None))) .when(mockUserRepo) .getUsers(dummyGroup.memberIds, None, None) + doReturn(IO.pure(Some(xyzGroup))) + .when(mockGroupRepo) + .getGroup(xyzGroup.id) // passes as all three properties within dotted hosts config (allowed zones, users and record types) are satisfied val result: RecordSetChange = @@ -787,6 +793,9 @@ class RecordSetServiceSpec doReturn(IO.pure(ListUsersResults(listOfDummyUsers.toSeq, None))) .when(mockUserRepo) .getUsers(dummyGroup.memberIds, None, None) + doReturn(IO.pure(Some(abcGroup))) + .when(mockGroupRepo) + .getGroup(abcGroup.id) // fails as only two properties within dotted hosts config (zones and record types) are satisfied while user is not allowed val result = underTest.addRecordSet(record, abcAuth).value.unsafeRunSync().swap.toOption.get @@ -1932,13 +1941,13 @@ class RecordSetServiceSpec doReturn(IO.pure(ListRecordSetChangesResults(completeRecordSetChanges))) .when(mockRecordChangeRepo) - .listRecordSetChanges(zoneId = okZone.id, startFrom = None, maxItems = 100) + .listRecordSetChanges(zoneId = Some(okZone.id), startFrom = None, maxItems = 100, fqdn = None, recordType = None) doReturn(IO.pure(ListUsersResults(Seq(okUser), None))) .when(mockUserRepo) .getUsers(any[Set[String]], any[Option[String]], any[Option[Int]]) val result: ListRecordSetChangesResponse = - underTest.listRecordSetChanges(okZone.id, authPrincipal = okAuth).value.unsafeRunSync().toOption.get + underTest.listRecordSetChanges(Some(okZone.id), authPrincipal = okAuth).value.unsafeRunSync().toOption.get val changesWithName = completeRecordSetChanges.map(change => RecordSetChangeInfo(change, Some("ok"))) val expectedResults = ListRecordSetChangesResponse( @@ -1951,13 +1960,42 @@ class RecordSetServiceSpec result shouldBe expectedResults } + "retrieve the recordset changes based on fqdn and record type" in { + val filteredRecordSetChanges: List[RecordSetChange] = + List(pendingCreateAAAA, completeCreateAAAA) + val zoneId = filteredRecordSetChanges.head.zoneId + + doReturn(IO.pure(ListRecordSetChangesResults(filteredRecordSetChanges))) + .when(mockRecordChangeRepo) + .listRecordSetChanges(zoneId = None, startFrom = None, maxItems = 100, fqdn = Some("aaaa.ok.zone.recordsets."), recordType = Some(RecordType.AAAA)) + doReturn(IO.pure(ListUsersResults(Seq(okUser), None))) + .when(mockUserRepo) + .getUsers(any[Set[String]], any[Option[String]], any[Option[Int]]) + + val result: ListRecordSetHistoryResponse = + underTest.listRecordSetChangeHistory(None, fqdn = Some("aaaa.ok.zone.recordsets."), recordType = Some(RecordType.AAAA), authPrincipal = okAuth).value.unsafeRunSync().toOption.get + val changesWithName = + filteredRecordSetChanges.map(change => RecordSetChangeInfo(change, Some("ok"))) + val expectedResults = ListRecordSetHistoryResponse( + zoneId = Some(zoneId), + recordSetChanges = changesWithName, + nextId = None, + startFrom = None, + maxItems = 100 + ) + result shouldBe expectedResults + } + "return a zone with no changes if no changes exist" in { doReturn(IO.pure(ListRecordSetChangesResults(items = Nil))) .when(mockRecordChangeRepo) - .listRecordSetChanges(zoneId = okZone.id, startFrom = None, maxItems = 100) + .listRecordSetChanges(zoneId = Some(okZone.id), startFrom = None, maxItems = 100, fqdn = None, recordType = None) + doReturn(IO.pure(ListUsersResults(Seq(okUser), None))) + .when(mockUserRepo) + .getUsers(any[Set[String]], any[Option[String]], any[Option[Int]]) val result: ListRecordSetChangesResponse = - underTest.listRecordSetChanges(okZone.id, authPrincipal = okAuth).value.unsafeRunSync().toOption.get + underTest.listRecordSetChanges(Some(okZone.id), authPrincipal = okAuth).value.unsafeRunSync().toOption.get val expectedResults = ListRecordSetChangesResponse( zoneId = okZone.id, recordSetChanges = List(), @@ -2023,7 +2061,7 @@ class RecordSetServiceSpec "return a NotAuthorizedError" in { val error = - underTest.listRecordSetChanges(zoneNotAuthorized.id, authPrincipal = okAuth).value.unsafeRunSync().swap.toOption.get + underTest.listRecordSetChanges(Some(zoneNotAuthorized.id), authPrincipal = okAuth).value.unsafeRunSync().swap.toOption.get error shouldBe a[NotAuthorizedError] } @@ -2034,10 +2072,13 @@ class RecordSetServiceSpec doReturn(IO.pure(ListRecordSetChangesResults(List(rsChange2, rsChange1)))) .when(mockRecordChangeRepo) - .listRecordSetChanges(zoneId = okZone.id, startFrom = None, maxItems = 100) + .listRecordSetChanges(zoneId = Some(okZone.id), startFrom = None, maxItems = 100, fqdn = None, recordType = None) + doReturn(IO.pure(ListUsersResults(Seq(okUser), None))) + .when(mockUserRepo) + .getUsers(any[Set[String]], any[Option[String]], any[Option[Int]]) val result: ListRecordSetChangesResponse = - underTest.listRecordSetChanges(okZone.id, authPrincipal = okAuth).value.unsafeRunSync().toOption.get + underTest.listRecordSetChanges(Some(okZone.id), authPrincipal = okAuth).value.unsafeRunSync().toOption.get val changesWithName = List(RecordSetChangeInfo(rsChange2, Some("ok")), RecordSetChangeInfo(rsChange1, Some("ok"))) val expectedResults = ListRecordSetChangesResponse( diff --git a/modules/api/src/test/scala/vinyldns/api/domain/zone/ZoneServiceSpec.scala b/modules/api/src/test/scala/vinyldns/api/domain/zone/ZoneServiceSpec.scala index 4f9dec9c5..dc8789eb1 100644 --- a/modules/api/src/test/scala/vinyldns/api/domain/zone/ZoneServiceSpec.scala +++ b/modules/api/src/test/scala/vinyldns/api/domain/zone/ZoneServiceSpec.scala @@ -799,6 +799,46 @@ class ZoneServiceSpec } } + "Getting a zone details" should { + "fail with no zone returned" in { + doReturn(IO.pure(None)).when(mockZoneRepo).getZone("notAZoneId") + + val error = underTest.getCommonZoneDetails("notAZoneId", okAuth).value.unsafeRunSync().swap.toOption.get + error shouldBe a[ZoneNotFoundError] + } + + "return zone details even if the user is not authorized for the zone" in { + doReturn(IO.pure(Some(okZone))).when(mockZoneRepo).getZone(anyString) + + val noAuth = AuthPrincipal(TestDataLoader.okUser, Seq()) + + val result = underTest.getCommonZoneDetails(okZone.id, noAuth).value.unsafeRunSync() + val expectedZoneDetails = + ZoneDetails(okZone, okGroup.name) + result.right.value shouldBe expectedZoneDetails + } + + "return the appropriate zone as a ZoneDetails" in { + doReturn(IO.pure(Some(abcZone))).when(mockZoneRepo).getZone(abcZone.id) + doReturn(IO.pure(Some(abcGroup))).when(mockGroupRepo).getGroup(anyString) + + val expectedZoneDetails = + ZoneDetails(abcZone, abcGroup.name) + val result = underTest.getCommonZoneDetails(abcZone.id, abcAuth).value.unsafeRunSync() + result.right.value shouldBe expectedZoneDetails + } + + "return Unknown group name if zone admin group cannot be found" in { + doReturn(IO.pure(Some(abcZone))).when(mockZoneRepo).getZone(abcZone.id) + doReturn(IO.pure(None)).when(mockGroupRepo).getGroup(anyString) + + val expectedZoneDetails = + ZoneDetails(abcZone, "Unknown group name") + val result: ZoneDetails = underTest.getCommonZoneDetails(abcZone.id, abcAuth).value.unsafeRunSync().toOption.get + result shouldBe expectedZoneDetails + } + } + "ListZones" should { "not fail with no zones returned" in { doReturn(IO.pure(ListZonesResults(List()))) diff --git a/modules/api/src/test/scala/vinyldns/api/engine/ZoneSyncHandlerSpec.scala b/modules/api/src/test/scala/vinyldns/api/engine/ZoneSyncHandlerSpec.scala index 91be15949..b566abceb 100644 --- a/modules/api/src/test/scala/vinyldns/api/engine/ZoneSyncHandlerSpec.scala +++ b/modules/api/src/test/scala/vinyldns/api/engine/ZoneSyncHandlerSpec.scala @@ -556,7 +556,8 @@ class ZoneSyncHandlerSpec verify(recordChangeRepo).save(any[DB], captor.capture()) val req = captor.getValue - anonymize(req) shouldBe anonymize(testChangeSet) + val expectedRecordSetChanges = testChangeSet.changes + anonymize(req) shouldBe anonymize(testChangeSet.withRecordSetChange(expectedRecordSetChanges)) } "save the record changes to the recordSetCacheRepo" in { @@ -566,7 +567,8 @@ class ZoneSyncHandlerSpec verify(recordSetCacheRepo).save(any[DB], captor.capture()) val req = captor.getValue - anonymize(req) shouldBe anonymize(testChangeSet) + val expectedRecordSetChanges = testChangeSet.changes + anonymize(req) shouldBe anonymize(testChangeSet.withRecordSetChange(expectedRecordSetChanges)) } @@ -577,7 +579,8 @@ class ZoneSyncHandlerSpec verify(recordSetRepo).apply(any[DB], captor.capture()) val req = captor.getValue - anonymize(req) shouldBe anonymize(testChangeSet) + val expectedRecordSetChanges = testChangeSet.changes + anonymize(req) shouldBe anonymize(testChangeSet.withRecordSetChange(expectedRecordSetChanges)) } "returns the zone as active and sets the latest sync" in { diff --git a/modules/api/src/test/scala/vinyldns/api/route/RecordSetRoutingSpec.scala b/modules/api/src/test/scala/vinyldns/api/route/RecordSetRoutingSpec.scala index 8632ef311..bba670e36 100644 --- a/modules/api/src/test/scala/vinyldns/api/route/RecordSetRoutingSpec.scala +++ b/modules/api/src/test/scala/vinyldns/api/route/RecordSetRoutingSpec.scala @@ -29,7 +29,7 @@ import org.scalatest.matchers.should.Matchers import org.scalatest.wordspec.AnyWordSpec import vinyldns.api.Interfaces._ import vinyldns.api.config.LimitsConfig -import vinyldns.api.domain.record.{ListFailedRecordSetChangesResponse, ListRecordSetChangesResponse, RecordSetServiceAlgebra} +import vinyldns.api.domain.record.{ListFailedRecordSetChangesResponse, ListRecordSetChangesResponse, ListRecordSetHistoryResponse, RecordSetServiceAlgebra} import vinyldns.api.domain.zone._ import vinyldns.core.TestMembershipData.okAuth import vinyldns.core.domain.Fqdn @@ -409,6 +409,16 @@ class RecordSetRoutingSpec maxItems = 100 ) + private val changeWithUserName = + List(RecordSetChangeInfo(rsChange1, Some("ok"))) + private val listRecordSetChangeHistoryResponse = ListRecordSetHistoryResponse( + Some(okZone.id), + changeWithUserName, + nextId = None, + startFrom = None, + maxItems = 100 + ) + private val failedChangesWithUserName = List(rsChange1.copy(status = RecordSetChangeStatus.Failed) , rsChange2.copy(status = RecordSetChangeStatus.Failed)) private val listFailedRecordSetChangeResponse = ListFailedRecordSetChangesResponse( @@ -736,18 +746,35 @@ class RecordSetRoutingSpec }.toResult def listRecordSetChanges( - zoneId: String, + zoneId: Option[String], startFrom: Option[Int], maxItems: Int, + fqdn: Option[String], + recordType: Option[RecordType], authPrincipal: AuthPrincipal ): Result[ListRecordSetChangesResponse] = { zoneId match { - case zoneNotFound.id => Left(ZoneNotFoundError(s"$zoneId")) - case notAuthorizedZone.id => Left(NotAuthorizedError("no way")) + case Some(zoneNotFound.id) => Left(ZoneNotFoundError(s"$zoneId")) + case Some(notAuthorizedZone.id) => Left(NotAuthorizedError("no way")) case _ => Right(listRecordSetChangesResponse) } }.toResult + def listRecordSetChangeHistory( + zoneId: Option[String], + startFrom: Option[Int], + maxItems: Int, + fqdn: Option[String], + recordType: Option[RecordType], + authPrincipal: AuthPrincipal + ): Result[ListRecordSetHistoryResponse] = { + zoneId match { + case Some(zoneNotFound.id) => Left(ZoneNotFoundError(s"$zoneId")) + case Some(notAuthorizedZone.id) => Left(NotAuthorizedError("no way")) + case _ => Right(listRecordSetChangeHistoryResponse) + } + }.toResult + } val recordSetService: RecordSetServiceAlgebra = new TestService @@ -883,6 +910,33 @@ class RecordSetRoutingSpec } } + "GET recordset change history" should { + "return the recordset change" in { + Get(s"/recordsetchange/history?fqdn=rs1.ok.&recordType=A") ~> recordSetRoute ~> check { + val response = responseAs[ListRecordSetHistoryResponse] + + response.zoneId shouldBe Some(okZone.id) + (response.recordSetChanges.map(_.id) should contain) + .only(rsChange1.id) + } + } + + "return an error when the record fqdn and type is not defined" in { + Get(s"/recordsetchange/history") ~> recordSetRoute ~> check { + status shouldBe StatusCodes.BadRequest + } + } + + "return a Bad Request when maxItems is out of Bounds" in { + Get(s"/recordsetchange/history?maxItems=101") ~> recordSetRoute ~> check { + status shouldBe StatusCodes.BadRequest + } + Get(s"/recordsetchange/history?maxItems=0") ~> recordSetRoute ~> check { + status shouldBe StatusCodes.BadRequest + } + } + } + "GET failed record set changes" should { "return the failed record set changes" in { val rsChangeFailed1 = rsChange1.copy(status = RecordSetChangeStatus.Failed) diff --git a/modules/api/src/test/scala/vinyldns/api/route/ZoneRoutingSpec.scala b/modules/api/src/test/scala/vinyldns/api/route/ZoneRoutingSpec.scala index 3c72d1d27..9b9f0673a 100644 --- a/modules/api/src/test/scala/vinyldns/api/route/ZoneRoutingSpec.scala +++ b/modules/api/src/test/scala/vinyldns/api/route/ZoneRoutingSpec.scala @@ -82,6 +82,7 @@ class ZoneRoutingSpec private val ok = Zone("ok.", "ok@test.com", acl = zoneAcl, adminGroupId = "test") private val aclAsInfo = ZoneACLInfo(zoneAcl.rules.map(ACLRuleInfo(_, Some("name")))) private val okAsZoneInfo = ZoneInfo(ok, aclAsInfo, okGroup.name, AccessLevel.Read) + private val okAsZoneDetails = ZoneDetails(ok, okGroup.name) private val badRegex = Zone("ok.", "bad-regex@test.com", adminGroupId = "test") private val trailingDot = Zone("trailing.dot", "trailing-dot@test.com") private val connectionOk = Zone( @@ -249,6 +250,15 @@ class ZoneRoutingSpec outcome.toResult } + def getCommonZoneDetails(zoneId: String, auth: AuthPrincipal): Result[ZoneDetails] = { + val outcome = zoneId match { + case notFound.id => Left(ZoneNotFoundError(s"$zoneId")) + case ok.id => Right(okAsZoneDetails) + case error.id => Left(new RuntimeException("fail")) + } + outcome.toResult + } + def getZoneByName(zoneName: String, auth: AuthPrincipal): Result[ZoneInfo] = { val outcome = zoneName match { case notFound.name => Left(ZoneNotFoundError(s"$zoneName")) @@ -890,6 +900,27 @@ class ZoneRoutingSpec } } + "GET zone details" should { + "return the zone is retrieved" in { + Get(s"/zones/${ok.id}/details") ~> zoneRoute ~> check { + status shouldBe OK + + val resultZone = responseAs[GetZoneDetailsResponse].zone + resultZone.email shouldBe ok.email + resultZone.name shouldBe ok.name + Option(resultZone.status) shouldBe defined + resultZone.adminGroupId shouldBe "test" + resultZone.adminGroupName shouldBe "ok" + } + } + + "return 404 if the zone does not exist" in { + Get(s"/zones/${notFound.id}/details") ~> zoneRoute ~> check { + status shouldBe NotFound + } + } + } + "GET zone by name " should { "return the zone is retrieved" in { Get(s"/zones/name/${ok.name}") ~> zoneRoute ~> check { diff --git a/modules/core/src/main/scala/vinyldns/core/domain/record/ChangeSet.scala b/modules/core/src/main/scala/vinyldns/core/domain/record/ChangeSet.scala index 290e1fbf2..31ee25d88 100644 --- a/modules/core/src/main/scala/vinyldns/core/domain/record/ChangeSet.scala +++ b/modules/core/src/main/scala/vinyldns/core/domain/record/ChangeSet.scala @@ -50,6 +50,11 @@ case class ChangeSet( status: ChangeSetStatus ) { + def withRecordSetChange(recordSetChanges: Seq[RecordSetChange]): ChangeSet = + copy( + changes = recordSetChanges + ) + def complete(change: RecordSetChange): ChangeSet = { val updatedChanges = this.changes.filterNot(_.id == change.id) :+ change if (isFinished) diff --git a/modules/core/src/main/scala/vinyldns/core/domain/record/RecordChangeRepository.scala b/modules/core/src/main/scala/vinyldns/core/domain/record/RecordChangeRepository.scala index cd338f5de..b691c845e 100644 --- a/modules/core/src/main/scala/vinyldns/core/domain/record/RecordChangeRepository.scala +++ b/modules/core/src/main/scala/vinyldns/core/domain/record/RecordChangeRepository.scala @@ -18,6 +18,7 @@ package vinyldns.core.domain.record import cats.effect._ import scalikejdbc.DB +import vinyldns.core.domain.record.RecordType.RecordType import vinyldns.core.repository.Repository trait RecordChangeRepository extends Repository { @@ -25,9 +26,11 @@ trait RecordChangeRepository extends Repository { def save(db: DB, changeSet: ChangeSet): IO[ChangeSet] def listRecordSetChanges( - zoneId: String, + zoneId: Option[String], startFrom: Option[Int] = None, - maxItems: Int = 100 + maxItems: Int = 100, + fqdn: Option[String] = None, + recordType: Option[RecordType] = None ): IO[ListRecordSetChangesResults] def getRecordSetChange(zoneId: String, changeId: String): IO[Option[RecordSetChange]] diff --git a/modules/docs/src/main/mdoc/operator/config-api.md b/modules/docs/src/main/mdoc/operator/config-api.md index 1e9b4563e..37026de6c 100644 --- a/modules/docs/src/main/mdoc/operator/config-api.md +++ b/modules/docs/src/main/mdoc/operator/config-api.md @@ -15,12 +15,13 @@ section: "operator_menu" - [Queue Configuration](#queue-configuration) - [Database Configuration](#database-configuration) - [Cryptography](#cryptography-settings) +- [Zone Connections](#zone-connections) - [Additional Configuration Settings](#additional-configuration-settings) - [Full Example Config](#full-example-config) There are a lot of configuration settings in VinylDNS. So much so that it may seem overwhelming to configure vinyldns to your environment. This document describes the configuration settings, highlighting the settings you are _most likely to -change_. All of the configuration settings are captured at the end. +change_. All the configuration settings are captured at the end. It is important to note that the `api` and `portal` have _different_ configuration. We will review the configuration for each separately. @@ -271,7 +272,7 @@ vinyldns { } ``` -## Default Zone Connections +## Zone Connections VinylDNS has three ways of indicating zone connections: @@ -291,6 +292,7 @@ VinylDNS also ties in testing network connectivity to the default zone connectio checks. A value for the health check connection timeout in milliseconds can be specified using `health-check-timeout`; a default value of 10000 will be used if not provided. +### Global Zone Connections Configuration: ```yaml vinyldns { @@ -347,6 +349,93 @@ vinyldns { ] ``` +### Alternate Zone Connections Configuration: +Below is an alternate way of setting zone connections configuration instead of using the [Global Zone Connections +Configuration](#global-zone-connections-configuration) +```yaml +# configured backend providers +backend { +# Use "default" when dns backend legacy = true +# otherwise, use the id of one of the connections in any of your backends +default-backend-id = "default" + +# this is where we can save additional backends +backend-providers = [ + { + class-name = "vinyldns.api.backend.dns.DnsBackendProviderLoader" + settings = { + legacy = false + backends = [ + { + id = "default" + zone-connection = { + name = "vinyldns." + key-name = "vinyldns." + key = "nzisn+4G2ldMn0q1CV3vsg==" + primary-server = "127.0.0.1:19001" + } + transfer-connection = { + name = "vinyldns." + key-name = "vinyldns." + key = "nzisn+4G2ldMn0q1CV3vsg==" + primary-server = "127.0.0.1:19001" + }, + tsig-usage = "always" + }, + { + id = "func-test-backend" + zone-connection = { + name = "vinyldns." + key-name = "vinyldns." + key = "nzisn+4G2ldMn0q1CV3vsg==" + primary-server = "127.0.0.1:19001" + } + transfer-connection = { + name = "vinyldns." + key-name = "vinyldns." + key = "nzisn+4G2ldMn0q1CV3vsg==" + primary-server = "127.0.0.1:19001" + }, + tsig-usage = "always" + } + ] + } + } +] +} +``` + +Below is an example configuration of backend provider for AWS Route 53, in case we want to use AWS Route 53 as backend. +```yaml +backend { + default-backend-id = "r53" + + backend-providers = [ + { + class-name = "vinyldns.route53.backend.Route53BackendProviderLoader" + settings = { + backends = [ + { + # AWS access key and secret key. + access-key = "your-access-key" + secret-key = "your-secret-key" + + # Regional endpoint to make your requests (eg. 'us-west-2', 'us-east-1', etc.). This is the region where your queue is housed. + signing-region = "us-east-1" + + # Endpoint to access r53 + service-endpoint = "https://route53.amazonaws.com/" + + id = "r53" + } + ] + } + } + ] + } +``` +Make sure to add AWS name servers in [Approved Name Servers Config](#approved-name-servers). + ## Additional Configuration Settings ### Approved Name Servers diff --git a/modules/mysql/src/it/scala/vinyldns/mysql/repository/MySqlRecordChangeRepositoryIntegrationSpec.scala b/modules/mysql/src/it/scala/vinyldns/mysql/repository/MySqlRecordChangeRepositoryIntegrationSpec.scala index 4a76f0664..813111e33 100644 --- a/modules/mysql/src/it/scala/vinyldns/mysql/repository/MySqlRecordChangeRepositoryIntegrationSpec.scala +++ b/modules/mysql/src/it/scala/vinyldns/mysql/repository/MySqlRecordChangeRepositoryIntegrationSpec.scala @@ -22,10 +22,12 @@ import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach} import org.scalatest.matchers.should.Matchers import org.scalatest.wordspec.AnyWordSpec import scalikejdbc._ -import vinyldns.core.domain.record.{ChangeSet, RecordChangeRepository, RecordSetChange, RecordSetChangeStatus, RecordSetChangeType} +import vinyldns.core.domain.record.{ChangeSet, RecordChangeRepository, RecordSetChange, RecordSetChangeStatus, RecordSetChangeType, RecordType} import vinyldns.core.domain.zone.Zone import vinyldns.mysql.TestMySqlInstance import vinyldns.mysql.TransactionProvider +import java.time.Instant + class MySqlRecordChangeRepositoryIntegrationSpec extends AnyWordSpec with Matchers @@ -60,6 +62,15 @@ class MySqlRecordChangeRepositoryIntegrationSpec newRecordSets.map(makeTestAddChange(_, zone)).toList } + def generateSameInserts(zone: Zone, count: Int): List[RecordSetChange] = { + val newRecordSets = + for { + i <- 1 to count + } yield aaaa.copy(zoneId = zone.id, name = s"apply-test", id = UUID.randomUUID().toString, created = Instant.now.plusSeconds(i)) + + newRecordSets.map(makeTestAddChange(_, zone)).toList + } + def generateFailedInserts(zone: Zone, count: Int): List[RecordSetChange] = { val newRecordSets = for { @@ -102,7 +113,7 @@ class MySqlRecordChangeRepositoryIntegrationSpec repo.save(db, ChangeSet(inserts)) } saveRecChange.attempt.unsafeRunSync() shouldBe right - val result = repo.listRecordSetChanges(okZone.id, None, 5).unsafeRunSync() + val result = repo.listRecordSetChanges(Some(okZone.id), None, 5).unsafeRunSync() result.nextId shouldBe defined result.maxItems shouldBe 5 (result.items should have).length(5) @@ -121,21 +132,48 @@ class MySqlRecordChangeRepositoryIntegrationSpec repo.save(db, ChangeSet(timeSpaced)) } saveRecChange.attempt.unsafeRunSync() shouldBe right - val page1 = repo.listRecordSetChanges(okZone.id, None, 2).unsafeRunSync() + val page1 = repo.listRecordSetChanges(Some(okZone.id), None, 2).unsafeRunSync() page1.nextId shouldBe Some(2) page1.maxItems shouldBe 2 (page1.items should contain).theSameElementsInOrderAs(expectedOrder.take(2)) - val page2 = repo.listRecordSetChanges(okZone.id, page1.nextId, 2).unsafeRunSync() + val page2 = repo.listRecordSetChanges(Some(okZone.id), page1.nextId, 2).unsafeRunSync() page2.nextId shouldBe Some(4) page2.maxItems shouldBe 2 (page2.items should contain).theSameElementsInOrderAs(expectedOrder.slice(2, 4)) - val page3 = repo.listRecordSetChanges(okZone.id, page2.nextId, 2).unsafeRunSync() + val page3 = repo.listRecordSetChanges(Some(okZone.id), page2.nextId, 2).unsafeRunSync() page3.nextId shouldBe None page3.maxItems shouldBe 2 page3.items should contain theSameElementsAs expectedOrder.slice(4, 5) } + "list a particular recordset's changes by fqdn and record type" in { + val inserts = generateInserts(okZone, 10) + val saveRecChange = executeWithinTransaction { db: DB => + repo.save(db, ChangeSet(inserts)) + } + saveRecChange.attempt.unsafeRunSync() shouldBe right + val result = repo.listRecordSetChanges(None, None, 5, Some("1-apply-test.ok.zone.recordsets."), Some(RecordType.AAAA)).unsafeRunSync() + result.nextId shouldBe None + result.maxItems shouldBe 5 + (result.items should have).length(1) + } + "page through a particular recordset's changes by fqdn and record type" in { + val inserts = generateSameInserts(okZone, 8) + val saveRecChange = executeWithinTransaction { db: DB => + repo.save(db, ChangeSet(inserts)) + } + saveRecChange.attempt.unsafeRunSync() shouldBe right + val page1 = repo.listRecordSetChanges(None, None, 5, Some("apply-test.ok.zone.recordsets."), Some(RecordType.AAAA)).unsafeRunSync() + page1.nextId shouldBe defined + page1.maxItems shouldBe 5 + (page1.items should have).length(5) + + val page2 = repo.listRecordSetChanges(None, page1.nextId, 5, Some("apply-test.ok.zone.recordsets."), Some(RecordType.AAAA)).unsafeRunSync() + page2.nextId shouldBe None + page2.maxItems shouldBe 5 + (page2.items should have).length(3) + } } "list failed record changes" should { diff --git a/modules/mysql/src/main/resources/db/migration/V3.29__AddRecordChangeFields.sql b/modules/mysql/src/main/resources/db/migration/V3.29__AddRecordChangeFields.sql new file mode 100644 index 000000000..cac0452fd --- /dev/null +++ b/modules/mysql/src/main/resources/db/migration/V3.29__AddRecordChangeFields.sql @@ -0,0 +1,8 @@ +CREATE SCHEMA IF NOT EXISTS ${dbName}; + +USE ${dbName}; + +ALTER TABLE record_change ADD COLUMN fqdn VARCHAR(255) NOT NULL; +ALTER TABLE record_change ADD COLUMN record_type VARCHAR(255) NOT NULL; +CREATE INDEX fqdn_index ON record_change (fqdn); +CREATE INDEX record_type_index ON record_change (record_type); diff --git a/modules/mysql/src/main/scala/vinyldns/mysql/repository/MySqlRecordChangeRepository.scala b/modules/mysql/src/main/scala/vinyldns/mysql/repository/MySqlRecordChangeRepository.scala index c353aa7fd..f847152f5 100644 --- a/modules/mysql/src/main/scala/vinyldns/mysql/repository/MySqlRecordChangeRepository.scala +++ b/modules/mysql/src/main/scala/vinyldns/mysql/repository/MySqlRecordChangeRepository.scala @@ -19,9 +19,11 @@ package vinyldns.mysql.repository import cats.effect._ import scalikejdbc._ import vinyldns.core.domain.record.RecordSetChangeType.RecordSetChangeType +import vinyldns.core.domain.record.RecordType.RecordType import vinyldns.core.domain.record._ import vinyldns.core.protobuf.ProtobufConversions import vinyldns.core.route.Monitored +import vinyldns.mysql.repository.MySqlRecordSetRepository.fromRecordType import vinyldns.proto.VinylDNSProto class MySqlRecordChangeRepository @@ -39,6 +41,24 @@ class MySqlRecordChangeRepository | LIMIT {limit} OFFSET {startFrom} """.stripMargin + private val LIST_CHANGES_WITH_START_FQDN_TYPE = + sql""" + |SELECT data + | FROM record_change + | WHERE fqdn = {fqdn} AND record_type = {type} + | ORDER BY created DESC + | LIMIT {limit} OFFSET {startFrom} + """.stripMargin + + private val LIST_CHANGES_WITHOUT_START_FQDN_TYPE = + sql""" + |SELECT data + | FROM record_change + | WHERE fqdn = {fqdn} AND record_type = {type} + | ORDER BY created DESC + | LIMIT {limit} + """.stripMargin + private val LIST_RECORD_CHANGES = sql""" |SELECT data @@ -63,7 +83,7 @@ class MySqlRecordChangeRepository """.stripMargin private val INSERT_CHANGES = - sql"INSERT IGNORE INTO record_change (id, zone_id, created, type, data) VALUES (?, ?, ?, ?, ?)" + sql"INSERT IGNORE INTO record_change (id, zone_id, created, type, fqdn, record_type, data) VALUES (?, ?, ?, ?, ?, ?, ?)" /** * We have the same issue with changes as record sets, namely we may have to save millions of them @@ -82,7 +102,9 @@ class MySqlRecordChangeRepository change.zoneId, change.created.toEpochMilli, fromChangeType(change.changeType), - toPB(change).toByteArray + if(change.recordSet.name == change.zone.name) change.zone.name else change.recordSet.name + "." + change.zone.name, + fromRecordType(change.recordSet.typ), + toPB(change).toByteArray, ) } } @@ -94,33 +116,52 @@ class MySqlRecordChangeRepository } def listRecordSetChanges( - zoneId: String, + zoneId: Option[String], startFrom: Option[Int], - maxItems: Int + maxItems: Int, + fqdn: Option[String], + recordType: Option[RecordType] ): IO[ListRecordSetChangesResults] = monitor("repo.RecordChange.listRecordSetChanges") { IO { DB.readOnly { implicit s => - val changes = startFrom match { - case Some(start) => - LIST_CHANGES_WITH_START - .bindByName('zoneId -> zoneId, 'startFrom -> start, 'limit -> maxItems) - .map(toRecordSetChange) - .list() - .apply() - case None => - LIST_CHANGES_NO_START - .bindByName('zoneId -> zoneId, 'limit -> maxItems) - .map(toRecordSetChange) - .list() - .apply() + val changes = if(startFrom.isDefined && fqdn.isDefined && recordType.isDefined){ + LIST_CHANGES_WITH_START_FQDN_TYPE + .bindByName('fqdn -> fqdn.get, 'type -> fromRecordType(recordType.get), 'startFrom -> startFrom.get, 'limit -> (maxItems + 1)) + .map(toRecordSetChange) + .list() + .apply() + } else if(fqdn.isDefined && recordType.isDefined){ + LIST_CHANGES_WITHOUT_START_FQDN_TYPE + .bindByName('fqdn -> fqdn.get, 'type -> fromRecordType(recordType.get), 'limit -> (maxItems + 1)) + .map(toRecordSetChange) + .list() + .apply() + } else if(startFrom.isDefined){ + LIST_CHANGES_WITH_START + .bindByName('zoneId -> zoneId.get, 'startFrom -> startFrom.get, 'limit -> (maxItems + 1)) + .map(toRecordSetChange) + .list() + .apply() + } else { + LIST_CHANGES_NO_START + .bindByName('zoneId -> zoneId.get, 'limit -> (maxItems + 1)) + .map(toRecordSetChange) + .list() + .apply() } + val maxQueries = changes.take(maxItems) val startValue = startFrom.getOrElse(0) - val nextId = if (changes.size < maxItems) None else Some(startValue + maxItems) + + // earlier maxItems was incremented, if the (maxItems + 1) size is not reached then pages are exhausted + val nextId = changes match { + case _ if changes.size <= maxItems | changes.isEmpty => None + case _ => Some(startValue + maxItems) + } ListRecordSetChangesResults( - changes, + maxQueries, nextId, startFrom, maxItems diff --git a/modules/portal/app/controllers/FrontendController.scala b/modules/portal/app/controllers/FrontendController.scala index e67ee1bf2..d51bf4029 100644 --- a/modules/portal/app/controllers/FrontendController.scala +++ b/modules/portal/app/controllers/FrontendController.scala @@ -76,7 +76,8 @@ class FrontendController @Inject() ( } def viewRecordSets(): Action[AnyContent] = userAction.async { implicit request => - Future(Ok(views.html.recordsets.recordSets(request.user.userName))) + val canReview = request.user.isSuper || request.user.isSupport + Future(Ok(views.html.recordsets.recordSets(request.user.userName, canReview))) } def viewAllBatchChanges(): Action[AnyContent] = userAction.async { implicit request => diff --git a/modules/portal/app/controllers/VinylDNS.scala b/modules/portal/app/controllers/VinylDNS.scala index 05e728707..7daa0c0d0 100644 --- a/modules/portal/app/controllers/VinylDNS.scala +++ b/modules/portal/app/controllers/VinylDNS.scala @@ -458,6 +458,16 @@ class VinylDNS @Inject() ( // $COVERAGE-ON$ } + def getCommonZoneDetails(id: String): Action[AnyContent] = userAction.async { implicit request => + // $COVERAGE-OFF$ + val vinyldnsRequest = new VinylDNSRequest("GET", s"$vinyldnsServiceBackend", s"zones/$id/details") + executeRequest(vinyldnsRequest, request.user).map(response => { + Status(response.status)(response.body) + .withHeaders(cacheHeaders: _*) + }) + // $COVERAGE-ON$ + } + def getZoneByName(name: String): Action[AnyContent] = userAction.async { implicit request => val vinyldnsRequest = new VinylDNSRequest("GET", s"$vinyldnsServiceBackend", s"zones/name/$name") @@ -590,6 +600,25 @@ class VinylDNS @Inject() ( // $COVERAGE-ON$ } + def listRecordSetChangeHistory: Action[AnyContent] = userAction.async { implicit request => + // $COVERAGE-OFF$ + val queryParameters = new HashMap[String, java.util.List[String]]() + for { + (name, values) <- request.queryString + } queryParameters.put(name, values.asJava) + val vinyldnsRequest = new VinylDNSRequest( + "GET", + s"$vinyldnsServiceBackend", + s"recordsetchange/history", + parameters = queryParameters + ) + executeRequest(vinyldnsRequest, request.user).map(response => { + Status(response.status)(response.body) + .withHeaders(cacheHeaders: _*) + }) + // $COVERAGE-ON$ + } + def addZone(): Action[AnyContent] = userAction.async { implicit request => // $COVERAGE-OFF$ val json = request.body.asJson diff --git a/modules/portal/app/views/recordsets/recordSets.scala.html b/modules/portal/app/views/recordsets/recordSets.scala.html index 78e5b820b..5331bccec 100644 --- a/modules/portal/app/views/recordsets/recordSets.scala.html +++ b/modules/portal/app/views/recordsets/recordSets.scala.html @@ -1,4 +1,4 @@ -@(rootAccountName: String)(implicit request: play.api.mvc.Request[Any], customLinks: models.CustomLinks, meta: models.Meta) +@(rootAccountName: String, rootAccountCanReview: Boolean)(implicit request: play.api.mvc.Request[Any], customLinks: models.CustomLinks, meta: models.Meta) @content = { @@ -81,7 +81,6 @@
-
@@ -167,6 +166,7 @@ @if(meta.sharedDisplayEnabled) { Owner Group Name } + Record History @@ -374,6 +374,9 @@ title="Group with ID {{record.ownerGroupId}} no longer exists."> Group deleted } + + + @@ -402,6 +405,102 @@
+ + + + } @plugins = { diff --git a/modules/portal/conf/routes b/modules/portal/conf/routes index ea70657e2..f0dc50335 100644 --- a/modules/portal/conf/routes +++ b/modules/portal/conf/routes @@ -29,6 +29,7 @@ GET /api/recordsets @controllers.VinylDNS.listRecor GET /api/zones @controllers.VinylDNS.getZones GET /api/zones/backendids @controllers.VinylDNS.getBackendIds GET /api/zones/:id @controllers.VinylDNS.getZone(id: String) +GET /api/zones/:id/details @controllers.VinylDNS.getCommonZoneDetails(id: String) GET /api/zones/name/:name @controllers.VinylDNS.getZoneByName(name: String) GET /api/zones/:id/changes @controllers.VinylDNS.getZoneChange(id: String) POST /api/zones @controllers.VinylDNS.addZone @@ -42,6 +43,7 @@ DELETE /api/zones/:zid/recordsets/:rid @controllers.VinylDNS.deleteRec PUT /api/zones/:zid/recordsets/:rid @controllers.VinylDNS.updateRecordSet(zid: String, rid:String) GET /api/zones/:id/recordsetchanges @controllers.VinylDNS.listRecordSetChanges(id: String) +GET /api/recordsetchange/history @controllers.VinylDNS.listRecordSetChangeHistory GET /api/groups @controllers.VinylDNS.getGroups GET /api/groups/:gid @controllers.VinylDNS.getGroup(gid: String) diff --git a/modules/portal/public/css/vinyldns.css b/modules/portal/public/css/vinyldns.css index c8056ffcf..57927b27f 100644 --- a/modules/portal/public/css/vinyldns.css +++ b/modules/portal/public/css/vinyldns.css @@ -563,6 +563,10 @@ input[type="file"] { } /* Ending of css override for cron library and it's associated elements used in zone sync scheduling */ +.set-width { + width: auto; +} + #set-dropdown-width { width: 4em; } diff --git a/modules/portal/public/lib/controllers/controller.manageZones.js b/modules/portal/public/lib/controllers/controller.manageZones.js index 01991f4e3..572b0e201 100644 --- a/modules/portal/public/lib/controllers/controller.manageZones.js +++ b/modules/portal/public/lib/controllers/controller.manageZones.js @@ -237,6 +237,9 @@ angular.module('controller.manageZones', ['angular-cron-jobs']) $scope.submitUpdateZone = function () { var zone = angular.copy($scope.updateZoneInfo); + if(zone['recurrenceSchedule'] == ""){ + delete zone['recurrenceSchedule'] + } zone = zonesService.normalizeZoneDates(zone); zone = zonesService.setConnectionKeys(zone); zone = zonesService.checkBackendId(zone); diff --git a/modules/portal/public/lib/recordset/recordsets.controller.js b/modules/portal/public/lib/recordset/recordsets.controller.js index fa78cb765..c31d91b2b 100644 --- a/modules/portal/public/lib/recordset/recordsets.controller.js +++ b/modules/portal/public/lib/recordset/recordsets.controller.js @@ -29,6 +29,51 @@ $scope.readRecordTypes = ['A', 'AAAA', 'CNAME', 'DS', 'MX', 'NS', 'PTR', "SOA", 'SRV', 'NAPTR', 'SSHFP', 'TXT']; $scope.selectedRecordTypes = []; $scope.groups = []; + $scope.recordFqdn = undefined; + $scope.recordType = undefined; + $scope.recordsetChanges = {}; + $scope.currentRecord = {}; + $scope.zoneInfo = {}; + $scope.profile = {}; + $scope.recordTypes = ['A', 'AAAA', 'CNAME', 'DS', 'MX', 'NS', 'PTR', 'SRV', 'NAPTR', 'SSHFP', 'TXT', 'SOA']; + $scope.sshfpAlgorithms = [{name: '(1) RSA', number: 1}, {name: '(2) DSA', number: 2}, {name: '(3) ECDSA', number: 3}, + {name: '(4) Ed25519', number: 4}]; + $scope.sshfpTypes = [{name: '(1) SHA-1', number: 1}, {name: '(2) SHA-256', number: 2}]; + $scope.dsAlgorithms = [{name: '(3) DSA', number: 3}, {name: '(5) RSASHA1', number: 5}, + {name: '(6) DSA_NSEC3_SHA1', number: 6}, {name: '(7) RSASHA1_NSEC3_SHA1' , number: 7}, + {name: '(8) RSASHA256', number: 8}, {name: '(10) RSASHA512' , number: 10}, + {name: '(12) ECC_GOST', number: 12}, {name: '(13) ECDSAP256SHA256' , number: 13}, + {name: '(14) ECDSAP384SHA384', number: 14}, {name: '(15) ED25519', number: 15}, + {name: '(16) ED448', number: 16},{name: '(253) PRIVATEDNS', number: 253}, + {name: '(254) PRIVATEOID', number: 254}] + $scope.dsDigestTypes = [{name: '(1) SHA1', number: 1}, {name: '(2) SHA256', number: 2}, {name: '(3) GOSTR341194', number: 3}, {name: '(4) SHA384', number: 4}] + $scope.isZoneAdmin = false; + $scope.canReadZone = false; + $scope.canCreateRecords = false; + $scope.zoneId = undefined; + $scope.recordModalState = { + CREATE: 0, + UPDATE: 1, + DELETE: 2, + CONFIRM_UPDATE: 3, + CONFIRM_DELETE: 4, + VIEW_DETAILS: 5 + }; + // read-only data for setting various classes/attributes in record modal + $scope.recordModalParams = { + readOnly: { + class: "", + readOnly: true + }, + editable: { + class: "record-edit", + readOnly: false + } + }; + $scope.userCanAccessGroup = false; + + // paging status for record changes + var changePaging = pagingService.getNewPagingParams(100); // paging status for recordsets var recordsPaging = pagingService.getNewPagingParams(100); @@ -68,6 +113,13 @@ .append("
" + recordSet + "
") .appendTo(ul); }; + $scope.viewRecordHistory = function(recordFqdn, recordType) { + $scope.recordFqdn = recordFqdn; + $scope.recordType = recordType; + $scope.refreshRecordChangeHistory($scope.recordFqdn, $scope.recordType); + $("#record_history_modal").modal("show"); + }; + $scope.refreshRecords = function() { if($scope.query.includes("|")) { const queryRecord = $scope.query.split('|'); @@ -80,6 +132,7 @@ function success(response) { recordsPaging.next = response.data.nextId; updateRecordDisplay(response.data['recordSets']); + getMembership(); } return recordsService .listRecordSetData(recordsPaging.maxItems, undefined, recordName, recordType, $scope.nameSort, $scope.ownerGroupFilter) @@ -131,6 +184,11 @@ newRecords.push(recordsService.toDisplayRecord(record, '')); }); $scope.records = newRecords; + for(var i = 0; i < $scope.records.length; i++) { + if (!$scope.records[i].zoneShared){ + getZone($scope.records[i].zoneId, i); + } + } if ($scope.records.length > 0) { $("#ShowNoRec").modal("hide"); $("td.dataTables_empty").hide(); @@ -186,5 +244,142 @@ handleError(error, 'recordsService::nextPage-failure'); }); }; + + $scope.refreshRecordChangeHistory = function(recordFqdn, recordType) { + changePaging = pagingService.resetPaging(changePaging); + function success(response) { + $scope.zoneId = response.data.zoneId; + $scope.refreshZone(); + changePaging.next = response.data.nextId; + updateChangeDisplay(response.data.recordSetChanges) + } + return recordsService + .listRecordSetChangeHistory(changePaging.maxItems, undefined, recordFqdn, recordType) + .then(success) + .catch(function (error){ + handleError(error, 'recordsService::getRecordSetChangeHistory-failure'); + }); + }; + + /** + * Record change history paging + */ + + $scope.changeHistoryPrevPageEnabled = function() { + return pagingService.prevPageEnabled(changePaging); + }; + + $scope.changeHistoryNextPageEnabled = function() { + return pagingService.nextPageEnabled(changePaging); + }; + + $scope.changeHistoryPrevPage = function() { + var startFrom = pagingService.getPrevStartFrom(changePaging); + return recordsService + .listRecordSetChangeHistory(changePaging.maxItems, startFrom, $scope.recordFqdn, $scope.recordType) + .then(function(response) { + changePaging = pagingService.prevPageUpdate(response.data.nextId, changePaging); + updateChangeDisplay(response.data.recordSetChanges); + }) + .catch(function (error) { + handleError(error, 'recordsService::changePrevPage-failure'); + }); + }; + + $scope.changeHistoryNextPage = function() { + return recordsService + .listRecordSetChangeHistory(changePaging.maxItems, changePaging.next, $scope.recordFqdn, $scope.recordType) + .then(function(response) { + var changes = response.data.recordSetChanges; + changePaging = pagingService.nextPageUpdate(changes, response.data.nextId, changePaging); + if(changes.length > 0){ + updateChangeDisplay(changes); + } + }) + .catch(function (error) { + handleError(error, 'recordsService::changeNextPage-failure'); + }); + }; + + function updateChangeDisplay(changes) { + var newChanges = []; + angular.forEach(changes, function(change) { + newChanges.push(change); + }); + $scope.recordsetChanges = newChanges; + } + + $scope.getRecordChangeStatusLabel = function(status) { + switch(status) { + case 'Complete': + return 'success'; + case 'Failed': + return 'danger'; + default: + return 'info'; + } + }; + + $scope.viewRecordInfo = function(record) { + $scope.currentRecord = recordsService.toDisplayRecord(record); + $scope.recordModal = { + action: $scope.recordModalState.VIEW_DETAILS, + title: "Record Info", + basics: $scope.recordModalParams.readOnly, + details: $scope.recordModalParams.readOnly, + sharedZone: $scope.zoneInfo.shared, + sharedDisplayEnabled: $scope.sharedDisplayEnabled + }; + $("#record_modal").modal("show"); + }; + + $scope.refreshZone = function() { + function success(response) { + $log.debug('recordsService::getZone-success'); + $scope.zoneInfo = response.data.zone; + // Get current user's groups and determine if they're an admin of this zone + getMembership() + } + return recordsService + .getZone($scope.zoneId) + .then(success) + .catch(function (error){ + handleError(error, 'recordsService::getZone-catch'); + }); + }; + + function getZone(zoneId, index){ + recordsService + .getCommonZoneDetails(zoneId) + .then( + function (results) { + $scope.zoneDetails = results; + $scope.records[index].ownerGroupId = results.data.zone.adminGroupId; + $scope.records[index].ownerGroupName = results.data.zone.adminGroupName; + if($scope.records[index].ownerGroupName && $scope.canAccessGroup($scope.records[index].ownerGroupId)){ + $scope.userCanAccessGroup = true; + } + }) + .catch(function (error){ + handleError(error, 'recordsService::getCommonZoneDetails-catch'); + }); + } + + function getMembership(){ + groupsService + .getGroupsStored() + .then( + function (results) { + $scope.myGroups = results.groups; + $scope.myGroupIds = results.groups.map(function(grp) {return grp['id']}); + }) + .catch(function (error){ + handleError(error, 'groupsService::getGroupsStored-failure'); + }); + } + + $scope.canAccessGroup = function(groupId) { + return $scope.myGroupIds !== undefined && $scope.myGroupIds.indexOf(groupId) > -1; + }; }); })(); diff --git a/modules/portal/public/lib/services/records/service.records.js b/modules/portal/public/lib/services/records/service.records.js index c4fff2fe9..d34862b11 100644 --- a/modules/portal/public/lib/services/records/service.records.js +++ b/modules/portal/public/lib/services/records/service.records.js @@ -100,6 +100,10 @@ angular.module('service.records', []) return $http.get("/api/zones/"+zid); }; + this.getCommonZoneDetails = function (zid) { + return $http.get("/api/zones/"+zid+"/details"); + }; + this.syncZone = function (zid) { return $http.post("/api/zones/"+zid+"/sync", {}, {headers: utilityService.getCsrfHeader()}); }; @@ -108,7 +112,21 @@ angular.module('service.records', []) var url = '/api/zones/' + zid + '/recordsetchanges'; var params = { "maxItems": maxItems, - "startFrom": startFrom + "startFrom": startFrom, + "fqdn": undefined, + "recordType": undefined + }; + url = utilityService.urlBuilder(url, params); + return $http.get(url); + }; + + this.listRecordSetChangeHistory = function (maxItems, startFrom, fqdn, recordType) { + var url = '/api/recordsetchange/history'; + var params = { + "maxItems": maxItems, + "startFrom": startFrom, + "fqdn": fqdn, + "recordType": recordType }; url = utilityService.urlBuilder(url, params); return $http.get(url); diff --git a/modules/portal/test/controllers/FrontendControllerSpec.scala b/modules/portal/test/controllers/FrontendControllerSpec.scala index 2e97f589a..9fe9ade7f 100644 --- a/modules/portal/test/controllers/FrontendControllerSpec.scala +++ b/modules/portal/test/controllers/FrontendControllerSpec.scala @@ -238,6 +238,32 @@ class FrontendControllerSpec extends Specification with Mockito with TestApplica } } + "Get for '/recordsets'" should { + "redirect to the login page when a user is not logged in" in new WithApplication(app) { + val result = underTest.viewRecordSets()(FakeRequest(GET, "/recordsets")) + status(result) must equalTo(SEE_OTHER) + headers(result) must contain("Location" -> "/login?target=/recordsets") + } + "render the recordset view page when the user is logged in" in new WithApplication(app) { + val result = + underTest.viewRecordSets()( + FakeRequest(GET, "/recordsets").withSession("username" -> "frodo").withCSRFToken + ) + status(result) must beEqualTo(OK) + contentType(result) must beSome.which(_ == "text/html") + contentAsString(result) must contain("RecordSets | VinylDNS") + } + "redirect to the no access page when a user is locked out" in new WithApplication(app) { + val result = + lockedUserUnderTest.viewRecordSets()( + FakeRequest(GET, "/recordsets") + .withSession("username" -> "lockedFbaggins") + .withCSRFToken + ) + headers(result) must contain("Location" -> "/noaccess") + } + } + "Get for login" should { "with ldap enabled" should { "render the login page when the user is not logged in" in new WithApplication(app) { diff --git a/modules/portal/test/controllers/VinylDNSSpec.scala b/modules/portal/test/controllers/VinylDNSSpec.scala index 3067874d9..8c9aef9c0 100644 --- a/modules/portal/test/controllers/VinylDNSSpec.scala +++ b/modules/portal/test/controllers/VinylDNSSpec.scala @@ -1650,6 +1650,35 @@ class VinylDNSSpec extends Specification with Mockito with TestApplicationData w } } + ".getCommonZoneDetails" should { + "return unauthorized (401) if requesting user is not logged in" in new WithApplication(app) { + val client = mock[WSClient] + val underTest = withClient(client) + val result = + underTest.getCommonZoneDetails(hobbitZoneId)(FakeRequest(GET, s"/api/zones/$hobbitZoneId/details")) + + status(result) mustEqual 401 + hasCacheHeaders(result) + contentAsString(result) must beEqualTo("You are not logged in. Please login to continue.") + } + "return forbidden (403) if user account is locked" in new WithApplication(app) { + val client = mock[WSClient] + val underTest = withLockedClient(client) + val result = underTest.getCommonZoneDetails(hobbitZoneId)( + FakeRequest(GET, s"/api/zones/$hobbitZoneId/details").withSession( + "username" -> lockedFrodoUser.userName, + "accessKey" -> lockedFrodoUser.accessKey + ) + ) + + status(result) mustEqual 403 + hasCacheHeaders(result) + contentAsString(result) must beEqualTo( + s"User account for `${lockedFrodoUser.userName}` is locked." + ) + } + } + ".getZoneChange" should { "return ok (200) if the zoneChanges is found" in new WithApplication(app) { @@ -1947,6 +1976,37 @@ class VinylDNSSpec extends Specification with Mockito with TestApplicationData w } } + ".listRecordSetChangeHistory" should { + "return unauthorized (401) if requesting user is not logged in" in new WithApplication(app) { + val client = mock[WSClient] + val underTest = withClient(client) + val result = + underTest.listRecordSetChangeHistory()( + FakeRequest(GET, s"/api/recordsetchange/history") + ) + + status(result) mustEqual 401 + hasCacheHeaders(result) + contentAsString(result) must beEqualTo("You are not logged in. Please login to continue.") + } + "return forbidden (403) if user account is locked" in new WithApplication(app) { + val client = mock[WSClient] + val underTest = withLockedClient(client) + val result = underTest.listRecordSetChangeHistory()( + FakeRequest(GET, s"/api/recordsetchange/history").withSession( + "username" -> lockedFrodoUser.userName, + "accessKey" -> lockedFrodoUser.accessKey + ) + ) + + status(result) mustEqual 403 + hasCacheHeaders(result) + contentAsString(result) must beEqualTo( + s"User account for `${lockedFrodoUser.userName}` is locked." + ) + } + } + ".addZone" should { "return unauthorized (401) if requesting user is not logged in" in new WithApplication(app) { val client = mock[WSClient] diff --git a/modules/r53/src/main/scala/vinyldns/route53/backend/Route53Backend.scala b/modules/r53/src/main/scala/vinyldns/route53/backend/Route53Backend.scala index 9625b7e61..4ed2e7c21 100644 --- a/modules/r53/src/main/scala/vinyldns/route53/backend/Route53Backend.scala +++ b/modules/r53/src/main/scala/vinyldns/route53/backend/Route53Backend.scala @@ -83,7 +83,7 @@ class Route53Backend( val found = result.getHostedZones.asScala.toList.headOption.map { hz => val hzid = parseHostedZoneId(hz.getId) - // adds the hozted zone name and id to our cache if not present + // adds the hosted zone name and id to our cache if not present zoneMap.putIfAbsent(hz.getName, hzid) hzid }