2
0
mirror of https://github.com/VinylDNS/vinyldns synced 2025-09-04 00:05:12 +00:00

Merge branch 'master' into fix_change_failure_metrics

This commit is contained in:
Arpit Shah
2023-07-21 14:10:40 -04:00
committed by GitHub
34 changed files with 1173 additions and 70 deletions

View File

@@ -256,7 +256,7 @@ class MembershipService(
_ <- isGroupChangePresent(result).toResult _ <- isGroupChangePresent(result).toResult
_ <- canSeeGroup(result.get.newGroup.id, authPrincipal).toResult _ <- canSeeGroup(result.get.newGroup.id, authPrincipal).toResult
allUserIds = getGroupUserIds(Seq(result.get)) 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) groupChangeMessage <- determineGroupDifference(Seq(result.get), allUserMap)
groupChanges = (groupChangeMessage, Seq(result.get)).zipped.map{ (a, b) => b.copy(groupChangeMessage = Some(a)) } groupChanges = (groupChangeMessage, Seq(result.get)).zipped.map{ (a, b) => b.copy(groupChangeMessage = Some(a)) }
userIds = Seq(result.get).map(_.userId).toSet userIds = Seq(result.get).map(_.userId).toSet
@@ -276,7 +276,7 @@ class MembershipService(
.getGroupChanges(groupId, startFrom, maxItems) .getGroupChanges(groupId, startFrom, maxItems)
.toResult[ListGroupChangesResults] .toResult[ListGroupChangesResults]
allUserIds = getGroupUserIds(result.changes) 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) groupChangeMessage <- determineGroupDifference(result.changes, allUserMap)
groupChanges = (groupChangeMessage, result.changes).zipped.map{ (a, b) => b.copy(groupChangeMessage = Some(a)) } groupChanges = (groupChangeMessage, result.changes).zipped.map{ (a, b) => b.copy(groupChangeMessage = Some(a)) }
userIds = result.changes.map(_.userId).toSet userIds = result.changes.map(_.userId).toSet
@@ -316,7 +316,8 @@ class MembershipService(
sb.append(s"Group email changed to '${change.newGroup.email}'. ") sb.append(s"Group email changed to '${change.newGroup.email}'. ")
} }
if (change.oldGroup.get.description != change.newGroup.description) { 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) val adminAddDifference = change.newGroup.adminUserIds.diff(change.oldGroup.get.adminUserIds)
if (adminAddDifference.nonEmpty) { if (adminAddDifference.nonEmpty) {

View File

@@ -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( case class ListFailedRecordSetChangesResponse(
failedRecordSetChanges: List[RecordSetChange] = Nil, failedRecordSetChanges: List[RecordSetChange] = Nil,
nextId: Int, nextId: Int,

View File

@@ -576,20 +576,38 @@ class RecordSetService(
} yield change } yield change
def listRecordSetChanges( def listRecordSetChanges(
zoneId: String, zoneId: Option[String] = None,
startFrom: Option[Int] = None, startFrom: Option[Int] = None,
maxItems: Int = 100, maxItems: Int = 100,
fqdn: Option[String] = None,
recordType: Option[RecordType] = None,
authPrincipal: AuthPrincipal authPrincipal: AuthPrincipal
): Result[ListRecordSetChangesResponse] = ): Result[ListRecordSetChangesResponse] =
for { for {
zone <- getZone(zoneId) zone <- getZone(zoneId.get)
_ <- canSeeZone(authPrincipal, zone).toResult _ <- canSeeZone(authPrincipal, zone).toResult
recordSetChangesResults <- recordChangeRepository recordSetChangesResults <- recordChangeRepository
.listRecordSetChanges(zone.id, startFrom, maxItems) .listRecordSetChanges(Some(zone.id), startFrom, maxItems, fqdn, recordType)
.toResult[ListRecordSetChangesResults] .toResult[ListRecordSetChangesResults]
recordSetChangesInfo <- buildRecordSetChangeInfo(recordSetChangesResults.items) recordSetChangesInfo <- buildRecordSetChangeInfo(recordSetChangesResults.items)
} yield ListRecordSetChangesResponse(zoneId, recordSetChangesResults, recordSetChangesInfo) } 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 {
recordSetChangesResults <- recordChangeRepository
.listRecordSetChanges(zoneId, startFrom, maxItems, fqdn, recordType)
.toResult[ListRecordSetChangesResults]
recordSetChangesInfo <- buildRecordSetChangeInfo(recordSetChangesResults.items)
_ <- 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( def listFailedRecordSetChanges(
authPrincipal: AuthPrincipal, authPrincipal: AuthPrincipal,

View File

@@ -101,12 +101,23 @@ trait RecordSetServiceAlgebra {
): Result[RecordSetChange] ): Result[RecordSetChange]
def listRecordSetChanges( def listRecordSetChanges(
zoneId: String, zoneId: Option[String],
startFrom: Option[Int], startFrom: Option[Int],
maxItems: Int, maxItems: Int,
fqdn: Option[String],
recordType: Option[RecordType],
authPrincipal: AuthPrincipal authPrincipal: AuthPrincipal
): Result[ListRecordSetChangesResponse] ): Result[ListRecordSetChangesResponse]
def listRecordSetChangeHistory(
zoneId: Option[String],
startFrom: Option[Int],
maxItems: Int,
fqdn: Option[String],
recordType: Option[RecordType],
authPrincipal: AuthPrincipal
): Result[ListRecordSetHistoryResponse]
def listFailedRecordSetChanges( def listFailedRecordSetChanges(
authPrincipal: AuthPrincipal, authPrincipal: AuthPrincipal,
startFrom: Int, startFrom: Int,

View File

@@ -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( case class ZoneSummaryInfo(
name: String, name: String,
email: String, email: String,

View File

@@ -153,6 +153,12 @@ class ZoneService(
accessLevel = getZoneAccess(auth, zone) accessLevel = getZoneAccess(auth, zone)
} yield ZoneInfo(zone, aclInfo, groupName, accessLevel) } 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] = def getZoneByName(zoneName: String, auth: AuthPrincipal): Result[ZoneInfo] =
for { for {
zone <- getZoneByNameOrFail(ensureTrailingDot(zoneName)) zone <- getZoneByNameOrFail(ensureTrailingDot(zoneName))

View File

@@ -35,6 +35,8 @@ trait ZoneServiceAlgebra {
def getZone(zoneId: String, auth: AuthPrincipal): Result[ZoneInfo] 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 getZoneByName(zoneName: String, auth: AuthPrincipal): Result[ZoneInfo]
def listZones( def listZones(

View File

@@ -223,8 +223,8 @@ class RecordSetRoute(
} ~ } ~
path("zones" / Segment / "recordsetchanges") { zoneId => path("zones" / Segment / "recordsetchanges") { zoneId =>
(get & monitor("Endpoint.listRecordSetChanges")) { (get & monitor("Endpoint.listRecordSetChanges")) {
parameters("startFrom".as[Int].?, "maxItems".as[Int].?(DEFAULT_MAX_ITEMS)) { parameters("startFrom".as[Int].?, "maxItems".as[Int].?(DEFAULT_MAX_ITEMS), "fqdn".as[String].?, "recordType".as[String].?) {
(startFrom: Option[Int], maxItems: Int) => (startFrom: Option[Int], maxItems: Int, fqdn: Option[String], _: Option[String]) =>
handleRejections(invalidQueryHandler) { handleRejections(invalidQueryHandler) {
validate( validate(
check = 0 < maxItems && maxItems <= DEFAULT_MAX_ITEMS, check = 0 < maxItems && maxItems <= DEFAULT_MAX_ITEMS,
@@ -233,7 +233,34 @@ class RecordSetRoute(
) { ) {
authenticateAndExecute( authenticateAndExecute(
recordSetService 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 => ) { changes =>
complete(StatusCodes.OK, changes) complete(StatusCodes.OK, changes)
} }

View File

@@ -29,6 +29,7 @@ import vinyldns.core.domain.zone._
import scala.concurrent.duration._ import scala.concurrent.duration._
case class GetZoneResponse(zone: ZoneInfo) case class GetZoneResponse(zone: ZoneInfo)
case class GetZoneDetailsResponse(zone: ZoneDetails)
case class ZoneRejected(zone: Zone, errors: List[String]) case class ZoneRejected(zone: Zone, errors: List[String])
class ZoneRoute( 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 => path("zones" / Segment / "sync") { id =>
(post & monitor("Endpoint.syncZone")) { (post & monitor("Endpoint.syncZone")) {
authenticateAndExecute(zoneService.syncZone(id, _)) { chg => authenticateAndExecute(zoneService.syncZone(id, _)) { chg =>

View File

@@ -30,6 +30,38 @@ def check_changes_response(response, recordChanges=False, nextId=False, startFro
assert_that(change["userName"], is_("history-user")) 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): def test_list_recordset_changes_no_authorization(shared_zone_test_context):
""" """
Test that recordset changes without authorization fails 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_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")) 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"))

View File

@@ -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["shared"], is_(True))
assert_that(retrieved["accessLevel"], is_("Read")) assert_that(retrieved["accessLevel"], is_("Read"))
def test_get_zone_private_by_id_fails_without_access(shared_zone_test_context): def test_get_zone_private_by_id_fails_without_access(shared_zone_test_context):
""" """
Test get an existing zone by id without access 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) 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 @pytest.mark.serial
def test_get_zone_by_id_includes_acl_display_name(shared_zone_test_context): def test_get_zone_by_id_includes_acl_display_name(shared_zone_test_context):
""" """

View File

@@ -51,7 +51,8 @@ class VinylDNSClient(object):
self.session.close() self.session.close()
self.session_not_found_ok.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() session = session or requests.Session()
retry = Retry( retry = Retry(
total=retries, total=retries,
@@ -79,7 +80,8 @@ class VinylDNSClient(object):
session.mount("https://", adapter) session.mount("https://", adapter)
return session 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 # pull out status or None
status_code = kwargs.pop("status", None) status_code = kwargs.pop("status", None)
@@ -100,13 +102,15 @@ class VinylDNSClient(object):
for k, v in query.items()) for k, v in query.items())
if sign_request: 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: else:
signed_headers = headers or {} signed_headers = headers or {}
signed_body = body_string signed_body = body_string
if not_found_ok: 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: else:
response = self.session.request(method, url, data=signed_body, headers=signed_headers, **kwargs) response = self.session.request(method, url, data=signed_body, headers=signed_headers, **kwargs)
@@ -388,6 +392,17 @@ class VinylDNSClient(object):
return data 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): def get_zone_by_name(self, zone_name, **kwargs):
""" """
Gets a zone for the given zone name 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) response, data = self.make_request(url, "GET", self.headers, not_found_ok=True, **kwargs)
return data 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 Gets a list of zones that currently exist
:return: a list of zones :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) response, data = self.make_request(url, "GET", self.headers, None, not_found_ok=True, **kwargs)
return data 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 Retrieves all recordsets in a zone
:param zone_id: the zone to retrieve :param zone_id: the zone to retrieve
@@ -618,7 +656,8 @@ class VinylDNSClient(object):
_, data = self.make_request(url, "POST", self.headers, **kwargs) _, data = self.make_request(url, "POST", self.headers, **kwargs)
return data 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 Gets list of user's batch change summaries
:return: the content of the response :return: the content of the response
@@ -660,7 +699,8 @@ class VinylDNSClient(object):
:return: the content of the response :return: the content of the response
""" """
url = urljoin(self.index_url, "/zones/{0}/acl/rules".format(zone_id)) 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 return data
@@ -804,7 +844,8 @@ class VinylDNSClient(object):
while change["status"] != expected_status and retries > 0: while change["status"] != expected_status and retries > 0:
time.sleep(RETRY_WAIT) time.sleep(RETRY_WAIT)
retries -= 1 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: if type(latest_change) != str:
change = latest_change change = latest_change

View File

@@ -184,6 +184,9 @@ class RecordSetServiceSpec
doReturn(IO.pure(ListUsersResults(Seq(), None))) doReturn(IO.pure(ListUsersResults(Seq(), None)))
.when(mockUserRepo) .when(mockUserRepo)
.getUsers(Set.empty, None, None) .getUsers(Set.empty, None, None)
doReturn(IO.pure(Some(okGroup)))
.when(mockGroupRepo)
.getGroup(okGroup.id)
val result: RecordSetChange = val result: RecordSetChange =
underTest.addRecordSet(record, okAuth).map(_.asInstanceOf[RecordSetChange]).value.unsafeRunSync().toOption.get underTest.addRecordSet(record, okAuth).map(_.asInstanceOf[RecordSetChange]).value.unsafeRunSync().toOption.get
@@ -468,7 +471,7 @@ class RecordSetServiceSpec
result.recordSet.ownerGroupId shouldBe Some(okGroup.id) 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)) val record = aaaa.copy(ownerGroupId = Some(dummyGroup.id))
doReturn(IO.pure(List())) doReturn(IO.pure(List()))
@@ -583,6 +586,9 @@ class RecordSetServiceSpec
doReturn(IO.pure(ListUsersResults(listOfDummyUsers.toSeq, None))) doReturn(IO.pure(ListUsersResults(listOfDummyUsers.toSeq, None)))
.when(mockUserRepo) .when(mockUserRepo)
.getUsers(dummyGroup.memberIds, None, None) .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 // passes as all three properties within dotted hosts config (allowed zones, users and record types) are satisfied
val result: RecordSetChange = val result: RecordSetChange =
@@ -787,6 +793,9 @@ class RecordSetServiceSpec
doReturn(IO.pure(ListUsersResults(listOfDummyUsers.toSeq, None))) doReturn(IO.pure(ListUsersResults(listOfDummyUsers.toSeq, None)))
.when(mockUserRepo) .when(mockUserRepo)
.getUsers(dummyGroup.memberIds, None, None) .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 // 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 val result = underTest.addRecordSet(record, abcAuth).value.unsafeRunSync().swap.toOption.get
@@ -1932,13 +1941,13 @@ class RecordSetServiceSpec
doReturn(IO.pure(ListRecordSetChangesResults(completeRecordSetChanges))) doReturn(IO.pure(ListRecordSetChangesResults(completeRecordSetChanges)))
.when(mockRecordChangeRepo) .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))) doReturn(IO.pure(ListUsersResults(Seq(okUser), None)))
.when(mockUserRepo) .when(mockUserRepo)
.getUsers(any[Set[String]], any[Option[String]], any[Option[Int]]) .getUsers(any[Set[String]], any[Option[String]], any[Option[Int]])
val result: ListRecordSetChangesResponse = 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 = val changesWithName =
completeRecordSetChanges.map(change => RecordSetChangeInfo(change, Some("ok"))) completeRecordSetChanges.map(change => RecordSetChangeInfo(change, Some("ok")))
val expectedResults = ListRecordSetChangesResponse( val expectedResults = ListRecordSetChangesResponse(
@@ -1951,13 +1960,42 @@ class RecordSetServiceSpec
result shouldBe expectedResults 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 { "return a zone with no changes if no changes exist" in {
doReturn(IO.pure(ListRecordSetChangesResults(items = Nil))) doReturn(IO.pure(ListRecordSetChangesResults(items = Nil)))
.when(mockRecordChangeRepo) .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 = 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( val expectedResults = ListRecordSetChangesResponse(
zoneId = okZone.id, zoneId = okZone.id,
recordSetChanges = List(), recordSetChanges = List(),
@@ -2023,7 +2061,7 @@ class RecordSetServiceSpec
"return a NotAuthorizedError" in { "return a NotAuthorizedError" in {
val error = 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] error shouldBe a[NotAuthorizedError]
} }
@@ -2034,10 +2072,13 @@ class RecordSetServiceSpec
doReturn(IO.pure(ListRecordSetChangesResults(List(rsChange2, rsChange1)))) doReturn(IO.pure(ListRecordSetChangesResults(List(rsChange2, rsChange1))))
.when(mockRecordChangeRepo) .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 = 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 = val changesWithName =
List(RecordSetChangeInfo(rsChange2, Some("ok")), RecordSetChangeInfo(rsChange1, Some("ok"))) List(RecordSetChangeInfo(rsChange2, Some("ok")), RecordSetChangeInfo(rsChange1, Some("ok")))
val expectedResults = ListRecordSetChangesResponse( val expectedResults = ListRecordSetChangesResponse(

View File

@@ -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 { "ListZones" should {
"not fail with no zones returned" in { "not fail with no zones returned" in {
doReturn(IO.pure(ListZonesResults(List()))) doReturn(IO.pure(ListZonesResults(List())))

View File

@@ -556,7 +556,8 @@ class ZoneSyncHandlerSpec
verify(recordChangeRepo).save(any[DB], captor.capture()) verify(recordChangeRepo).save(any[DB], captor.capture())
val req = captor.getValue 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 { "save the record changes to the recordSetCacheRepo" in {
@@ -566,7 +567,8 @@ class ZoneSyncHandlerSpec
verify(recordSetCacheRepo).save(any[DB], captor.capture()) verify(recordSetCacheRepo).save(any[DB], captor.capture())
val req = captor.getValue 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()) verify(recordSetRepo).apply(any[DB], captor.capture())
val req = captor.getValue 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 { "returns the zone as active and sets the latest sync" in {

View File

@@ -29,7 +29,7 @@ import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpec import org.scalatest.wordspec.AnyWordSpec
import vinyldns.api.Interfaces._ import vinyldns.api.Interfaces._
import vinyldns.api.config.LimitsConfig 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.api.domain.zone._
import vinyldns.core.TestMembershipData.okAuth import vinyldns.core.TestMembershipData.okAuth
import vinyldns.core.domain.Fqdn import vinyldns.core.domain.Fqdn
@@ -409,6 +409,16 @@ class RecordSetRoutingSpec
maxItems = 100 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 = private val failedChangesWithUserName =
List(rsChange1.copy(status = RecordSetChangeStatus.Failed) , rsChange2.copy(status = RecordSetChangeStatus.Failed)) List(rsChange1.copy(status = RecordSetChangeStatus.Failed) , rsChange2.copy(status = RecordSetChangeStatus.Failed))
private val listFailedRecordSetChangeResponse = ListFailedRecordSetChangesResponse( private val listFailedRecordSetChangeResponse = ListFailedRecordSetChangesResponse(
@@ -736,18 +746,35 @@ class RecordSetRoutingSpec
}.toResult }.toResult
def listRecordSetChanges( def listRecordSetChanges(
zoneId: String, zoneId: Option[String],
startFrom: Option[Int], startFrom: Option[Int],
maxItems: Int, maxItems: Int,
fqdn: Option[String],
recordType: Option[RecordType],
authPrincipal: AuthPrincipal authPrincipal: AuthPrincipal
): Result[ListRecordSetChangesResponse] = { ): Result[ListRecordSetChangesResponse] = {
zoneId match { zoneId match {
case zoneNotFound.id => Left(ZoneNotFoundError(s"$zoneId")) case Some(zoneNotFound.id) => Left(ZoneNotFoundError(s"$zoneId"))
case notAuthorizedZone.id => Left(NotAuthorizedError("no way")) case Some(notAuthorizedZone.id) => Left(NotAuthorizedError("no way"))
case _ => Right(listRecordSetChangesResponse) case _ => Right(listRecordSetChangesResponse)
} }
}.toResult }.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 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 { "GET failed record set changes" should {
"return the failed record set changes" in { "return the failed record set changes" in {
val rsChangeFailed1 = rsChange1.copy(status = RecordSetChangeStatus.Failed) val rsChangeFailed1 = rsChange1.copy(status = RecordSetChangeStatus.Failed)

View File

@@ -82,6 +82,7 @@ class ZoneRoutingSpec
private val ok = Zone("ok.", "ok@test.com", acl = zoneAcl, adminGroupId = "test") private val ok = Zone("ok.", "ok@test.com", acl = zoneAcl, adminGroupId = "test")
private val aclAsInfo = ZoneACLInfo(zoneAcl.rules.map(ACLRuleInfo(_, Some("name")))) private val aclAsInfo = ZoneACLInfo(zoneAcl.rules.map(ACLRuleInfo(_, Some("name"))))
private val okAsZoneInfo = ZoneInfo(ok, aclAsInfo, okGroup.name, AccessLevel.Read) 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 badRegex = Zone("ok.", "bad-regex@test.com", adminGroupId = "test")
private val trailingDot = Zone("trailing.dot", "trailing-dot@test.com") private val trailingDot = Zone("trailing.dot", "trailing-dot@test.com")
private val connectionOk = Zone( private val connectionOk = Zone(
@@ -249,6 +250,15 @@ class ZoneRoutingSpec
outcome.toResult 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] = { def getZoneByName(zoneName: String, auth: AuthPrincipal): Result[ZoneInfo] = {
val outcome = zoneName match { val outcome = zoneName match {
case notFound.name => Left(ZoneNotFoundError(s"$zoneName")) 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 { "GET zone by name " should {
"return the zone is retrieved" in { "return the zone is retrieved" in {
Get(s"/zones/name/${ok.name}") ~> zoneRoute ~> check { Get(s"/zones/name/${ok.name}") ~> zoneRoute ~> check {

View File

@@ -50,6 +50,11 @@ case class ChangeSet(
status: ChangeSetStatus status: ChangeSetStatus
) { ) {
def withRecordSetChange(recordSetChanges: Seq[RecordSetChange]): ChangeSet =
copy(
changes = recordSetChanges
)
def complete(change: RecordSetChange): ChangeSet = { def complete(change: RecordSetChange): ChangeSet = {
val updatedChanges = this.changes.filterNot(_.id == change.id) :+ change val updatedChanges = this.changes.filterNot(_.id == change.id) :+ change
if (isFinished) if (isFinished)

View File

@@ -18,6 +18,7 @@ package vinyldns.core.domain.record
import cats.effect._ import cats.effect._
import scalikejdbc.DB import scalikejdbc.DB
import vinyldns.core.domain.record.RecordType.RecordType
import vinyldns.core.repository.Repository import vinyldns.core.repository.Repository
trait RecordChangeRepository extends Repository { trait RecordChangeRepository extends Repository {
@@ -25,9 +26,11 @@ trait RecordChangeRepository extends Repository {
def save(db: DB, changeSet: ChangeSet): IO[ChangeSet] def save(db: DB, changeSet: ChangeSet): IO[ChangeSet]
def listRecordSetChanges( def listRecordSetChanges(
zoneId: String, zoneId: Option[String],
startFrom: Option[Int] = None, startFrom: Option[Int] = None,
maxItems: Int = 100 maxItems: Int = 100,
fqdn: Option[String] = None,
recordType: Option[RecordType] = None
): IO[ListRecordSetChangesResults] ): IO[ListRecordSetChangesResults]
def getRecordSetChange(zoneId: String, changeId: String): IO[Option[RecordSetChange]] def getRecordSetChange(zoneId: String, changeId: String): IO[Option[RecordSetChange]]

View File

@@ -15,12 +15,13 @@ section: "operator_menu"
- [Queue Configuration](#queue-configuration) - [Queue Configuration](#queue-configuration)
- [Database Configuration](#database-configuration) - [Database Configuration](#database-configuration)
- [Cryptography](#cryptography-settings) - [Cryptography](#cryptography-settings)
- [Zone Connections](#zone-connections)
- [Additional Configuration Settings](#additional-configuration-settings) - [Additional Configuration Settings](#additional-configuration-settings)
- [Full Example Config](#full-example-config) - [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 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 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 It is important to note that the `api` and `portal` have _different_ configuration. We will review the configuration for
each separately. each separately.
@@ -271,7 +272,7 @@ vinyldns {
} }
``` ```
## Default Zone Connections ## Zone Connections
VinylDNS has three ways of indicating 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 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. default value of 10000 will be used if not provided.
### Global Zone Connections Configuration:
```yaml ```yaml
vinyldns { 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 ## Additional Configuration Settings
### Approved Name Servers ### Approved Name Servers

View File

@@ -22,10 +22,12 @@ import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach}
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpec import org.scalatest.wordspec.AnyWordSpec
import scalikejdbc._ 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.core.domain.zone.Zone
import vinyldns.mysql.TestMySqlInstance import vinyldns.mysql.TestMySqlInstance
import vinyldns.mysql.TransactionProvider import vinyldns.mysql.TransactionProvider
import java.time.Instant
class MySqlRecordChangeRepositoryIntegrationSpec class MySqlRecordChangeRepositoryIntegrationSpec
extends AnyWordSpec extends AnyWordSpec
with Matchers with Matchers
@@ -60,6 +62,15 @@ class MySqlRecordChangeRepositoryIntegrationSpec
newRecordSets.map(makeTestAddChange(_, zone)).toList 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] = { def generateFailedInserts(zone: Zone, count: Int): List[RecordSetChange] = {
val newRecordSets = val newRecordSets =
for { for {
@@ -102,7 +113,7 @@ class MySqlRecordChangeRepositoryIntegrationSpec
repo.save(db, ChangeSet(inserts)) repo.save(db, ChangeSet(inserts))
} }
saveRecChange.attempt.unsafeRunSync() shouldBe right 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.nextId shouldBe defined
result.maxItems shouldBe 5 result.maxItems shouldBe 5
(result.items should have).length(5) (result.items should have).length(5)
@@ -121,21 +132,48 @@ class MySqlRecordChangeRepositoryIntegrationSpec
repo.save(db, ChangeSet(timeSpaced)) repo.save(db, ChangeSet(timeSpaced))
} }
saveRecChange.attempt.unsafeRunSync() shouldBe right 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.nextId shouldBe Some(2)
page1.maxItems shouldBe 2 page1.maxItems shouldBe 2
(page1.items should contain).theSameElementsInOrderAs(expectedOrder.take(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.nextId shouldBe Some(4)
page2.maxItems shouldBe 2 page2.maxItems shouldBe 2
(page2.items should contain).theSameElementsInOrderAs(expectedOrder.slice(2, 4)) (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.nextId shouldBe None
page3.maxItems shouldBe 2 page3.maxItems shouldBe 2
page3.items should contain theSameElementsAs expectedOrder.slice(4, 5) 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 { "list failed record changes" should {

View File

@@ -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);

View File

@@ -19,9 +19,11 @@ package vinyldns.mysql.repository
import cats.effect._ import cats.effect._
import scalikejdbc._ import scalikejdbc._
import vinyldns.core.domain.record.RecordSetChangeType.RecordSetChangeType import vinyldns.core.domain.record.RecordSetChangeType.RecordSetChangeType
import vinyldns.core.domain.record.RecordType.RecordType
import vinyldns.core.domain.record._ import vinyldns.core.domain.record._
import vinyldns.core.protobuf.ProtobufConversions import vinyldns.core.protobuf.ProtobufConversions
import vinyldns.core.route.Monitored import vinyldns.core.route.Monitored
import vinyldns.mysql.repository.MySqlRecordSetRepository.fromRecordType
import vinyldns.proto.VinylDNSProto import vinyldns.proto.VinylDNSProto
class MySqlRecordChangeRepository class MySqlRecordChangeRepository
@@ -39,6 +41,24 @@ class MySqlRecordChangeRepository
| LIMIT {limit} OFFSET {startFrom} | LIMIT {limit} OFFSET {startFrom}
""".stripMargin """.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 = private val LIST_RECORD_CHANGES =
sql""" sql"""
|SELECT data |SELECT data
@@ -63,7 +83,7 @@ class MySqlRecordChangeRepository
""".stripMargin """.stripMargin
private val INSERT_CHANGES = 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 * 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.zoneId,
change.created.toEpochMilli, change.created.toEpochMilli,
fromChangeType(change.changeType), 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( def listRecordSetChanges(
zoneId: String, zoneId: Option[String],
startFrom: Option[Int], startFrom: Option[Int],
maxItems: Int maxItems: Int,
fqdn: Option[String],
recordType: Option[RecordType]
): IO[ListRecordSetChangesResults] = ): IO[ListRecordSetChangesResults] =
monitor("repo.RecordChange.listRecordSetChanges") { monitor("repo.RecordChange.listRecordSetChanges") {
IO { IO {
DB.readOnly { implicit s => DB.readOnly { implicit s =>
val changes = startFrom match { val changes = if(startFrom.isDefined && fqdn.isDefined && recordType.isDefined){
case Some(start) => LIST_CHANGES_WITH_START_FQDN_TYPE
LIST_CHANGES_WITH_START .bindByName('fqdn -> fqdn.get, 'type -> fromRecordType(recordType.get), 'startFrom -> startFrom.get, 'limit -> (maxItems + 1))
.bindByName('zoneId -> zoneId, 'startFrom -> start, 'limit -> maxItems)
.map(toRecordSetChange) .map(toRecordSetChange)
.list() .list()
.apply() .apply()
case None => } 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 LIST_CHANGES_NO_START
.bindByName('zoneId -> zoneId, 'limit -> maxItems) .bindByName('zoneId -> zoneId.get, 'limit -> (maxItems + 1))
.map(toRecordSetChange) .map(toRecordSetChange)
.list() .list()
.apply() .apply()
} }
val maxQueries = changes.take(maxItems)
val startValue = startFrom.getOrElse(0) 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( ListRecordSetChangesResults(
changes, maxQueries,
nextId, nextId,
startFrom, startFrom,
maxItems maxItems

View File

@@ -76,7 +76,8 @@ class FrontendController @Inject() (
} }
def viewRecordSets(): Action[AnyContent] = userAction.async { implicit request => 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 => def viewAllBatchChanges(): Action[AnyContent] = userAction.async { implicit request =>

View File

@@ -458,6 +458,16 @@ class VinylDNS @Inject() (
// $COVERAGE-ON$ // $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 => def getZoneByName(name: String): Action[AnyContent] = userAction.async { implicit request =>
val vinyldnsRequest = val vinyldnsRequest =
new VinylDNSRequest("GET", s"$vinyldnsServiceBackend", s"zones/name/$name") new VinylDNSRequest("GET", s"$vinyldnsServiceBackend", s"zones/name/$name")
@@ -590,6 +600,25 @@ class VinylDNS @Inject() (
// $COVERAGE-ON$ // $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 => def addZone(): Action[AnyContent] = userAction.async { implicit request =>
// $COVERAGE-OFF$ // $COVERAGE-OFF$
val json = request.body.asJson val json = request.body.asJson

View File

@@ -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 = { @content = {
<!-- PAGE CONTENT --> <!-- PAGE CONTENT -->
@@ -81,7 +81,6 @@
<div class="btn-group"> <div class="btn-group">
<button id="refresh-records-button" class="btn btn-default" ng-click="refreshRecords()"><span class="fa fa-refresh"></span> Refresh</button> <button id="refresh-records-button" class="btn btn-default" ng-click="refreshRecords()"><span class="fa fa-refresh"></span> Refresh</button>
<button id="create-record-button" class="btn btn-default" ng-if="canCreateRecords" ng-click="createRecord(defaultTtl)"><span class="fa fa-plus"></span> Create Record Set</button> <button id="create-record-button" class="btn btn-default" ng-if="canCreateRecords" ng-click="createRecord(defaultTtl)"><span class="fa fa-plus"></span> Create Record Set</button>
<button id="zone-sync-button" class="btn btn-default mb-control" ng-if="zoneInfo.accessLevel=='Delete'" data-toggle="modal" data-target="#mb-sync"><span class="fa fa-exchange"></span> Sync Zone</button>
</div> </div>
<div> <div>
@@ -167,6 +166,7 @@
@if(meta.sharedDisplayEnabled) { @if(meta.sharedDisplayEnabled) {
<th>Owner Group Name</th> <th>Owner Group Name</th>
} }
<th ng-if="rootAccountCanReview || userCanAccessGroup">Record History</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -374,6 +374,9 @@
title="Group with ID {{record.ownerGroupId}} no longer exists."><span class="fa fa-warning"></span> Group deleted</span> title="Group with ID {{record.ownerGroupId}} no longer exists."><span class="fa fa-warning"></span> Group deleted</span>
</td> </td>
} }
<td ng-if="rootAccountCanReview || (record.ownerGroupName && canAccessGroup(record.ownerGroupId))">
<span><button class="btn btn-info btn-sm" ng-click="viewRecordHistory(record.fqdn, record.type)">View History</button></span>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -402,6 +405,102 @@
</div> </div>
<!-- END PAGE CONTENT --> <!-- END PAGE CONTENT -->
<div class="modal fade in" id="record_history_modal">
<div class="modal-dialog set-width">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span>
</button>
<div class="modal-title">Record History</div>
</div>
<div class="modal-body">
<!-- START SIMPLE DATATABLE -->
<div class="panel panel-default" id="record_change_history">
<div class="panel-heading">
<h3 class="panel-title">Record Change History</h3>
</div>
<div class="panel-body">
<div class="btn-group">
<button class="btn btn-default" ng-click="refreshRecordChangeHistory(recordFqdn, recordType)"><span class="fa fa-refresh"></span> Refresh</button>
</div>
<!-- PAGINATION -->
<div class="dataTables_paginate">
<ul class="pagination">
<li class="paginate_button previous">
<a ng-if="changeHistoryPrevPageEnabled()" ng-click="changeHistoryPrevPage()" class="paginate_button">Previous</a>
</li>
<li class="paginate_button next">
<a ng-if="changeHistoryNextPageEnabled()" ng-click="changeHistoryNextPage()" class="paginate_button">Next</a>
</li>
</ul>
</div>
<!-- END PAGINATION -->
<table id="changeDataTable" class="table table-hover table-striped">
<thead>
<tr>
<th>Time</th>
<th class="col-md-5">Recordset Name</th>
<th>Recordset Type</th>
<th>Change Type</th>
<th>User</th>
<th>Status</th>
<th>Additional Info</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="change in recordsetChanges track by $index">
<td>{{change.created}}</td>
<td class="wrap-long-text">{{change.recordSet.name}}</td>
<td>{{change.recordSet.type}}</td>
<td>{{change.changeType}}</td>
<td>{{change.userName}}</td>
<td>
<span class="label label-{{ getRecordChangeStatusLabel(change.status) }}">{{ change.status }}</span>
</td>
<td class="col-md-3 wrap-long-text">
{{change.systemMessage}}
<div ng-if="change.status !='Failed'">
<a ng-if="change.changeType =='Create'" ng-click="viewRecordInfo(change.recordSet)" class="force-cursor">View created recordset</a>
<a ng-if="change.changeType =='Delete'" ng-click="viewRecordInfo(change.recordSet)" class="force-cursor">View deleted recordset</a>
<div><a ng-if="change.changeType =='Update'" ng-click="viewRecordInfo(change.recordSet)" class="force-cursor">View new recordset</a></div>
<div><a ng-if="change.changeType =='Update'" ng-click="viewRecordInfo(change.updates)" class="force-cursor">View old recordset</a></div>
</div>
</td>
</tr>
</tbody>
</table>
<!-- PAGINATION -->
<div class="dataTables_paginate">
<ul class="pagination">
<li class="paginate_button previous">
<a ng-if="changeHistoryPrevPageEnabled()" ng-click="changeHistoryPrevPage()" class="paginate_button">Previous</a>
</li>
<li class="paginate_button next">
<a ng-if="changeHistoryNextPageEnabled()" ng-click="changeHistoryNextPage()" class="paginate_button">Next</a>
</li>
</ul>
</div>
<!-- END PAGINATION -->
</div>
<div class="panel-footer"></div>
</div>
<!-- END SIMPLE DATATABLE -->
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<recordmodal></recordmodal>
} }
@plugins = { @plugins = {

View File

@@ -29,6 +29,7 @@ GET /api/recordsets @controllers.VinylDNS.listRecor
GET /api/zones @controllers.VinylDNS.getZones GET /api/zones @controllers.VinylDNS.getZones
GET /api/zones/backendids @controllers.VinylDNS.getBackendIds GET /api/zones/backendids @controllers.VinylDNS.getBackendIds
GET /api/zones/:id @controllers.VinylDNS.getZone(id: String) 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/name/:name @controllers.VinylDNS.getZoneByName(name: String)
GET /api/zones/:id/changes @controllers.VinylDNS.getZoneChange(id: String) GET /api/zones/:id/changes @controllers.VinylDNS.getZoneChange(id: String)
POST /api/zones @controllers.VinylDNS.addZone 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) 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/zones/:id/recordsetchanges @controllers.VinylDNS.listRecordSetChanges(id: String)
GET /api/recordsetchange/history @controllers.VinylDNS.listRecordSetChangeHistory
GET /api/groups @controllers.VinylDNS.getGroups GET /api/groups @controllers.VinylDNS.getGroups
GET /api/groups/:gid @controllers.VinylDNS.getGroup(gid: String) GET /api/groups/:gid @controllers.VinylDNS.getGroup(gid: String)

View File

@@ -563,6 +563,10 @@ input[type="file"] {
} }
/* Ending of css override for cron library and it's associated elements used in zone sync scheduling */ /* Ending of css override for cron library and it's associated elements used in zone sync scheduling */
.set-width {
width: auto;
}
#set-dropdown-width { #set-dropdown-width {
width: 4em; width: 4em;
} }

View File

@@ -237,6 +237,9 @@ angular.module('controller.manageZones', ['angular-cron-jobs'])
$scope.submitUpdateZone = function () { $scope.submitUpdateZone = function () {
var zone = angular.copy($scope.updateZoneInfo); var zone = angular.copy($scope.updateZoneInfo);
if(zone['recurrenceSchedule'] == ""){
delete zone['recurrenceSchedule']
}
zone = zonesService.normalizeZoneDates(zone); zone = zonesService.normalizeZoneDates(zone);
zone = zonesService.setConnectionKeys(zone); zone = zonesService.setConnectionKeys(zone);
zone = zonesService.checkBackendId(zone); zone = zonesService.checkBackendId(zone);

View File

@@ -29,6 +29,51 @@
$scope.readRecordTypes = ['A', 'AAAA', 'CNAME', 'DS', 'MX', 'NS', 'PTR', "SOA", 'SRV', 'NAPTR', 'SSHFP', 'TXT']; $scope.readRecordTypes = ['A', 'AAAA', 'CNAME', 'DS', 'MX', 'NS', 'PTR', "SOA", 'SRV', 'NAPTR', 'SSHFP', 'TXT'];
$scope.selectedRecordTypes = []; $scope.selectedRecordTypes = [];
$scope.groups = []; $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 // paging status for recordsets
var recordsPaging = pagingService.getNewPagingParams(100); var recordsPaging = pagingService.getNewPagingParams(100);
@@ -68,6 +113,13 @@
.append("<div>" + recordSet + "</div>") .append("<div>" + recordSet + "</div>")
.appendTo(ul); }; .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() { $scope.refreshRecords = function() {
if($scope.query.includes("|")) { if($scope.query.includes("|")) {
const queryRecord = $scope.query.split('|'); const queryRecord = $scope.query.split('|');
@@ -80,6 +132,7 @@
function success(response) { function success(response) {
recordsPaging.next = response.data.nextId; recordsPaging.next = response.data.nextId;
updateRecordDisplay(response.data['recordSets']); updateRecordDisplay(response.data['recordSets']);
getMembership();
} }
return recordsService return recordsService
.listRecordSetData(recordsPaging.maxItems, undefined, recordName, recordType, $scope.nameSort, $scope.ownerGroupFilter) .listRecordSetData(recordsPaging.maxItems, undefined, recordName, recordType, $scope.nameSort, $scope.ownerGroupFilter)
@@ -131,6 +184,11 @@
newRecords.push(recordsService.toDisplayRecord(record, '')); newRecords.push(recordsService.toDisplayRecord(record, ''));
}); });
$scope.records = newRecords; $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) { if ($scope.records.length > 0) {
$("#ShowNoRec").modal("hide"); $("#ShowNoRec").modal("hide");
$("td.dataTables_empty").hide(); $("td.dataTables_empty").hide();
@@ -186,5 +244,142 @@
handleError(error, 'recordsService::nextPage-failure'); 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;
};
}); });
})(); })();

View File

@@ -100,6 +100,10 @@ angular.module('service.records', [])
return $http.get("/api/zones/"+zid); return $http.get("/api/zones/"+zid);
}; };
this.getCommonZoneDetails = function (zid) {
return $http.get("/api/zones/"+zid+"/details");
};
this.syncZone = function (zid) { this.syncZone = function (zid) {
return $http.post("/api/zones/"+zid+"/sync", {}, {headers: utilityService.getCsrfHeader()}); 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 url = '/api/zones/' + zid + '/recordsetchanges';
var params = { var params = {
"maxItems": maxItems, "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); url = utilityService.urlBuilder(url, params);
return $http.get(url); return $http.get(url);

View File

@@ -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 { "Get for login" should {
"with ldap enabled" should { "with ldap enabled" should {
"render the login page when the user is not logged in" in new WithApplication(app) { "render the login page when the user is not logged in" in new WithApplication(app) {

View File

@@ -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 { ".getZoneChange" should {
"return ok (200) if the zoneChanges is found" in new WithApplication(app) { "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 { ".addZone" should {
"return unauthorized (401) if requesting user is not logged in" in new WithApplication(app) { "return unauthorized (401) if requesting user is not logged in" in new WithApplication(app) {
val client = mock[WSClient] val client = mock[WSClient]

View File

@@ -83,7 +83,7 @@ class Route53Backend(
val found = result.getHostedZones.asScala.toList.headOption.map { hz => val found = result.getHostedZones.asScala.toList.headOption.map { hz =>
val hzid = parseHostedZoneId(hz.getId) 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) zoneMap.putIfAbsent(hz.getName, hzid)
hzid hzid
} }