2
0
mirror of https://github.com/VinylDNS/vinyldns synced 2025-08-29 13:27:43 +00:00

Merge branch 'master' into show_recordsets_count

This commit is contained in:
Arpit Shah 2023-09-26 15:51:41 -04:00 committed by GitHub
commit fb6db99685
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
52 changed files with 1691 additions and 135 deletions

View File

@ -57,6 +57,8 @@ class Route53ApiIntegrationSpec
"test",
Some("access"),
Some("secret"),
None,
None,
sys.env.getOrElse("R53_SERVICE_ENDPOINT", "http://localhost:19003"),
"us-east-1"
)

View File

@ -158,8 +158,8 @@ final case class ListAdminsResponse(admins: Seq[UserInfo])
final case class ListGroupChangesResponse(
changes: Seq[GroupChangeInfo],
startFrom: Option[String] = None,
nextId: Option[String] = None,
startFrom: Option[Int] = None,
nextId: Option[Int] = None,
maxItems: Int
)

View File

@ -266,7 +266,7 @@ class MembershipService(
def getGroupActivity(
groupId: String,
startFrom: Option[String],
startFrom: Option[Int],
maxItems: Int,
authPrincipal: AuthPrincipal
): Result[ListGroupChangesResponse] =
@ -285,7 +285,7 @@ class MembershipService(
} yield ListGroupChangesResponse(
groupChanges.map(change => GroupChangeInfo.apply(change.copy(userName = userMap.get(change.userId)))),
startFrom,
result.lastEvaluatedTimeStamp,
result.nextId,
maxItems
)

View File

@ -63,7 +63,7 @@ trait MembershipServiceAlgebra {
def getGroupActivity(
groupId: String,
startFrom: Option[String],
startFrom: Option[Int],
maxItems: Int,
authPrincipal: AuthPrincipal
): Result[ListGroupChangesResponse]

View File

@ -38,6 +38,15 @@ object ListZoneChangesResponse {
)
}
case class ListDeletedZoneChangesResponse(
zonesDeletedInfo: List[ZoneChangeDeletedInfo],
zoneChangeFilter: Option[String] = None,
nextId: Option[String] = None,
startFrom: Option[String] = None,
maxItems: Int = 100,
ignoreAccess: Boolean = false
)
case class ListFailedZoneChangesResponse(
failedZoneChanges: List[ZoneChange] = Nil,
nextId: Int,

View File

@ -22,7 +22,7 @@ import vinyldns.core.domain.record.RecordSetChangeType.RecordSetChangeType
import vinyldns.core.domain.record.RecordSetStatus.RecordSetStatus
import vinyldns.core.domain.record.RecordType.RecordType
import vinyldns.core.domain.record.{RecordData, RecordSet, RecordSetChange}
import vinyldns.core.domain.zone.{ACLRuleInfo, AccessLevel, Zone, ZoneACL, ZoneConnection}
import vinyldns.core.domain.zone.{ACLRuleInfo, AccessLevel, Zone, ZoneACL, ZoneChange, ZoneConnection}
import vinyldns.core.domain.zone.AccessLevel.AccessLevel
import vinyldns.core.domain.zone.ZoneStatus.ZoneStatus
@ -145,6 +145,27 @@ object ZoneSummaryInfo {
)
}
case class ZoneChangeDeletedInfo(
zoneChange: ZoneChange,
adminGroupName: String,
userName: String,
accessLevel: AccessLevel
)
object ZoneChangeDeletedInfo {
def apply(zoneChange: List[ZoneChange],
groupName: String,
userName: String,
accessLevel: AccessLevel)
: ZoneChangeDeletedInfo =
ZoneChangeDeletedInfo(
zoneChange= zoneChange,
groupName = groupName,
userName = userName,
accessLevel = accessLevel
)
}
case class RecordSetListInfo(
zoneId: String,
name: String,

View File

@ -23,7 +23,7 @@ import vinyldns.api.Interfaces
import vinyldns.core.domain.auth.AuthPrincipal
import vinyldns.api.repository.ApiDataAccessor
import vinyldns.core.crypto.CryptoAlgebra
import vinyldns.core.domain.membership.{Group, GroupRepository, User, UserRepository}
import vinyldns.core.domain.membership.{Group, GroupRepository, ListUsersResults, User, UserRepository}
import vinyldns.core.domain.zone._
import vinyldns.core.queue.MessageQueue
import vinyldns.core.domain.DomainHelpers.ensureTrailingDot
@ -226,6 +226,58 @@ class ZoneService(
}
}.toResult
def listDeletedZones(
authPrincipal: AuthPrincipal,
nameFilter: Option[String] = None,
startFrom: Option[String] = None,
maxItems: Int = 100,
ignoreAccess: Boolean = false
): Result[ListDeletedZoneChangesResponse] = {
for {
listZonesChangeResult <- zoneChangeRepository.listDeletedZones(
authPrincipal,
nameFilter,
startFrom,
maxItems,
ignoreAccess
)
zoneChanges = listZonesChangeResult.zoneDeleted
groupIds = zoneChanges.map(_.zone.adminGroupId).toSet
groups <- groupRepository.getGroups(groupIds)
userId = zoneChanges.map(_.userId).toSet
users <- userRepository.getUsers(userId,None,None)
zoneDeleteSummaryInfos = ZoneChangeDeletedInfoMapping(zoneChanges, authPrincipal, groups, users)
} yield {
ListDeletedZoneChangesResponse(
zoneDeleteSummaryInfos,
listZonesChangeResult.zoneChangeFilter,
listZonesChangeResult.nextId,
listZonesChangeResult.startFrom,
listZonesChangeResult.maxItems,
listZonesChangeResult.ignoreAccess
)
}
}.toResult
private def ZoneChangeDeletedInfoMapping(
zoneChange: List[ZoneChange],
auth: AuthPrincipal,
groups: Set[Group],
users: ListUsersResults
): List[ZoneChangeDeletedInfo] =
zoneChange.map { zc =>
val groupName = groups.find(_.id == zc.zone.adminGroupId) match {
case Some(group) => group.name
case None => "Unknown group name"
}
val userName = users.users.find(_.id == zc.userId) match {
case Some(user) => user.userName
case None => "Unknown user name"
}
val zoneAccess = getZoneAccess(auth, zc.zone)
ZoneChangeDeletedInfo(zc, groupName,userName, zoneAccess)
}
def zoneSummaryInfoMapping(
zones: List[Zone],
auth: AuthPrincipal,

View File

@ -49,6 +49,14 @@ trait ZoneServiceAlgebra {
includeReverse: Boolean
): Result[ListZonesResponse]
def listDeletedZones(
authPrincipal: AuthPrincipal,
nameFilter: Option[String],
startFrom: Option[String],
maxItems: Int,
ignoreAccess: Boolean
): Result[ListDeletedZoneChangesResponse]
def listZoneChanges(
zoneId: String,
authPrincipal: AuthPrincipal,

View File

@ -162,8 +162,8 @@ class MembershipRoute(
} ~
path("groups" / Segment / "activity") { groupId =>
(get & monitor("Endpoint.groupActivity")) {
parameters("startFrom".?, "maxItems".as[Int].?(DEFAULT_MAX_ITEMS)) {
(startFrom: Option[String], maxItems: Int) =>
parameters("startFrom".as[Int].?, "maxItems".as[Int].?(DEFAULT_MAX_ITEMS)) {
(startFrom: Option[Int], maxItems: Int) =>
handleRejections(invalidQueryHandler) {
validate(
0 < maxItems && maxItems <= MAX_ITEMS_LIMIT,

View File

@ -249,33 +249,6 @@ class RecordSetRoute(
}
}
} ~
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)
}
}
}
}
}
} ~
path("metrics" / "health" / "zones" / Segment / "recordsetchangesfailure") {zoneId =>
(get & monitor("Endpoint.listFailedRecordSetChanges")) {
parameters("startFrom".as[Int].?(0), "maxItems".as[Int].?(DEFAULT_MAX_ITEMS)) {

View File

@ -111,6 +111,38 @@ class ZoneRoute(
}
}
} ~
path("zones" / "deleted" / "changes") {
(get & monitor("Endpoint.listDeletedZones")) {
parameters(
"nameFilter".?,
"startFrom".as[String].?,
"maxItems".as[Int].?(DEFAULT_MAX_ITEMS),
"ignoreAccess".as[Boolean].?(false)
) {
(
nameFilter: Option[String],
startFrom: Option[String],
maxItems: Int,
ignoreAccess: Boolean
) =>
{
handleRejections(invalidQueryHandler) {
validate(
0 < maxItems && maxItems <= MAX_ITEMS_LIMIT,
s"maxItems was $maxItems, maxItems must be between 0 and $MAX_ITEMS_LIMIT"
) {
authenticateAndExecute(
zoneService
.listDeletedZones(_, nameFilter, startFrom, maxItems, ignoreAccess)
) { result =>
complete(StatusCodes.OK, result)
}
}
}
}
}
}
} ~
path("zones" / "backendids") {
(get & monitor("Endpoint.getBackendIds")) {
authenticateAndExecute(_ => zoneService.getBackendIds()) { ids =>

View File

@ -26,12 +26,6 @@ def test_list_group_activity_start_from_success(group_activity_context, shared_z
# we grab 3 items, which when sorted by most recent will give the 3 most recent items
page_one = client.get_group_changes(created_group["id"], max_items=3, status=200)
# our start from will align with the created on the 3rd change in the list
start_from_index = 2
# using epoch time to start from a timestamp
epoch = datetime.utcfromtimestamp(0)
# start from a known good timestamp
start_from = str(int((datetime.strptime(page_one["changes"][start_from_index]["created"], "%Y-%m-%dT%H:%M:%S.%fZ") - epoch).total_seconds() * 1000))
# now, we say give me all changes since the start_from, which should yield 8-7-6-5-4
result = client.get_group_changes(created_group["id"], start_from=page_one["nextId"], max_items=5, status=200)
@ -50,26 +44,19 @@ def test_list_group_activity_start_from_success(group_activity_context, shared_z
assert_that(result["changes"][i]["oldGroup"], is_(updated_groups[expected_start - i - 1]))
def test_list_group_activity_start_from_fake_time(group_activity_context, shared_zone_test_context):
def test_list_group_activity_start_from_random_number(group_activity_context, shared_zone_test_context):
"""
Test that we can start from a fake time stamp
Test that we can start from a random number, but it returns no changes
"""
client = shared_zone_test_context.ok_vinyldns_client
created_group = group_activity_context["created_group"]
updated_groups = group_activity_context["updated_groups"]
start_from = "9999999999999" # start from a random timestamp far in the future
result = client.get_group_changes(created_group["id"], start_from=start_from, max_items=5, status=200)
result = client.get_group_changes(created_group["id"], start_from=999, max_items=5, status=200)
# there are 10 updates, proceeded by 1 create
assert_that(result["changes"], has_length(5))
assert_that(result["changes"], has_length(0))
assert_that(result["maxItems"], is_(5))
assert_that(result["startFrom"], is_(start_from))
assert_that(result["nextId"], is_not(none()))
for i in range(0, 5):
assert_that(result["changes"][i]["newGroup"], is_(updated_groups[9 - i]))
assert_that(result["changes"][i]["oldGroup"], is_(updated_groups[9 - i - 1]))
def test_list_group_activity_max_item_success(group_activity_context, shared_zone_test_context):

View File

@ -1013,11 +1013,11 @@ class MembershipServiceSpec
"return the group activity" in {
val groupChangeRepoResponse = ListGroupChangesResults(
listOfDummyGroupChanges.take(100),
Some(listOfDummyGroupChanges(100).id)
Some(listOfDummyGroupChanges.size)
)
doReturn(IO.pure(groupChangeRepoResponse))
.when(mockGroupChangeRepo)
.getGroupChanges(anyString, any[Option[String]], anyInt)
.getGroupChanges(anyString, any[Option[Int]], anyInt)
doReturn(IO.pure(ListUsersResults(Seq(dummyUser), Some("1"))))
.when(mockUserRepo)
@ -1031,18 +1031,18 @@ class MembershipServiceSpec
underTest.getGroupActivity(dummyGroup.id, None, 100, dummyAuth).value.unsafeRunSync().toOption.get
result.changes should contain theSameElementsAs expected
result.maxItems shouldBe 100
result.nextId shouldBe Some(listOfDummyGroupChanges(100).id)
result.nextId shouldBe Some(listOfDummyGroupChanges.size)
result.startFrom shouldBe None
}
"return group activity even if the user is not authorized" in {
val groupChangeRepoResponse = ListGroupChangesResults(
listOfDummyGroupChanges.take(100),
Some(listOfDummyGroupChanges(100).id)
Some(listOfDummyGroupChanges.size)
)
doReturn(IO.pure(groupChangeRepoResponse))
.when(mockGroupChangeRepo)
.getGroupChanges(anyString, any[Option[String]], anyInt)
.getGroupChanges(anyString, any[Option[Int]], anyInt)
doReturn(IO.pure(ListUsersResults(Seq(dummyUser), Some("1"))))
.when(mockUserRepo)
@ -1056,7 +1056,7 @@ class MembershipServiceSpec
underTest.getGroupActivity(dummyGroup.id, None, 100, okAuth).value.unsafeRunSync().toOption.get
result.changes should contain theSameElementsAs expected
result.maxItems shouldBe 100
result.nextId shouldBe Some(listOfDummyGroupChanges(100).id)
result.nextId shouldBe Some(listOfDummyGroupChanges.size)
result.startFrom shouldBe None
}
}

View File

@ -57,6 +57,8 @@ class ZoneServiceSpec
private val badConnection = ZoneConnection("bad", "bad", Encrypted("bad"), "bad")
private val abcZoneSummary = ZoneSummaryInfo(abcZone, abcGroup.name, AccessLevel.Delete)
private val xyzZoneSummary = ZoneSummaryInfo(xyzZone, xyzGroup.name, AccessLevel.NoAccess)
private val abcDeletedZoneSummary = ZoneChangeDeletedInfo(abcDeletedZoneChange, abcGroup.name, okUser.userName, AccessLevel.Delete)
private val xyzDeletedZoneSummary = ZoneChangeDeletedInfo(xyzDeletedZoneChange, xyzGroup.name, okUser.userName, AccessLevel.NoAccess)
private val zoneIp4ZoneSummary = ZoneSummaryInfo(zoneIp4, abcGroup.name, AccessLevel.Delete)
private val zoneIp6ZoneSummary = ZoneSummaryInfo(zoneIp6, abcGroup.name, AccessLevel.Delete)
private val mockMembershipRepo = mock[MembershipRepository]
@ -1084,6 +1086,195 @@ class ZoneServiceSpec
}
}
"ListDeletedZones" should {
"not fail with no zones returned" in {
doReturn(IO.pure(ListDeletedZonesChangeResults(List())))
.when(mockZoneChangeRepo)
.listDeletedZones(abcAuth, None, None, 100, false)
doReturn(IO.pure(Set(abcGroup))).when(mockGroupRepo).getGroups(any[Set[String]])
doReturn(IO.pure(ListUsersResults(Seq(okUser), None)))
.when(mockUserRepo)
.getUsers(any[Set[String]], any[Option[String]], any[Option[Int]])
val result: ListDeletedZoneChangesResponse = underTest.listDeletedZones(abcAuth).value.unsafeRunSync().toOption.get
result.zonesDeletedInfo shouldBe List()
result.maxItems shouldBe 100
result.startFrom shouldBe None
result.zoneChangeFilter shouldBe None
result.nextId shouldBe None
result.ignoreAccess shouldBe false
}
"return the appropriate zones" in {
doReturn(IO.pure(ListDeletedZonesChangeResults(List(abcDeletedZoneChange))))
.when(mockZoneChangeRepo)
.listDeletedZones(abcAuth, None, None, 100, false)
doReturn(IO.pure(Set(abcGroup)))
.when(mockGroupRepo)
.getGroups(any[Set[String]])
doReturn(IO.pure(ListUsersResults(Seq(okUser), None)))
.when(mockUserRepo)
.getUsers(any[Set[String]], any[Option[String]], any[Option[Int]])
val result: ListDeletedZoneChangesResponse = underTest.listDeletedZones(abcAuth).value.unsafeRunSync().toOption.get
result.zonesDeletedInfo shouldBe List(abcDeletedZoneSummary)
result.maxItems shouldBe 100
result.startFrom shouldBe None
result.zoneChangeFilter shouldBe None
result.nextId shouldBe None
result.ignoreAccess shouldBe false
}
"return all zones" in {
doReturn(IO.pure(ListDeletedZonesChangeResults(List(abcDeletedZoneChange, xyzDeletedZoneChange), ignoreAccess = true)))
.when(mockZoneChangeRepo)
.listDeletedZones(abcAuth, None, None, 100, true)
doReturn(IO.pure(Set(abcGroup, xyzGroup)))
.when(mockGroupRepo)
.getGroups(any[Set[String]])
doReturn(IO.pure(ListUsersResults(Seq(okUser), None)))
.when(mockUserRepo)
.getUsers(any[Set[String]], any[Option[String]], any[Option[Int]])
val result: ListDeletedZoneChangesResponse =
underTest.listDeletedZones(abcAuth, ignoreAccess = true).value.unsafeRunSync().toOption.get
result.zonesDeletedInfo shouldBe List(abcDeletedZoneSummary,xyzDeletedZoneSummary)
result.maxItems shouldBe 100
result.startFrom shouldBe None
result.zoneChangeFilter shouldBe None
result.nextId shouldBe None
result.ignoreAccess shouldBe true
}
"return Unknown group name if zone admin group cannot be found" in {
doReturn(IO.pure(ListDeletedZonesChangeResults(List(abcDeletedZoneChange, xyzDeletedZoneChange))))
.when(mockZoneChangeRepo)
.listDeletedZones(abcAuth, None, None, 100, false)
doReturn(IO.pure(Set(okGroup))).when(mockGroupRepo).getGroups(any[Set[String]])
doReturn(IO.pure(ListUsersResults(Seq(okUser), None)))
.when(mockUserRepo)
.getUsers(any[Set[String]], any[Option[String]], any[Option[Int]])
val result: ListDeletedZoneChangesResponse = underTest.listDeletedZones(abcAuth).value.unsafeRunSync().toOption.get
val expectedZones =
List(abcDeletedZoneSummary, xyzDeletedZoneSummary).map(_.copy(adminGroupName = "Unknown group name"))
result.zonesDeletedInfo shouldBe expectedZones
result.maxItems shouldBe 100
result.startFrom shouldBe None
result.zoneChangeFilter shouldBe None
result.nextId shouldBe None
}
"set the nextId appropriately" in {
doReturn(
IO.pure(
ListDeletedZonesChangeResults(
List(abcDeletedZoneChange, xyzDeletedZoneChange),
maxItems = 2,
nextId = Some("zone2."),
ignoreAccess = false
)
)
).when(mockZoneChangeRepo)
.listDeletedZones(abcAuth, None, None, 2, false)
doReturn(IO.pure(Set(abcGroup, xyzGroup)))
.when(mockGroupRepo)
.getGroups(any[Set[String]])
doReturn(IO.pure(ListUsersResults(Seq(okUser), None)))
.when(mockUserRepo)
.getUsers(any[Set[String]], any[Option[String]], any[Option[Int]])
val result: ListDeletedZoneChangesResponse =
underTest.listDeletedZones(abcAuth, maxItems = 2).value.unsafeRunSync().toOption.get
result.zonesDeletedInfo shouldBe List(abcDeletedZoneSummary, xyzDeletedZoneSummary)
result.maxItems shouldBe 2
result.startFrom shouldBe None
result.zoneChangeFilter shouldBe None
result.nextId shouldBe Some("zone2.")
}
"set the nameFilter when provided" in {
doReturn(
IO.pure(
ListDeletedZonesChangeResults(
List(abcDeletedZoneChange, xyzDeletedZoneChange),
zoneChangeFilter = Some("foo"),
maxItems = 2,
nextId = Some("zone2."),
ignoreAccess = false
)
)
).when(mockZoneChangeRepo)
.listDeletedZones(abcAuth, Some("foo"), None, 2, false)
doReturn(IO.pure(Set(abcGroup, xyzGroup)))
.when(mockGroupRepo)
.getGroups(any[Set[String]])
doReturn(IO.pure(ListUsersResults(Seq(okUser), None)))
.when(mockUserRepo)
.getUsers(any[Set[String]], any[Option[String]], any[Option[Int]])
val result: ListDeletedZoneChangesResponse =
underTest.listDeletedZones(abcAuth, nameFilter = Some("foo"), maxItems = 2).value.unsafeRunSync().toOption.get
result.zonesDeletedInfo shouldBe List(abcDeletedZoneSummary, xyzDeletedZoneSummary)
result.zoneChangeFilter shouldBe Some("foo")
result.nextId shouldBe Some("zone2.")
result.maxItems shouldBe 2
}
"set the startFrom when provided" in {
doReturn(
IO.pure(
ListDeletedZonesChangeResults(
List(abcDeletedZoneChange, xyzDeletedZoneChange),
startFrom = Some("zone4."),
maxItems = 2,
ignoreAccess = false
)
)
).when(mockZoneChangeRepo)
.listDeletedZones(abcAuth, None, Some("zone4."), 2, false)
doReturn(IO.pure(Set(abcGroup, xyzGroup)))
.when(mockGroupRepo)
.getGroups(any[Set[String]])
doReturn(IO.pure(ListUsersResults(Seq(okUser), None)))
.when(mockUserRepo)
.getUsers(any[Set[String]], any[Option[String]], any[Option[Int]])
val result: ListDeletedZoneChangesResponse =
underTest.listDeletedZones(abcAuth, startFrom = Some("zone4."), maxItems = 2).value.unsafeRunSync().toOption.get
result.zonesDeletedInfo shouldBe List(abcDeletedZoneSummary, xyzDeletedZoneSummary)
result.startFrom shouldBe Some("zone4.")
}
"set the nextId to be the current result set size plus the start from" in {
doReturn(
IO.pure(
ListDeletedZonesChangeResults(
List(abcDeletedZoneChange, xyzDeletedZoneChange),
startFrom = Some("zone4."),
maxItems = 2,
nextId = Some("zone6."),
ignoreAccess = false
)
)
).when(mockZoneChangeRepo)
.listDeletedZones(abcAuth, None, Some("zone4."), 2, false)
doReturn(IO.pure(Set(abcGroup, xyzGroup)))
.when(mockGroupRepo)
.getGroups(any[Set[String]])
doReturn(IO.pure(ListUsersResults(Seq(okUser), None)))
.when(mockUserRepo)
.getUsers(any[Set[String]], any[Option[String]], any[Option[Int]])
val result: ListDeletedZoneChangesResponse =
underTest.listDeletedZones(abcAuth, startFrom = Some("zone4."), maxItems = 2).value.unsafeRunSync().toOption.get
result.zonesDeletedInfo shouldBe List(abcDeletedZoneSummary, xyzDeletedZoneSummary)
result.nextId shouldBe Some("zone6.")
}
}
"listZoneChanges" should {
"retrieve the zone changes" in {
doReturn(IO.pure(Some(okZone)))

View File

@ -705,14 +705,14 @@ class MembershipRoutingSpec
)
doReturn(result(expected))
.when(membershipService)
.getGroupActivity(anyString, any[Option[String]], anyInt, any[AuthPrincipal])
.getGroupActivity(anyString, any[Option[Int]], anyInt, any[AuthPrincipal])
Get(s"/groups/pageSize/activity") ~> Route.seal(membershipRoute) ~> check {
status shouldBe StatusCodes.OK
val maxItemsCaptor = ArgumentCaptor.forClass(classOf[Int])
verify(membershipService).getGroupActivity(
anyString,
any[Option[String]],
any[Option[Int]],
maxItemsCaptor.capture(),
any[AuthPrincipal]
)

View File

@ -117,6 +117,26 @@ class ZoneRoutingSpec
Zone("zone6.in-addr.arpa.", "zone6@test.com", ZoneStatus.Active, adminGroupId = xyzGroup.id)
private val zoneSummaryInfo6 = ZoneSummaryInfo(zone6, xyzGroup.name, AccessLevel.NoAccess)
private val error = Zone("error.", "error@test.com")
private val deletedZone1 = Zone("ok1.", "ok1@test.com", ZoneStatus.Deleted , acl = zoneAcl)
private val deletedZoneChange1 = ZoneChange(deletedZone1, "ok1", ZoneChangeType.Create, ZoneChangeStatus.Synced)
private val ZoneChangeDeletedInfo1 = ZoneChangeDeletedInfo(
deletedZoneChange1, okGroup.name, okUser.userName, AccessLevel.NoAccess)
private val deletedZone2 = Zone("ok2.", "ok2@test.com", ZoneStatus.Deleted , acl = zoneAcl)
private val deletedZoneChange2 = ZoneChange(deletedZone2, "ok2", ZoneChangeType.Create, ZoneChangeStatus.Synced)
private val ZoneChangeDeletedInfo2 = ZoneChangeDeletedInfo(
deletedZoneChange2, okGroup.name, okUser.userName, AccessLevel.NoAccess)
private val deletedZone3 = Zone("ok3.", "ok3@test.com", ZoneStatus.Deleted , acl = zoneAcl)
private val deletedZoneChange3 = ZoneChange(deletedZone3, "ok3", ZoneChangeType.Create, ZoneChangeStatus.Synced)
private val ZoneChangeDeletedInfo3= ZoneChangeDeletedInfo(
deletedZoneChange3, okGroup.name, okUser.userName, AccessLevel.NoAccess)
private val deletedZone4 = Zone("ok4.", "ok4@test.com", ZoneStatus.Deleted , acl = zoneAcl, adminGroupId = xyzGroup.id)
private val deletedZoneChange4 = ZoneChange(deletedZone4, "ok4", ZoneChangeType.Create, ZoneChangeStatus.Synced)
private val ZoneChangeDeletedInfo4 = ZoneChangeDeletedInfo(
deletedZoneChange4, okGroup.name, okUser.userName, AccessLevel.NoAccess)
private val deletedZone5 = Zone("ok5.", "ok5@test.com", ZoneStatus.Deleted , acl = zoneAcl, adminGroupId = xyzGroup.id)
private val deletedZoneChange5 = ZoneChange(deletedZone5, "ok5", ZoneChangeType.Create, ZoneChangeStatus.Synced)
private val ZoneChangeDeletedInfo5 = ZoneChangeDeletedInfo(
deletedZoneChange5, okGroup.name, okUser.userName, AccessLevel.NoAccess)
private val missingFields: JValue =
("invalidField" -> "randomValue") ~~
@ -395,6 +415,92 @@ class ZoneRoutingSpec
outcome.toResult
}
def listDeletedZones(
authPrincipal: AuthPrincipal,
nameFilter: Option[String],
startFrom: Option[String],
maxItems: Int,
ignoreAccess: Boolean = false
): Result[ListDeletedZoneChangesResponse] = {
val outcome = (authPrincipal, nameFilter, startFrom, maxItems, ignoreAccess) match {
case (_, None, Some("zone3."), 3, false) =>
Right(
ListDeletedZoneChangesResponse(
zonesDeletedInfo = List(ZoneChangeDeletedInfo1,ZoneChangeDeletedInfo2,ZoneChangeDeletedInfo3),
zoneChangeFilter = None,
startFrom = Some("zone3."),
nextId = Some("zone6."),
maxItems = 3,
ignoreAccess = false
)
)
case (_, None, Some("zone4."), 4, false) =>
Right(
ListDeletedZoneChangesResponse(
zonesDeletedInfo = List(ZoneChangeDeletedInfo1,ZoneChangeDeletedInfo2,ZoneChangeDeletedInfo3),
zoneChangeFilter = None,
startFrom = Some("zone4."),
nextId = None,
maxItems = 4,
ignoreAccess = false
)
)
case (_, None, None, 3, false) =>
Right(
ListDeletedZoneChangesResponse(
zonesDeletedInfo = List(ZoneChangeDeletedInfo1,ZoneChangeDeletedInfo2,ZoneChangeDeletedInfo3),
zoneChangeFilter = None,
startFrom = None,
nextId = Some("zone3."),
maxItems = 3,
ignoreAccess = false
)
)
case (_, None, None, 5, true) =>
Right(
ListDeletedZoneChangesResponse(
zonesDeletedInfo =
List(ZoneChangeDeletedInfo1,ZoneChangeDeletedInfo2,ZoneChangeDeletedInfo3, ZoneChangeDeletedInfo4,ZoneChangeDeletedInfo5),
zoneChangeFilter = None,
startFrom = None,
nextId = None,
maxItems = 5,
ignoreAccess = true
)
)
case (_, Some(filter), Some("zone4."), 4, false) =>
Right(
ListDeletedZoneChangesResponse(
zonesDeletedInfo = List(ZoneChangeDeletedInfo1,ZoneChangeDeletedInfo2,ZoneChangeDeletedInfo3),
zoneChangeFilter = Some(filter),
startFrom = Some("zone4."),
nextId = None,
maxItems = 4,
ignoreAccess = false
)
)
case (_, None, None, _, _) =>
Right(
ListDeletedZoneChangesResponse(
zonesDeletedInfo = List(ZoneChangeDeletedInfo1,ZoneChangeDeletedInfo2,ZoneChangeDeletedInfo3),
zoneChangeFilter = None,
startFrom = None,
nextId = None,
ignoreAccess = false
)
)
case _ => Left(InvalidRequest("shouldnt get here"))
}
outcome.toResult
}
def listZoneChanges(
zoneId: String,
authPrincipal: AuthPrincipal,
@ -1078,6 +1184,83 @@ class ZoneRoutingSpec
}
}
"GET Deleted zones" should {
"return the next id when more results exist" in {
Get(s"/zones/deleted/changes?startFrom=zone3.&maxItems=3") ~> zoneRoute ~> check {
val resp = responseAs[ListDeletedZoneChangesResponse]
val deletedZones = resp.zonesDeletedInfo
(deletedZones.map(_.zoneChange.zone.id) should contain)
.only(deletedZone1.id, deletedZone2.id, deletedZone3.id)
resp.nextId shouldBe Some("zone6.")
resp.maxItems shouldBe 3
resp.startFrom shouldBe Some("zone3.")
}
}
"not return the next id when there are no more results" in {
Get(s"/zones/deleted/changes?startFrom=zone4.&maxItems=4") ~> zoneRoute ~> check {
val resp = responseAs[ListDeletedZoneChangesResponse]
val deletedZones = resp.zonesDeletedInfo
(deletedZones.map(_.zoneChange.zone.id) should contain)
.only(deletedZone1.id, deletedZone2.id, deletedZone3.id)
resp.nextId shouldBe None
resp.maxItems shouldBe 4
resp.startFrom shouldBe Some("zone4.")
resp.ignoreAccess shouldBe false
}
}
"not return the start from when not provided" in {
Get(s"/zones/deleted/changes?maxItems=3") ~> zoneRoute ~> check {
val resp = responseAs[ListDeletedZoneChangesResponse]
val deletedZones = resp.zonesDeletedInfo
(deletedZones.map(_.zoneChange.zone.id) should contain)
.only(deletedZone1.id, deletedZone2.id, deletedZone3.id)
resp.nextId shouldBe Some("zone3.")
resp.maxItems shouldBe 3
resp.startFrom shouldBe None
resp.ignoreAccess shouldBe false
}
}
"return the name filter when provided" in {
Get(s"/zones/deleted/changes?nameFilter=foo&startFrom=zone4.&maxItems=4") ~> zoneRoute ~> check {
val resp = responseAs[ListDeletedZoneChangesResponse]
val deletedZones = resp.zonesDeletedInfo
(deletedZones.map(_.zoneChange.zone.id) should contain)
.only(deletedZone1.id, deletedZone2.id, deletedZone3.id)
resp.nextId shouldBe None
resp.maxItems shouldBe 4
resp.startFrom shouldBe Some("zone4.")
resp.zoneChangeFilter shouldBe Some("foo")
resp.ignoreAccess shouldBe false
}
}
"return all zones when list all is true" in {
Get(s"/zones/deleted/changes?maxItems=5&ignoreAccess=true") ~> zoneRoute ~> check {
val resp = responseAs[ListDeletedZoneChangesResponse]
val deletedZones = resp.zonesDeletedInfo
(deletedZones.map(_.zoneChange.zone.id) should contain)
.only(deletedZone1.id, deletedZone2.id, deletedZone3.id, deletedZone4.id, deletedZone5.id)
resp.nextId shouldBe None
resp.maxItems shouldBe 5
resp.startFrom shouldBe None
resp.zoneChangeFilter shouldBe None
resp.ignoreAccess shouldBe true
}
}
"return an error if the max items is out of range" in {
Get(s"/zones/deleted/changes?maxItems=700") ~> zoneRoute ~> check {
status shouldBe BadRequest
responseEntity.toString should include(
"maxItems was 700, maxItems must be between 0 and 100"
)
}
}
}
"GET zone changes" should {
"return the zone changes" in {
Get(s"/zones/${ok.id}/changes") ~> zoneRoute ~> check {

View File

@ -28,7 +28,7 @@ trait GroupChangeRepository extends Repository {
def getGroupChanges(
groupId: String,
startFrom: Option[String],
startFrom: Option[Int],
maxItems: Int
): IO[ListGroupChangesResults]

View File

@ -18,5 +18,5 @@ package vinyldns.core.domain.membership
final case class ListGroupChangesResults(
changes: Seq[GroupChange],
lastEvaluatedTimeStamp: Option[String]
nextId: Option[Int]
)

View File

@ -23,6 +23,15 @@ case class ListZoneChangesResults(
maxItems: Int = 100
)
case class ListDeletedZonesChangeResults(
zoneDeleted: List[ZoneChange] = List[ZoneChange](),
nextId: Option[String] = None,
startFrom: Option[String] = None,
maxItems: Int = 100,
ignoreAccess: Boolean = false,
zoneChangeFilter: Option[String] = None
)
case class ListFailedZoneChangesResults(
items: List[ZoneChange] = List[ZoneChange](),
nextId: Int = 0,

View File

@ -17,6 +17,7 @@
package vinyldns.core.domain.zone
import cats.effect._
import vinyldns.core.domain.auth.AuthPrincipal
import vinyldns.core.repository.Repository
trait ZoneChangeRepository extends Repository {
@ -29,6 +30,14 @@ trait ZoneChangeRepository extends Repository {
maxItems: Int = 100
): IO[ListZoneChangesResults]
def listDeletedZones(
authPrincipal: AuthPrincipal,
zoneNameFilter: Option[String] = None,
startFrom: Option[String] = None,
maxItems: Int = 100,
ignoreAccess: Boolean = false
): IO[ListDeletedZonesChangeResults]
def listFailedZoneChanges(
maxItems: Int = 100,
startFrom: Int= 0

View File

@ -52,9 +52,21 @@ object TestZoneData {
connection = testConnection
)
val abcZoneDeleted: Zone = Zone("abc.zone.recordsets.", "test@test.com", adminGroupId = abcGroup.id, status = ZoneStatus.Deleted)
val xyzZoneDeleted: Zone = Zone("xyz.zone.recordsets.", "abc@xyz.com", adminGroupId = xyzGroup.id, status = ZoneStatus.Deleted)
val zoneDeleted: Zone = Zone(
"some.deleted.zone.",
"test@test.com",
adminGroupId = abcGroup.id,
status = ZoneStatus.Deleted,
connection = testConnection
)
val zoneDeletedOkGroup: Zone = Zone(
"some.deleted.zone.",
"test@test.com",
adminGroupId = okGroup.id,
status = ZoneStatus.Deleted,
connection = testConnection
)
@ -89,6 +101,22 @@ object TestZoneData {
val zoneUpdate: ZoneChange = zoneChangePending.copy(status = ZoneChangeStatus.Synced)
val abcDeletedZoneChange: ZoneChange = ZoneChange(
abcZoneDeleted,
"ok",
ZoneChangeType.Create,
ZoneChangeStatus.Synced,
created = Instant.now.truncatedTo(ChronoUnit.MILLIS).minusMillis(1000)
)
val xyzDeletedZoneChange: ZoneChange = ZoneChange(
xyzZoneDeleted,
"ok",
ZoneChangeType.Create,
ZoneChangeStatus.Synced,
created = Instant.now.truncatedTo(ChronoUnit.MILLIS).minusMillis(1000)
)
def makeTestPendingZoneChange(zone: Zone): ZoneChange =
ZoneChange(zone, "userId", ZoneChangeType.Update, ZoneChangeStatus.Pending)

View File

@ -102,7 +102,7 @@ class MySqlGroupChangeRepositoryIntegrationSpec
}
"MySqlGroupChangeRepository.getGroupChanges" should {
"don't return lastEvaluatedTimeStamp if page size < maxItems" in {
"don't return nextId if page size < maxItems" in {
val groupId = "group-id-1"
val changes = generateGroupChanges(groupId, 50)
changes.map(saveGroupChangeData(repo, _).unsafeRunSync())
@ -113,7 +113,7 @@ class MySqlGroupChangeRepositoryIntegrationSpec
val listResponse = repo.getGroupChanges(groupId, None, 100).unsafeRunSync()
listResponse.changes shouldBe expectedChanges
listResponse.lastEvaluatedTimeStamp shouldBe None
listResponse.nextId shouldBe None
}
"get group changes properly using a maxItems of 1" in {
@ -130,9 +130,7 @@ class MySqlGroupChangeRepositoryIntegrationSpec
val listResponse =
repo.getGroupChanges(groupId, startFrom = None, maxItems = 1).unsafeRunSync()
listResponse.changes shouldBe expectedChanges
listResponse.lastEvaluatedTimeStamp shouldBe Some(
expectedChanges.head.created.toEpochMilli.toString
)
listResponse.nextId shouldBe Some(1)
}
"page group changes using a startFrom and maxItems" in {
@ -145,33 +143,33 @@ class MySqlGroupChangeRepositoryIntegrationSpec
.reverse
val expectedPageOne = Seq(changesSorted(0))
val expectedPageOneNext = Some(changesSorted(0).created.toEpochMilli.toString)
val expectedPageOneNext = Some(1)
val expectedPageTwo = Seq(changesSorted(1))
val expectedPageTwoNext = Some(changesSorted(1).created.toEpochMilli.toString)
val expectedPageTwoNext = Some(2)
val expectedPageThree = Seq(changesSorted(2))
val expectedPageThreeNext = Some(changesSorted(2).created.toEpochMilli.toString)
val expectedPageThreeNext = Some(3)
// get first page
val pageOne =
repo.getGroupChanges(groupId, startFrom = None, maxItems = 1).unsafeRunSync()
pageOne.changes shouldBe expectedPageOne
pageOne.lastEvaluatedTimeStamp shouldBe expectedPageOneNext
pageOne.nextId shouldBe expectedPageOneNext
// get second page
val pageTwo =
repo
.getGroupChanges(groupId, startFrom = pageOne.lastEvaluatedTimeStamp, maxItems = 1)
.getGroupChanges(groupId, startFrom = pageOne.nextId, maxItems = 1)
.unsafeRunSync()
pageTwo.changes shouldBe expectedPageTwo
pageTwo.lastEvaluatedTimeStamp shouldBe expectedPageTwoNext
pageTwo.nextId shouldBe expectedPageTwoNext
// get final page
val pageThree =
repo
.getGroupChanges(groupId, startFrom = pageTwo.lastEvaluatedTimeStamp, maxItems = 1)
.getGroupChanges(groupId, startFrom = pageTwo.nextId, maxItems = 1)
.unsafeRunSync()
pageThree.changes shouldBe expectedPageThree
pageThree.lastEvaluatedTimeStamp shouldBe expectedPageThreeNext
pageThree.nextId shouldBe expectedPageThreeNext
}
}
}

View File

@ -33,6 +33,9 @@ import vinyldns.core.TestZoneData.okZone
import vinyldns.core.TestZoneData.testConnection
import vinyldns.core.domain.Encrypted
import vinyldns.mysql.TestMySqlInstance
import vinyldns.core.TestZoneData.{okZone, testConnection}
import vinyldns.core.domain.auth.AuthPrincipal
import vinyldns.core.TestMembershipData.{dummyAuth, dummyGroup, okGroup, okUser}
import scala.concurrent.duration._
import scala.util.Random
@ -92,6 +95,7 @@ class MySqlZoneChangeRepositoryIntegrationSpec
status= ZoneChangeStatus.Failed,
created = Instant.now.truncatedTo(ChronoUnit.MILLIS).minusSeconds(Random.nextInt(1000))
)
val successChanges
: IndexedSeq[ZoneChange] = for { zone <- zones } yield ZoneChange(
zone,
@ -100,6 +104,84 @@ class MySqlZoneChangeRepositoryIntegrationSpec
status= ZoneChangeStatus.Synced,
created = Instant.now.truncatedTo(ChronoUnit.MILLIS).minusSeconds(Random.nextInt(1000))
)
val groups = (11 until 20)
.map(num => okGroup.copy(name = num.toString, id = UUID.randomUUID().toString))
.toList
// generate some ACLs
private val groupAclRules = groups.map(
g =>
ACLRule(
accessLevel = AccessLevel.Read,
groupId = Some(g.id)
)
)
private val userOnlyAclRule =
ACLRule(
accessLevel = AccessLevel.Read,
userId = Some(okUser.id)
)
// the zone acl rule will have the user rule and all of the group rules
private val testZoneAcl = ZoneACL(
rules = Set(userOnlyAclRule) ++ groupAclRules
)
private val testZoneAdminGroupId = "foo"
val dummyAclRule =
ACLRule(
accessLevel = AccessLevel.Read,
groupId = Some(dummyGroup.id)
)
val testZone = (11 until 20).map { num =>
val z =
okZone.copy(
name = num.toString + ".",
id = UUID.randomUUID().toString,
adminGroupId = testZoneAdminGroupId,
acl = testZoneAcl
)
// add the dummy acl rule to the first zone
if (num == 11) z.addACLRule(dummyAclRule) else z
}
val deletedZoneChanges
: IndexedSeq[ZoneChange] = for { testZone <- testZone } yield {
ZoneChange(
testZone.copy(status = ZoneStatus.Deleted),
testZone.account,
ZoneChangeType.Create,
ZoneChangeStatus.Synced,
created = Instant.now.truncatedTo(ChronoUnit.MILLIS).minusMillis(1000)
)}
def saveZones(zones: Seq[Zone]): IO[Unit] =
zones.foldLeft(IO.unit) {
case (acc, z) =>
acc.flatMap { _ =>
zoneRepo.save(z).map(_ => ())
}
}
def deleteZones(zones: Seq[Zone]): IO[Unit] =
zones.foldLeft(IO.unit) {
case (acc, z) =>
acc.flatMap { _ =>
zoneRepo.deleteTx(z).map(_ => ())
}
}
def saveZoneChanges(zoneChanges: Seq[ZoneChange]): IO[Unit] =
zoneChanges.foldLeft(IO.unit) {
case (acc, zc) =>
acc.flatMap { _ =>
repo.save(zc).map(_ => ())
}
}
}
import TestData._
@ -303,5 +385,145 @@ class MySqlZoneChangeRepositoryIntegrationSpec
pageThree.nextId should equal(None)
pageThree.startFrom should equal(pageTwo.nextId)
}
"get authorized zones" in {
// store all of the zones
saveZones(testZone).unsafeRunSync()
// delete all stored zones
deleteZones(testZone).unsafeRunSync()
// save the change
saveZoneChanges(deletedZoneChanges).unsafeRunSync()
// query for all zones for the ok user, he should have access to all of the zones
val okUserAuth = AuthPrincipal(
signedInUser = okUser,
memberGroupIds = groups.map(_.id)
)
repo.listDeletedZones(okUserAuth).unsafeRunSync().zoneDeleted should contain theSameElementsAs deletedZoneChanges
// dummy user only has access to one zone
(repo.listDeletedZones(dummyAuth).unsafeRunSync().zoneDeleted should contain).only(deletedZoneChanges.head)
}
"page deleted zones using a startFrom and maxItems" in {
// store all of the zones
saveZones(testZone).unsafeRunSync()
// delete all stored zones
deleteZones(testZone).unsafeRunSync()
// save the change
saveZoneChanges(deletedZoneChanges).unsafeRunSync()
// query for all zones for the ok user, he should have access to all of the zones
val okUserAuth = AuthPrincipal(
signedInUser = okUser,
memberGroupIds = groups.map(_.id)
)
val listDeletedZones = repo.listDeletedZones(okUserAuth).unsafeRunSync()
val expectedPageOne = List(listDeletedZones.zoneDeleted(0))
val expectedPageOneNext = Some(listDeletedZones.zoneDeleted(1).zone.id)
val expectedPageTwo = List(listDeletedZones.zoneDeleted(1))
val expectedPageTwoNext = Some(listDeletedZones.zoneDeleted(2).zone.id)
val expectedPageThree = List(listDeletedZones.zoneDeleted(2))
val expectedPageThreeNext = Some(listDeletedZones.zoneDeleted(3).zone.id)
// get first page
val pageOne = repo.listDeletedZones(okUserAuth,startFrom = None, maxItems = 1 ).unsafeRunSync()
pageOne.zoneDeleted.size should equal(1)
pageOne.zoneDeleted should equal(expectedPageOne)
pageOne.nextId should equal(expectedPageOneNext)
pageOne.startFrom should equal(None)
// get second page
val pageTwo =
repo.listDeletedZones(okUserAuth, startFrom = pageOne.nextId, maxItems = 1).unsafeRunSync()
pageTwo.zoneDeleted.size should equal(1)
pageTwo.zoneDeleted should equal(expectedPageTwo)
pageTwo.nextId should equal(expectedPageTwoNext)
pageTwo.startFrom should equal(pageOne.nextId)
// get final page
// next id should be none now
val pageThree =
repo.listDeletedZones(okUserAuth, startFrom = pageTwo.nextId, maxItems = 1).unsafeRunSync()
pageThree.zoneDeleted.size should equal(1)
pageThree.zoneDeleted should equal(expectedPageThree)
pageThree.nextId should equal(expectedPageThreeNext)
pageThree.startFrom should equal(pageTwo.nextId)
}
"return empty in deleted zone if zone is created again" in {
// store all of the zones
saveZones(testZone).unsafeRunSync()
// save the change
saveZoneChanges(deletedZoneChanges).unsafeRunSync()
// query for all zones for the ok user, he should have access to all of the zones
val okUserAuth = AuthPrincipal(
signedInUser = okUser,
memberGroupIds = groups.map(_.id)
)
repo.listDeletedZones(okUserAuth).unsafeRunSync().zoneDeleted should contain theSameElementsAs List()
// delete all stored zones
deleteZones(testZone).unsafeRunSync()
}
"return an empty list of zones if the user is not authorized to any" in {
val unauthorized = AuthPrincipal(
signedInUser = User("not-authorized", "not-authorized", Encrypted("not-authorized")),
memberGroupIds = Seq.empty
)
val f =
for {
_ <- saveZones(testZone)
_ <- deleteZones(testZone)
_ <- saveZoneChanges(deletedZoneChanges)
zones <- repo.listDeletedZones(unauthorized)
} yield zones
f.unsafeRunSync().zoneDeleted shouldBe empty
deleteZones(testZone).unsafeRunSync()
}
"not return zones when access is revoked" in {
// ok user can access both zones, dummy can only access first zone
val zones = testZone.take(2)
val zoneChange = deletedZoneChanges.take(2)
val addACL = saveZones(zones)
val deleteZone= deleteZones(zones)
val addACLZc = saveZoneChanges(zoneChange)
val okUserAuth = AuthPrincipal(
signedInUser = okUser,
memberGroupIds = groups.map(_.id)
)
addACL.unsafeRunSync()
deleteZone.unsafeRunSync()
addACLZc.unsafeRunSync()
(repo.listDeletedZones(okUserAuth).unsafeRunSync().zoneDeleted should contain). allElementsOf(zoneChange)
// dummy user only has access to first zone
(repo.listDeletedZones(dummyAuth).unsafeRunSync().zoneDeleted should contain).only(zoneChange.head)
// revoke the access for the dummy user
val revoked = zones(0).deleteACLRule(dummyAclRule)
val revokedZc = zoneChange(0).copy(zone=revoked)
zoneRepo.save(revoked).unsafeRunSync()
repo.save(revokedZc).unsafeRunSync()
// ok user can still access zones
(repo.listDeletedZones(okUserAuth).unsafeRunSync().zoneDeleted should contain).allElementsOf(Seq( zoneChange(1)))
// dummy user can not access the revoked zone
repo.listDeletedZones(dummyAuth).unsafeRunSync().zoneDeleted shouldBe empty
}
}
}

View File

@ -952,12 +952,13 @@ class MySqlZoneRepositoryIntegrationSpec
"check if an id has an ACL rule for at least one of the zones" in {
val zoneId = UUID.randomUUID().toString
val adminId = UUID.randomUUID().toString
val testZones = (1 until 3).map { num =>
okZone.copy(
name = num.toString + ".",
id = zoneId,
adminGroupId = testZoneAdminGroupId,
adminGroupId = adminId,
acl = testZoneAcl
)
}
@ -965,7 +966,7 @@ class MySqlZoneRepositoryIntegrationSpec
val f =
for {
_ <- saveZones(testZones)
zones <- repo.getFirstOwnedZoneAclGroupId(testZoneAdminGroupId)
zones <- repo.getFirstOwnedZoneAclGroupId(adminId)
} yield zones
f.unsafeRunSync() shouldBe Some(zoneId)

View File

@ -0,0 +1,8 @@
CREATE SCHEMA IF NOT EXISTS ${dbName};
USE ${dbName};
ALTER TABLE zone_access
DROP CONSTRAINT fk_zone_access,
ADD zone_status CHAR(36) NOT NULL
;

View File

@ -237,10 +237,7 @@ CREATE TABLE IF NOT EXISTS zone_access
(
accessor_id char(36) not null,
zone_id char(36) not null,
primary key (accessor_id, zone_id),
constraint fk_zone_access_zone_id
foreign key (zone_id) references zone (id)
on delete cascade
primary key (accessor_id, zone_id)
);
CREATE INDEX IF NOT EXISTS zone_access_accessor_id_index

View File

@ -47,9 +47,9 @@ class MySqlGroupChangeRepository extends GroupChangeRepository with Monitored {
sql"""
|SELECT data
| FROM group_change
| WHERE group_id = {groupId} AND created_timestamp < {startFrom}
| WHERE group_id = {groupId}
| ORDER BY created_timestamp DESC
| LIMIT {maxItems}
| LIMIT {maxItems} OFFSET {startFrom}
""".stripMargin
private final val LIST_GROUP_CHANGE_NO_START =
@ -100,7 +100,7 @@ class MySqlGroupChangeRepository extends GroupChangeRepository with Monitored {
def getGroupChanges(
groupId: String,
startFrom: Option[String],
startFrom: Option[Int],
maxItems: Int
): IO[ListGroupChangesResults] =
monitor("repo.GroupChange.getGroupChanges") {
@ -112,21 +112,25 @@ class MySqlGroupChangeRepository extends GroupChangeRepository with Monitored {
val query = startFrom match {
case Some(start) =>
LIST_GROUP_CHANGES_WITH_START
.bindByName('groupId -> groupId, 'startFrom -> start, 'maxItems -> maxItems)
.bindByName('groupId -> groupId, 'startFrom -> start, 'maxItems -> (maxItems + 1))
case None =>
LIST_GROUP_CHANGE_NO_START
.bindByName('groupId -> groupId, 'maxItems -> maxItems)
.bindByName('groupId -> groupId, 'maxItems -> (maxItems + 1))
}
val queryResult = query
.map(toGroupChange(1))
.list()
.apply()
val nextId =
if (queryResult.size < maxItems) None
else queryResult.lastOption.map(_.created.toEpochMilli.toString)
val maxQueries = queryResult.take(maxItems)
val startValue = startFrom.getOrElse(0)
ListGroupChangesResults(queryResult, nextId)
val nextId = queryResult match {
case _ if queryResult.size <= maxItems | queryResult.isEmpty => None
case _ => Some(startValue + maxItems)
}
ListGroupChangesResults(maxQueries, nextId)
}
}
}

View File

@ -17,10 +17,13 @@
package vinyldns.mysql.repository
import cats.effect.IO
import java.time.Instant
import java.time.temporal.ChronoUnit
import org.slf4j.LoggerFactory
import scalikejdbc._
import vinyldns.core.domain.auth.AuthPrincipal
import vinyldns.core.domain.membership.User
import vinyldns.core.domain.zone._
import vinyldns.core.protobuf._
import vinyldns.core.route.Monitored
@ -32,12 +35,26 @@ class MySqlZoneChangeRepository
with Monitored {
private final val logger = LoggerFactory.getLogger(classOf[MySqlZoneChangeRepository])
private final val MAX_ACCESSORS = 30
private final val PUT_ZONE_CHANGE =
sql"""
|REPLACE INTO zone_change (change_id, zone_id, data, created_timestamp)
| VALUES ({change_id}, {zone_id}, {data}, {created_timestamp})
""".stripMargin
private final val BASE_ZONE_CHANGE_SEARCH_SQL =
"""
|SELECT zc.data
| FROM zone_change zc
""".stripMargin
private final val BASE_GET_ZONES_SQL =
"""
|SELECT z.data
| FROM zone z
""".stripMargin
private final val LIST_ZONES_CHANGES =
sql"""
|SELECT zc.data
@ -110,6 +127,106 @@ class MySqlZoneChangeRepository
}
}
private def withAccessors(
user: User,
groupIds: Seq[String],
ignoreAccessZones: Boolean
): (String, Seq[Any]) =
// Super users do not need to join across to check zone access as they have access to all of the zones
if (ignoreAccessZones || user.isSuper || user.isSupport) {
(BASE_ZONE_CHANGE_SEARCH_SQL, Seq.empty)
} else {
// User is not super or support,
// let's join across to the zone access table so we return only zones a user has access to
val accessors = buildZoneSearchAccessorList(user, groupIds)
val questionMarks = List.fill(accessors.size)("?").mkString(",")
val withAccessorCheck = BASE_ZONE_CHANGE_SEARCH_SQL +
s"""
| JOIN zone_access za ON zc.zone_id = za.zone_id
| AND za.accessor_id IN ($questionMarks)
""".stripMargin
(withAccessorCheck, accessors)
}
/* Limit the accessors so that we don't have boundless parameterized queries */
private def buildZoneSearchAccessorList(user: User, groupIds: Seq[String]): Seq[String] = {
val allAccessors = user.id +: groupIds
if (allAccessors.length > MAX_ACCESSORS) {
logger.warn(
s"User ${user.userName} with id ${user.id} is in more than $MAX_ACCESSORS groups, no all zones maybe returned!"
)
}
// Take the top 30 accessors, but add "EVERYONE" to the list so that we include zones that have everyone access
allAccessors.take(MAX_ACCESSORS) :+ "EVERYONE"
}
def listDeletedZones(
authPrincipal: AuthPrincipal,
zoneNameFilter: Option[String] = None,
startFrom: Option[String] = None,
maxItems: Int = 100,
ignoreAccess: Boolean = false
): IO[ListDeletedZonesChangeResults] =
monitor("repo.ZoneChange.listDeletedZoneInZoneChanges") {
IO {
DB.readOnly { implicit s =>
val (withAccessorCheck, accessors) =
withAccessors(authPrincipal.signedInUser, authPrincipal.memberGroupIds, ignoreAccess)
val sb = new StringBuilder
sb.append(withAccessorCheck)
val query = sb.toString
val zoneChangeResults: List[ZoneChange] =
SQL(query)
.bind(accessors: _*)
.map(extractZoneChange(1))
.list()
.apply()
val zoneResults: List[Zone] =
SQL(BASE_GET_ZONES_SQL)
.map(extractZone(1))
.list()
.apply()
val zoneNotInZoneChange: List[ZoneChange] =
zoneChangeResults.filter(z=> !zoneResults.map(_.name).contains(z.zone.name) && z.zone.status != ZoneStatus.Active)
val deletedZoneResults: List[ZoneChange] =
zoneNotInZoneChange.filter(_.zone.status.equals(ZoneStatus.Deleted)).distinct.sortBy(_.zone.updated).reverse
val results: List[ZoneChange] =
if (zoneNameFilter.nonEmpty) {
deletedZoneResults.filter(r => r.zone.name.contains(zoneNameFilter.getOrElse("not found")))
} else {
deletedZoneResults
}
val deletedZonesWithStartFrom: List[ZoneChange] = startFrom match {
case Some(zoneId) => results.dropWhile(_.zone.id != zoneId)
case None => results
}
val deletedZonesWithMaxItems = deletedZonesWithStartFrom.take(maxItems + 1)
val (newResults, nextId) =
if (deletedZonesWithMaxItems.size > maxItems)
(deletedZonesWithMaxItems.dropRight(1), deletedZonesWithMaxItems.lastOption.map(_.zone.id))
else (deletedZonesWithMaxItems, None)
ListDeletedZonesChangeResults(
zoneDeleted = newResults,
nextId = nextId,
startFrom = startFrom,
maxItems = maxItems,
zoneChangeFilter = zoneNameFilter,
ignoreAccess = ignoreAccess
)
}}}
def listFailedZoneChanges(maxItems: Int, startFrom: Int): IO[ListFailedZoneChangesResults] =
monitor("repo.ZoneChange.listFailedZoneChanges") {
IO {
@ -130,4 +247,8 @@ class MySqlZoneChangeRepository
private def extractZoneChange(colIndex: Int): WrappedResultSet => ZoneChange = res => {
fromPB(VinylDNSProto.ZoneChange.parseFrom(res.bytes(colIndex)))
}
private def extractZone(columnIndex: Int): WrappedResultSet => Zone = res => {
fromPB(VinylDNSProto.Zone.parseFrom(res.bytes(columnIndex)))
}
}

View File

@ -71,10 +71,17 @@ class MySqlZoneRepository extends ZoneRepository with ProtobufConversions with M
*/
private final val PUT_ZONE_ACCESS =
sql"""
|REPLACE INTO zone_access(accessor_id, zone_id)
| VALUES ({accessorId}, {zoneId})
|REPLACE INTO zone_access(accessor_id, zone_id, zone_status)
| VALUES ({accessorId}, {zoneId}, {zoneStatus})
""".stripMargin
private final val UPDATE_ZONE_ACCESS =
sql"""
|UPDATE zone_access
| SET zone_status = {zoneStatus}
| WHERE zone_id = {zoneId}
""".stripMargin
private final val DELETE_ZONE_ACCESS =
sql"""
|DELETE
@ -127,8 +134,8 @@ class MySqlZoneRepository extends ZoneRepository with ProtobufConversions with M
private final val GET_ZONE_ACCESS_BY_ADMIN_GROUP_ID =
sql"""
|SELECT zone_id
| FROM zone_access z
| WHERE z.accessor_id = (?)
| FROM zone_access za
| WHERE za.accessor_id = (?) AND za.zone_status <> 'Deleted'
| LIMIT 1
""".stripMargin
@ -503,10 +510,10 @@ class MySqlZoneRepository extends ZoneRepository with ProtobufConversions with M
val sqlParameters: Seq[Seq[(Symbol, Any)]] =
zone.acl.rules.toSeq
.map(r => r.userId.orElse(r.groupId).getOrElse("EVERYONE")) // if the user and group are empty, assert everyone
.map(userOrGroupId => Seq('accessorId -> userOrGroupId, 'zoneId -> zone.id))
.map(userOrGroupId => Seq('accessorId -> userOrGroupId, 'zoneId -> zone.id, 'zoneStatus -> zone.status.toString))
// we MUST make sure that we put the admin group id as an accessor to this zone
val allAccessors = sqlParameters :+ Seq('accessorId -> zone.adminGroupId, 'zoneId -> zone.id)
val allAccessors = sqlParameters :+ Seq('accessorId -> zone.adminGroupId, 'zoneId -> zone.id,'zoneStatus -> zone.status.toString)
// make sure that we do a distinct, so that we don't generate unnecessary inserts
PUT_ZONE_ACCESS.batchByName(allAccessors.distinct: _*).apply()
@ -518,6 +525,12 @@ class MySqlZoneRepository extends ZoneRepository with ProtobufConversions with M
zone
}
private def updateZoneAccess(zone: Zone)(implicit session: DBSession): Zone = {
UPDATE_ZONE_ACCESS.bindByName(
'zoneStatus ->zone.status.toString, 'zoneId ->zone.id).update().apply()
zone
}
private def deleteZoneAccess(zone: Zone)(implicit session: DBSession): Zone = {
DELETE_ZONE_ACCESS.bind(zone.id).update().apply()
zone
@ -532,6 +545,7 @@ class MySqlZoneRepository extends ZoneRepository with ProtobufConversions with M
IO {
DB.localTx { implicit s =>
deleteZone(zone)
updateZoneAccess(zone)
}
}
}

View File

@ -486,6 +486,20 @@ class VinylDNS @Inject() (
})
}
def getDeletedZones: Action[AnyContent] = userAction.async { implicit request =>
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", "zones/deleted/changes", parameters = queryParameters)
executeRequest(vinyldnsRequest, request.user).map(response => {
Status(response.status)(response.body)
.withHeaders(cacheHeaders: _*)
})
// $COVERAGE-ON$
}
def getZoneChange(id: String): Action[AnyContent] = userAction.async { implicit request =>
val queryParameters = new HashMap[String, java.util.List[String]]()
for {

View File

@ -12,7 +12,7 @@
<!-- END BREADCRUMB -->
<!-- PAGE TITLE -->
<div class="page-title"><h3><span class="fa fa-list-ol"></span> DNS Changes {{ getPageTitle() }}</h3></div>
<div class="page-title"><h3><span class="fa fa-list-ol"></span> DNS Changes</h3></div>
<!-- END PAGE TITLE -->
<!-- PAGE CONTENT WRAPPER -->
@ -58,7 +58,8 @@
<div class="panel-body">
<!-- PAGINATION -->
<div class="dataTables_paginate">
<div class="dataTables_paginate vinyldns_paginate">
<span class="vinyldns_page_number">{{ getPageTitle() }}</span>
<ul class="pagination">
<li class="paginate_button previous">
<a type="button" ng-if="prevPageEnabled()" ng-click="prevPage()">Previous</a>
@ -125,7 +126,8 @@
</table>
<!-- PAGINATION -->
<div class="dataTables_paginate">
<div class="dataTables_paginate vinyldns_paginate">
<span class="vinyldns_page_number">{{ getPageTitle() }}</span>
<ul class="pagination">
<li class="paginate_button previous">
<a ng-if="prevPageEnabled()" ng-click="prevPage()">Previous</a>

View File

@ -121,7 +121,7 @@
<!-- START SIMPLE DATATABLE -->
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">All Group Changes {{ getChangePageTitle() }}</h3>
<h3 class="panel-title">All Group Changes</h3>
</div>
<div class="panel-body">
<div class="btn-group">
@ -129,7 +129,8 @@
</div>
<!-- PAGINATION -->
<div class="dataTables_paginate">
<div class="dataTables_paginate vinyldns_paginate">
<span class="vinyldns_page_number">{{ getChangePageTitle() }}</span>
<ul class="pagination">
<li class="paginate_button previous">
<a ng-if="changePrevPageEnabled()" ng-click="changePrevPage()" class="paginate_button">Previous</a>
@ -171,7 +172,8 @@
</table>
<!-- PAGINATION -->
<div class="dataTables_paginate">
<div class="dataTables_paginate vinyldns_paginate">
<span class="vinyldns_page_number">{{ getChangePageTitle() }}</span>
<ul class="pagination">
<li class="paginate_button previous">
<a ng-if="changePrevPageEnabled()" ng-click="changePrevPage()" class="paginate_button">Previous</a>

View File

@ -426,7 +426,8 @@
</div>
<!-- PAGINATION -->
<div class="dataTables_paginate">
<div class="dataTables_paginate vinyldns_paginate">
<span class="vinyldns_page_number">{{ getRecordChangePageTitle() }}</span>
<ul class="pagination">
<li class="paginate_button previous">
<a ng-if="changeHistoryPrevPageEnabled()" ng-click="changeHistoryPrevPage()" class="paginate_button">Previous</a>
@ -476,7 +477,8 @@
</table>
<!-- PAGINATION -->
<div class="dataTables_paginate">
<div class="dataTables_paginate vinyldns_paginate">
<span class="vinyldns_page_number">{{ getRecordChangePageTitle() }}</span>
<ul class="pagination">
<li class="paginate_button previous">
<a ng-if="changeHistoryPrevPageEnabled()" ng-click="changeHistoryPrevPage()" class="paginate_button">Previous</a>

View File

@ -3,7 +3,7 @@
<!-- START SIMPLE DATATABLE -->
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">All Record Changes {{ getChangePageTitle() }}</h3>
<h3 class="panel-title">All Record Changes</h3>
</div>
<div class="panel-body">
<div class="btn-group">
@ -11,7 +11,8 @@
</div>
<!-- PAGINATION -->
<div class="dataTables_paginate">
<div class="dataTables_paginate vinyldns_paginate">
<span class="vinyldns_page_number">{{ getChangePageTitle() }}</span>
<ul class="pagination">
<li class="paginate_button previous">
<a ng-if="changePrevPageEnabled()" ng-click="changePrevPage()" class="paginate_button">Previous</a>
@ -61,7 +62,8 @@
</table>
<!-- PAGINATION -->
<div class="dataTables_paginate">
<div class="dataTables_paginate vinyldns_paginate">
<span class="vinyldns_page_number">{{ getChangePageTitle() }}</span>
<ul class="pagination">
<li class="paginate_button previous">
<a ng-if="changePrevPageEnabled()" ng-click="changePrevPage()" class="paginate_button">Previous</a>

View File

@ -5,13 +5,13 @@
<div class="panel panel-default x_panel" ng-init="sharedDisplayEnabled = @meta.sharedDisplayEnabled; defaultTtl = @meta.defaultTtl">
<div class="panel-heading">
<h3 class="panel-title">
<a class="collapse-link">
<a class="collapse-link" href data-toggle="collapse" data-target="#recordChangePreviewTableCollapse" aria-expanded="false" aria-controls="recordChangePreviewTableCollapse">
<i class="fa fa-chevron-down"></i>
Recent Record Changes
</a>
</h3>
</div>
<div class="panel-collapse collapse in" id="recordChangePreviewTableCollapse">
<div class="panel-body x_content panel-body-open">
<button class="btn btn-default" ng-click="refreshRecordChangesPreview()"><span class="fa fa-refresh"></span> Refresh</button>
<table id="recordChangePreviewTable" class="table table-condensed">
@ -52,6 +52,7 @@
</table>
<!-- <button type="button" class="btn btn-info btn-sm">View full change log</button> -->
</div>
</div>
</div>
<!-- START RECENT RECORDSET CHANGES TABLE PANEL -->
<!-- END ACCORDION -->

View File

@ -75,7 +75,7 @@
<label class="col-md-3 control-label">Access</label>
<div class="col-md-9">
<div>
<select class="form-control" ng-model="updateZoneInfo.shared">
<select class="form-control" ng-model="updateZoneInfo.shared" ng-disabled="!isZoneAdmin">
<option ng-value="true" ng-model="updateZoneInfo.shared"
ng-selected="updateZoneInfo.shared == true">
Shared</option>

View File

@ -9,6 +9,21 @@
<div class="btn-group">
<button class="btn btn-default" ng-click="refreshZoneChange()"><span class="fa fa-refresh"></span> Refresh</button>
</div>
<!-- PAGINATION -->
<div class="dataTables_paginate vinyldns_paginate">
<span class="vinyldns_page_number">{{ getZoneHistoryPageNumber() }}</span>
<ul class="pagination">
<li class="paginate_button previous">
<a ng-if="prevPageEnabled()" ng-click="prevPageZoneHistory()" class="paginate_button">Previous</a>
</li>
<li class="paginate_button next">
<a ng-if="nextPageEnabled()" ng-click="nextPageZoneHistory()" class="paginate_button">Next</a>
</li>
</ul>
</div>
<!-- END PAGINATION -->
<table id="zoneChangeDataTable" class="table table-hover table-striped">
<thead>
<tr>
@ -46,8 +61,8 @@
</table>
<!-- PAGINATION -->
<div class="dataTables_paginate vinyldns_zones_paginate">
<span class="vinyldns_zones_page_number">{{ getZoneHistoryPageNumber() }}</span>
<div class="dataTables_paginate vinyldns_paginate">
<span class="vinyldns_page_number">{{ getZoneHistoryPageNumber() }}</span>
<ul class="pagination">
<li class="paginate_button previous">
<a ng-if="prevPageEnabled()" ng-click="prevPageZoneHistory()" class="paginate_button">Previous</a>

View File

@ -29,6 +29,7 @@
<ul class="nav nav-tabs bar_tabs">
<li class="active"><a href="#myZones" data-toggle="tab" ng-click="myZonesAccess()">My Zones</a></li>
<li><a id="tab2-button" href="#allZones" data-toggle="tab" ng-click="allZonesAccess()">All Zones</a></li>
<li><a id="tab3-button" href="#deletedZones" data-toggle="tab">Abandoned Zones</a></li>
</ul>
<div class="panel-body tab-content">
@ -278,6 +279,209 @@
</div>
</div>
</div>
<div class="tab-pane" id="deletedZones">
<div class="row">
<div class="col-md-12">
<!-- SIMPLE DATATABLE -->
<div class="panel panel-default">
<div class="panel-heading">
<button id="zone-refresh-button" class="btn btn-default" ng-click="refreshZones()">
<span class="fa fa-refresh"></span> Refresh
</button>
<!-- SEARCH BOX -->
<div class="pull-right">
<form class="input-group" ng-submit="refreshZones()">
<div class="input-group">
<span class="input-group-btn">
<button id="my-deleted-zones-search-button" type="submit" class="btn btn-primary btn-left-round">
<span class="fa fa-search"></span>
</button>
</span>
<input id="deleted-zones-search-text" ng-model="query" type="text" class="form-control" placeholder="Zone Name"/>
</div>
</form>
</div>
<!-- END SEARCH BOX -->
<!-- DELETED ZONES TABS -->
<div class="panel panel-default panel-tabs">
<ul class="nav nav-tabs bar_tabs">
<li class="active"><a href="#myDeletedZones" data-toggle="tab">My Zones</a></li>
<li><a id="tab2-button" href="#allDeletedZones" data-toggle="tab">All Zones</a></li>
</ul>
<div class="panel-body tab-content">
<div class="tab-pane active" id="myDeletedZones">
<div id="zone-list-table" class="panel-body">
<p ng-if="!myDeletedZonesLoaded">Loading zones...</p>
<p ng-if="myDeletedZonesLoaded && !myDeletedZones.length">No zones match the search criteria.</p>
<!-- PAGINATION -->
<div class="dataTables_paginate vinyldns_zones_paginate">
<span class="vinyldns_zones_page_number">{{ getZonesPageNumber("myDeletedZones") }}</span>
<ul class="pagination">
<li class="paginate_button previous">
<a ng-if="prevPageEnabled('myDeletedZones')" ng-click="prevPageMyDeletedZones()">Previous</a>
</li>
<li class="paginate_button next">
<a ng-if="nextPageEnabled('myDeletedZones')" ng-click="nextPageMyDeletedZones()">Next</a>
</li>
</ul>
</div>
<!-- END PAGINATION -->
<table class="table" ng-if="myDeletedZones.length">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Admin Group</th>
<th>Created</th>
<th>Abandoned</th>
<th>Status</th>
<th>Abandoned By</th>
@if(meta.sharedDisplayEnabled) {
<th>Access</th>
}
</tr>
</thead>
<tbody>
<tr ng-repeat="deletedZone in myDeletedZones">
<td class="wrap-long-text" ng-bind="deletedZone.zoneChange.zone.name">
</td>
<td class="wrap-long-text" ng-bind="deletedZone.zoneChange.zone.email">
</td>
<td>
<a ng-if="canAccessGroup(deletedZone.zoneChange.zone.adminGroupId)" ng-bind="deletedZone.adminGroupName"
href="/groups/{{deletedZone.zoneChange.zone.adminGroupId}}"></a>
<span ng-if="!canAccessGroup(deletedZone.zoneChange.zone.adminGroupId)" ng-bind="deletedZone.adminGroupName"
style="line-height: 0"></span>
</td>
<td>
{{deletedZone.zoneChange.zone.created}}
</td>
<td>
{{deletedZone.zoneChange.zone.updated}}
</td>
<td ng-bind="deletedZone.zoneChange.zone.status"></td>
<td>
{{deletedZone.userName}}
</td>
@if(meta.sharedDisplayEnabled) {
<td>{{zone.shared ? "Shared" : "Private"}}</td>
}
</tr>
</tbody>
</table>
<!-- PAGINATION -->
<div class="dataTables_paginate vinyldns_zones_paginate">
<span class="vinyldns_zones_page_number">{{ getZonesPageNumber("myDeletedZones") }}</span>
<ul class="pagination">
<li class="paginate_button previous">
<a ng-if="prevPageEnabled('myDeletedZones')" ng-click="prevPageMyDeletedZones()">Previous</a>
</li>
<li class="paginate_button next">
<a ng-if="nextPageEnabled('myDeletedZones')" ng-click="nextPageMyDeletedZones()">Next</a>
</li>
</ul>
</div>
<!-- END PAGINATION -->
</div>
</div>
<div class="tab-pane" id="allDeletedZones">
<div id="zone-list-table" class="panel-body">
<p ng-if="!allDeletedZonesLoaded">Loading zones...</p>
<p ng-if="allDeletedZonesLoaded && !allDeletedZones.length">No zones match the search criteria.</p>
<!-- PAGINATION -->
<div class="dataTables_paginate vinyldns_zones_paginate">
<span class="vinyldns_zones_page_number">{{ getZonesPageNumber("allDeletedZones") }}</span>
<ul class="pagination">
<li class="paginate_button previous">
<a ng-if="prevPageEnabled('allDeletedZones')" ng-click="prevPageAllDeletedZones()">Previous</a>
</li>
<li class="paginate_button next">
<a ng-if="nextPageEnabled('allDeletedZones')" ng-click="nextPageAllDeletedZones()">Next</a>
</li>
</ul>
</div>
<!-- END PAGINATION -->
<table class="table" ng-if="allDeletedZones.length">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Admin Group</th>
<th>Created</th>
<th>Abandoned</th>
<th>Status</th>
<th>Abandoned By</th>
@if(meta.sharedDisplayEnabled) {
<th>Access</th>
}
</tr>
</thead>
<tbody>
<tr ng-repeat="deletedZone in allDeletedZones">
<td class="wrap-long-text" ng-bind="deletedZone.zoneChange.zone.name">
</td>
<td class="wrap-long-text" ng-bind="deletedZone.zoneChange.zone.email">
</td>
<td>
<a ng-if="canAccessGroup(deletedZone.zoneChange.zone.adminGroupId)" ng-bind="deletedZone.adminGroupName"
href="/groups/{{deletedZone.zoneChange.zone.adminGroupId}}"></a>
<span ng-if="!canAccessGroup(deletedZone.zoneChange.zone.adminGroupId)" ng-bind="deletedZone.adminGroupName"
style="line-height: 0"></span>
</td>
<td>
{{deletedZone.zoneChange.zone.created}}
</td>
<td>
{{deletedZone.zoneChange.zone.updated}}
</td>
<td ng-bind="deletedZone.zoneChange.zone.status"></td>
<td>
{{deletedZone.userName}}
</td>
@if(meta.sharedDisplayEnabled) {
<td>{{zone.shared ? "Shared" : "Private"}}</td>
}
</tr>
</tbody>
</table>
<!-- PAGINATION -->
<div class="dataTables_paginate vinyldns_zones_paginate">
<span class="vinyldns_zones_page_number">{{ getZonesPageNumber("allDeletedZones") }}</span>
<ul class="pagination">
<li class="paginate_button previous">
<a ng-if="prevPageEnabled('allDeletedZones')" ng-click="prevPageAllDeletedZones()">Previous</a>
</li>
<li class="paginate_button next">
<a ng-if="nextPageEnabled('allDeletedZones')" ng-click="nextPageAllDeletedZones()">Next</a>
</li>
</ul>
</div>
<!-- END PAGINATION -->
</div>
</div>
</div>
</div>
<!-- END DELETED ZONES TABS -->
</div>
<div class="panel-footer"></div>
</div>
<!-- END SIMPLE DATATABLE -->
</div>
</div>
</div>
</div>
</div>
<!-- END VERTICAL TABS -->

View File

@ -31,6 +31,7 @@ GET /api/zones/backendids @controllers.VinylDNS.getBacken
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/deleted/changes @controllers.VinylDNS.getDeletedZones
GET /api/zones/:id/changes @controllers.VinylDNS.getZoneChange(id: String)
POST /api/zones @controllers.VinylDNS.addZone
PUT /api/zones/:id @controllers.VinylDNS.updateZone(id: String)

View File

@ -42,6 +42,13 @@ describe('Controller: ManageZonesController', function () {
}
});
};
zonesService.getDeletedZones = function() {
return $q.when({
data: {
zonesDeletedInfo: ["all my deleted zones"]
}
});
};
zonesService.getBackendIds = function() {
return $q.when({
data: ['backend-1', 'backend-2']

View File

@ -23,6 +23,8 @@ angular.module('controller.zones', [])
$scope.allZonesLoaded = false;
$scope.hasZones = false; // Re-assigned each time zones are fetched without a query
$scope.allGroups = [];
$scope.myDeletedZones = [];
$scope.allDeletedZones = [];
$scope.ignoreAccess = false;
$scope.validEmailDomains= [];
$scope.allZonesAccess = function () {
@ -41,6 +43,8 @@ angular.module('controller.zones', [])
// Paging status for zone sets
var zonesPaging = pagingService.getNewPagingParams(100);
var allZonesPaging = pagingService.getNewPagingParams(100);
var myDeleteZonesPaging = pagingService.getNewPagingParams(100);
var allDeleteZonesPaging = pagingService.getNewPagingParams(100);
profileService.getAuthenticatedUserData().then(function (results) {
if (results.data) {
$scope.profile = results.data;
@ -197,8 +201,53 @@ angular.module('controller.zones', [])
.catch(function (error) {
handleError(error, 'zonesService::getZones-failure');
});
zonesService
.getDeletedZones(myDeleteZonesPaging.maxItems, undefined, $scope.query, false)
.then(function (response) {
$log.debug('zonesService::getMyDeletedZones-success (' + response.data.zonesDeletedInfo.length + ' zones)');
myDeleteZonesPaging.next = response.data.nextId;
updateMyDeletedZoneDisplay(response.data.zonesDeletedInfo);
})
.catch(function (error) {
handleError(error, 'zonesService::getDeletedZones-failure');
});
zonesService
.getDeletedZones(allDeleteZonesPaging.maxItems, undefined, $scope.query, true)
.then(function (response) {
$log.debug('zonesService::getAllDeletedZones-success (' + response.data.zonesDeletedInfo.length + ' zones)');
allDeleteZonesPaging.next = response.data.nextId;
updateAllDeletedZoneDisplay(response.data.zonesDeletedInfo);
})
.catch(function (error) {
handleError(error, 'zonesService::getDeletedZones-failure');
});
};
function updateMyDeletedZoneDisplay (myDeletedZones) {
$scope.myDeletedZones = myDeletedZones;
$scope.myDeletedZonesLoaded = true;
$log.debug("Displaying my Deleted zones: ", $scope.myDeletedZones);
if($scope.myDeletedZones.length > 0) {
$("td.dataTables_empty").hide();
} else {
$("td.dataTables_empty").show();
}
}
function updateAllDeletedZoneDisplay (allDeletedZones) {
$scope.allDeletedZones = allDeletedZones;
$scope.allDeletedZonesLoaded = true;
$log.debug("Displaying all Deleted zones: ", $scope.allDeletedZones);
if($scope.allDeletedZones.length > 0) {
$("td.dataTables_empty").hide();
} else {
$("td.dataTables_empty").show();
}
}
function updateZoneDisplay (zones) {
$scope.zones = zones;
$scope.myZoneIds = zones.map(function(zone) {return zone['id']});
@ -283,6 +332,10 @@ angular.module('controller.zones', [])
return pagingService.getPanelTitle(zonesPaging);
case 'allZones':
return pagingService.getPanelTitle(allZonesPaging);
case 'myDeletedZones':
return pagingService.getPanelTitle(myDeleteZonesPaging);
case 'allDeletedZones':
return pagingService.getPanelTitle(allDeleteZonesPaging);
}
};
@ -292,6 +345,10 @@ angular.module('controller.zones', [])
return pagingService.prevPageEnabled(zonesPaging);
case 'allZones':
return pagingService.prevPageEnabled(allZonesPaging);
case 'myDeletedZones':
return pagingService.prevPageEnabled(myDeleteZonesPaging);
case 'allDeletedZones':
return pagingService.prevPageEnabled(allDeleteZonesPaging);
}
};
@ -301,6 +358,10 @@ angular.module('controller.zones', [])
return pagingService.nextPageEnabled(zonesPaging);
case 'allZones':
return pagingService.nextPageEnabled(allZonesPaging);
case 'myDeletedZones':
return pagingService.nextPageEnabled(myDeleteZonesPaging);
case 'allDeletedZones':
return pagingService.nextPageEnabled(allDeleteZonesPaging);
}
};
@ -330,6 +391,32 @@ angular.module('controller.zones', [])
});
}
$scope.prevPageMyDeletedZones = function() {
var startFrom = pagingService.getPrevStartFrom(myDeleteZonesPaging);
return zonesService
.getDeletedZones(myDeleteZonesPaging.maxItems, startFrom, $scope.query, false)
.then(function(response) {
myDeleteZonesPaging = pagingService.prevPageUpdate(response.data.nextId, myDeleteZonesPaging);
updateMyDeletedZoneDisplay(response.data.zonesDeletedInfo);
})
.catch(function (error) {
handleError(error,'zonesService::prevPage-failure');
});
}
$scope.prevPageAllDeletedZones = function() {
var startFrom = pagingService.getPrevStartFrom(allDeleteZonesPaging);
return zonesService
.getDeletedZones(allDeleteZonesPaging.maxItems, startFrom, $scope.query, true)
.then(function(response) {
allDeleteZonesPaging = pagingService.prevPageUpdate(response.data.nextId, allDeleteZonesPaging);
updateAllDeletedZoneDisplay(response.data.zonesDeletedInfo);
})
.catch(function (error) {
handleError(error,'zonesService::prevPage-failure');
});
}
$scope.nextPageMyZones = function () {
return zonesService
.getZones(zonesPaging.maxItems, zonesPaging.next, $scope.query, $scope.searchByAdminGroup, false, true)
@ -362,5 +449,37 @@ angular.module('controller.zones', [])
});
};
$scope.nextPageMyDeletedZones = function () {
return zonesService
.getDeletedZones(myDeleteZonesPaging.maxItems, myDeleteZonesPaging.next, $scope.query, false)
.then(function(response) {
var myDeletedZoneSets = response.data.zonesDeletedInfo;
myDeleteZonesPaging = pagingService.nextPageUpdate(myDeletedZoneSets, response.data.nextId, myDeleteZonesPaging);
if (myDeletedZoneSets.length > 0) {
updateMyDeletedZoneDisplay(response.data.zonesDeletedInfo);
}
})
.catch(function (error) {
handleError(error,'zonesService::nextPage-failure')
});
};
$scope.nextPageAllDeletedZones = function () {
return zonesService
.getDeletedZones(allDeleteZonesPaging.maxItems, allDeleteZonesPaging.next, $scope.query, false)
.then(function(response) {
var allDeletedZoneSets = response.data.zonesDeletedInfo;
allDeleteZonesPaging = pagingService.nextPageUpdate(allDeletedZoneSets, response.data.nextId, allDeleteZonesPaging);
if (allDeletedZoneSets.length > 0) {
updateAllDeletedZoneDisplay(response.data.zonesDeletedInfo);
}
})
.catch(function (error) {
handleError(error,'zonesService::nextPage-failure')
});
};
$timeout($scope.refreshZones, 0);
});

View File

@ -114,4 +114,77 @@ describe('Controller: ZonesController', function () {
expect(getZoneSets.calls.mostRecent().args).toEqual(
[expectedMaxItems, expectedStartFrom, expectedQuery, expectedSearchByAdminGroup, expectedignoreAccess, expectedincludeReverse]);
});
it('nextPageZones should call getDeletedZones with the correct parameters', function () {
mockDeletedZone = {zonesDeletedInfo:[ {
zoneChanges: [{ zone: {
name: "dummy.",
email: "test@test.com",
status: "Deleted",
created: "2017-02-15T14:58:39Z",
account: "c8234503-bfda-4b80-897f-d74129051eaa",
acl: {rules: []},
adminGroupId: "c8234503-bfda-4b80-897f-d74129051eaa",
id: "c5c87405-2ec8-4e03-b2dc-c6758a5d9666",
shared: false,
status: "Active",
latestSync: "2017-02-15T14:58:39Z",
isTest: true
}}],maxItems: 100}]};
var getDeletedZoneSets = spyOn(this.zonesService, 'getDeletedZones')
.and.stub()
.and.returnValue(this.zonesService.q.when(mockDeletedZone));
var expectedMaxItems = 100;
var expectedStartFrom = undefined;
var expectedQuery = this.scope.query;
var expectedIgnoreAccess = false;
this.scope.nextPageMyDeletedZones();
expect(getDeletedZoneSets.calls.count()).toBe(1);
expect(getDeletedZoneSets.calls.mostRecent().args).toEqual(
[expectedMaxItems, expectedStartFrom, expectedQuery, expectedIgnoreAccess]);
});
it('prevPageZones should call getDeletedZones with the correct parameters', function () {
mockDeletedZone = {zonesDeletedInfo:[ {
zoneChanges: [{ zone: {
name: "dummy.",
email: "test@test.com",
status: "Deleted",
created: "2017-02-15T14:58:39Z",
account: "c8234503-bfda-4b80-897f-d74129051eaa",
acl: {rules: []},
adminGroupId: "c8234503-bfda-4b80-897f-d74129051eaa",
id: "c5c87405-2ec8-4e03-b2dc-c6758a5d9666",
shared: false,
status: "Active",
latestSync: "2017-02-15T14:58:39Z",
isTest: true
}}],maxItems: 100}]};
var getDeletedZoneSets = spyOn(this.zonesService, 'getDeletedZones')
.and.stub()
.and.returnValue(this.zonesService.q.when(mockDeletedZone));
var expectedMaxItems = 100;
var expectedStartFrom = undefined;
var expectedQuery = this.scope.query;
var expectedIgnoreAccess = false;
this.scope.prevPageMyDeletedZones();
expect(getDeletedZoneSets.calls.count()).toBe(1);
expect(getDeletedZoneSets.calls.mostRecent().args).toEqual(
[expectedMaxItems, expectedStartFrom, expectedQuery, expectedIgnoreAccess]);
this.scope.nextPageMyDeletedZones();
this.scope.prevPageMyDeletedZones();
expect(getDeletedZoneSets.calls.count()).toBe(3);
expect(getDeletedZoneSets.calls.mostRecent().args).toEqual(
[expectedMaxItems, expectedStartFrom, expectedQuery, expectedIgnoreAccess]);
});
});

View File

@ -264,6 +264,9 @@
/**
* Record change history paging
*/
$scope.getRecordChangePageTitle = function() {
return pagingService.getPanelTitle(changePaging);
};
$scope.changeHistoryPrevPageEnabled = function() {
return pagingService.prevPageEnabled(changePaging);

View File

@ -53,6 +53,20 @@ angular.module('service.zones', [])
return $http.get(url);
};
this.getDeletedZones = function (limit, startFrom, query, ignoreAccess) {
if (query == "") {
query = null;
}
var params = {
"maxItems": limit,
"startFrom": startFrom,
"nameFilter": query,
"ignoreAccess": ignoreAccess
};
var url = groupsService.urlBuilder("/api/zones/deleted/changes", params);
return $http.get(url);
};
this.getBackendIds = function() {
var url = "/api/zones/backendids";
return $http.get(url);

View File

@ -54,6 +54,24 @@ describe('Service: zoneService', function () {
this.$httpBackend.flush();
});
it('http backend gets called properly when getting my deleted zones', function () {
this.$httpBackend.expectGET('/api/zones/deleted/changes?maxItems=100&startFrom=start&nameFilter=someQuery&ignoreAccess=true').respond('deleted my zone returned');
this.zonesService.getDeletedZones('100', 'start', 'someQuery', true)
.then(function(response) {
expect(response.data).toBe('deleted my zone returned');
});
this.$httpBackend.flush();
});
it('http backend gets called properly when getting all deleted zones', function () {
this.$httpBackend.expectGET('/api/zones/deleted/changes?maxItems=100&startFrom=start&nameFilter=someQuery&ignoreAccess=false').respond('deleted all zone returned');
this.zonesService.getDeletedZones('100', 'start', 'someQuery', false)
.then(function(response) {
expect(response.data).toBe('deleted all zone returned');
});
this.$httpBackend.flush();
});
it('http backend gets called properly when deleting zone', function (done) {
this.$httpBackend.expectDELETE('/api/zones/id').respond('zone deleted');
this.zonesService.delZone('id')

View File

@ -240,6 +240,24 @@ trait TestApplicationData { this: Mockito =>
| }
""".stripMargin)
val hobbitDeletedZoneChange: JsValue = Json.parse(s"""{
| "zoneId": "$hobbitZoneId",
| "zoneChanges":
| [{ "zone": {
| "name": "$hobbitZoneName",
| "email": "hobbitAdmin@shire.me",
| "status": "Active",
| "account": "system",
| "acl": "rules",
| "adminGroupId": "$hobbitGroupId",
| "id": "$hobbitZoneId",
| "shared": false,
| "status": "Deleted"
| }}],
| "maxItems": 100
| }
""".stripMargin)
val hobbitZoneChange: JsValue = Json.parse(s"""{
| "zoneId": "$hobbitZoneId",
| "zoneChanges":

View File

@ -1621,6 +1621,74 @@ class VinylDNSSpec extends Specification with Mockito with TestApplicationData w
}
}
".getDeletedZones" should {
"return ok (200) if the DeletedZones is found" in new WithApplication(app) {
val client = MockWS {
case (GET, u) if u == s"http://localhost:9001/zones/deleted/changes" =>
defaultActionBuilder { Results.Ok(hobbitDeletedZoneChange) }
}
val mockUserAccessor = mock[UserAccountAccessor]
mockUserAccessor.get(anyString).returns(IO.pure(Some(frodoUser)))
mockUserAccessor.getUserByKey(anyString).returns(IO.pure(Some(frodoUser)))
val underTest = withClient(client)
val result =
underTest.getDeletedZones()(
FakeRequest(GET, s"/zones/deleted/changes")
.withSession("username" -> frodoUser.userName, "accessKey" -> frodoUser.accessKey)
)
status(result) must beEqualTo(OK)
hasCacheHeaders(result)
contentAsJson(result) must beEqualTo(hobbitDeletedZoneChange)
}
"return a not found (404) if the DeletedZones does not exist" in new WithApplication(app) {
val client = MockWS {
case (GET, u) if u == s"http://localhost:9001/zones/deleted/changes" =>
defaultActionBuilder { Results.NotFound }
}
val mockUserAccessor = mock[UserAccountAccessor]
mockUserAccessor.get(anyString).returns(IO.pure(Some(frodoUser)))
mockUserAccessor.getUserByKey(anyString).returns(IO.pure(Some(frodoUser)))
val underTest = withClient(client)
val result =
underTest.getDeletedZones()(
FakeRequest(GET, "zones/deleted/changes")
.withSession("username" -> frodoUser.userName, "accessKey" -> frodoUser.accessKey)
)
status(result) must beEqualTo(NOT_FOUND)
hasCacheHeaders(result)
}
"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.getDeletedZones()(FakeRequest(GET, s"/api/zones/deleted/changes"))
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.getDeletedZones()(
FakeRequest(GET, s"/api/zones/deleted/changes").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."
)
}
}
".getZone" should {
"return unauthorized (401) if requesting user is not logged in" in new WithApplication(app) {
val client = mock[WSClient]

View File

@ -30,7 +30,7 @@ import vinyldns.core.domain.{Fqdn, record}
import vinyldns.core.domain.record.{RecordSet, RecordType}
class Route53IntegrationSpec
extends AnyWordSpec
extends AnyWordSpec
with BeforeAndAfterAll
with BeforeAndAfterEach
with Matchers {
@ -52,6 +52,8 @@ class Route53IntegrationSpec
"test",
Option("access"),
Option("secret"),
None,
None,
sys.env.getOrElse("R53_SERVICE_ENDPOINT", "http://localhost:19003"),
"us-east-1"
)

View File

@ -16,13 +16,9 @@
package vinyldns.route53.backend
import java.util.UUID
import cats.data.OptionT
import cats.effect.IO
import com.amazonaws.auth.{
AWSStaticCredentialsProvider,
BasicAWSCredentials,
DefaultAWSCredentialsProviderChain
}
import com.amazonaws.client.builder.AwsClientBuilder.EndpointConfiguration
import com.amazonaws.handlers.AsyncHandler
import com.amazonaws.services.route53.{AmazonRoute53Async, AmazonRoute53AsyncClientBuilder}
@ -106,10 +102,17 @@ class Route53Backend(
* @return A list of record sets matching the name, empty if not found
*/
def resolve(name: String, zoneName: String, typ: RecordType): IO[List[RecordSet]] = {
val fqdn = Fqdn.merge(name, zoneName).fqdn
def filterResourceRecordSet(
rrs: java.util.List[ResourceRecordSet],
rrType: RRType
): java.util.List[ResourceRecordSet] =
rrs.asScala.filter { r =>
r.getName == fqdn && RRType.fromValue(r.getType) == rrType
}.asJava
for {
hostedZoneId <- lookupHostedZone(zoneName)
awsRRType <- OptionT.fromOption[IO](toRoute53RecordType(typ))
fqdn = Fqdn.merge(name, zoneName).fqdn
result <- OptionT.liftF {
r53(
new ListResourceRecordSetsRequest()
@ -119,7 +122,10 @@ class Route53Backend(
client.listResourceRecordSetsAsync
)
}
} yield toVinylRecordSets(result.getResourceRecordSets, zoneName: String)
} yield toVinylRecordSets(
filterResourceRecordSet(result.getResourceRecordSets, awsRRType),
zoneName: String
)
}.getOrElse(Nil)
/**
@ -278,21 +284,23 @@ object Route53Backend {
r53ClientBuilder.withEndpointConfiguration(
new EndpointConfiguration(config.serviceEndpoint, config.signingRegion)
)
// If either of accessKey or secretKey are empty in conf file; then use AWSCredentialsProviderChain to figure out
// credentials.
// https://docs.aws.amazon.com/AWSJavaSDK/latest/javadoc/com/amazonaws/auth/DefaultAWSCredentialsProviderChain.html
val credProvider = config.accessKey
.zip(config.secretKey)
.map {
case (key, secret) =>
new AWSStaticCredentialsProvider(
new BasicAWSCredentials(key, secret)
)
}
.headOption
.getOrElse {
new DefaultAWSCredentialsProviderChain()
val r53CredBuilder = Route53Credentials.builder
for {
accessKey <- config.accessKey
secretKey <- config.secretKey
} r53CredBuilder.basicCredentials(accessKey, secretKey)
for (role <- config.roleArn) {
config.externalId match {
case Some(externalId) =>
r53CredBuilder.withRole(role, UUID.randomUUID().toString, externalId)
case None => r53CredBuilder.withRole(role, UUID.randomUUID().toString)
}
}
val credProvider = r53CredBuilder.build().provider
r53ClientBuilder.withCredentials(credProvider).build()
}

View File

@ -0,0 +1,111 @@
/*
* Copyright 2018 Comcast Cable Communications Management, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package vinyldns.route53.backend
import com.amazonaws.auth._
import org.slf4j.LoggerFactory
import com.amazonaws.services.securitytoken.{
AWSSecurityTokenService,
AWSSecurityTokenServiceClientBuilder
}
private[backend] sealed trait Route53Credentials extends Serializable {
def provider: AWSCredentialsProvider
}
private[backend] final case object DefaultCredentials extends Route53Credentials {
def provider: AWSCredentialsProvider = new DefaultAWSCredentialsProviderChain
}
private[backend] final case class BasicCredentials(accessKeyId: String, secretKey: String)
extends Route53Credentials {
private final val logger = LoggerFactory.getLogger(classOf[Route53Backend])
def provider: AWSCredentialsProvider =
try {
new AWSStaticCredentialsProvider(new BasicAWSCredentials(accessKeyId, secretKey))
} catch {
case e: IllegalArgumentException =>
logger.error(
"Error when using accessKey/secret: {}. Using DefaultProviderChain.",
e.getMessage()
)
new DefaultAWSCredentialsProviderChain
}
}
private[backend] final case class STSCredentials(
roleArn: String,
sessionName: String,
externalId: Option[String] = None,
longLivedCreds: Route53Credentials = DefaultCredentials
) extends Route53Credentials {
def provider: AWSCredentialsProvider = {
lazy val stsClient: AWSSecurityTokenService =
AWSSecurityTokenServiceClientBuilder
.standard()
.withCredentials(longLivedCreds.provider)
.build()
val builder = new STSAssumeRoleSessionCredentialsProvider.Builder(roleArn, sessionName)
.withStsClient(stsClient)
externalId match {
case Some(externalId) =>
builder
.withExternalId(externalId)
.build()
case None =>
builder.build()
}
}
}
object Route53Credentials {
class Builder {
private var basicCreds: Option[BasicCredentials] = None
private var stsCreds: Option[STSCredentials] = None
def basicCredentials(accessKeyId: String, secretKey: String): Builder = {
basicCreds = Option(BasicCredentials(accessKeyId, secretKey))
this
}
def withRole(roleArn: String, sessionName: String): Builder = {
stsCreds = Option(STSCredentials(roleArn, sessionName))
this
}
def withRole(roleArn: String, sessionName: String, externalId: String): Builder = {
stsCreds = Option(
STSCredentials(
roleArn,
sessionName,
Option(externalId)
)
)
this
}
def build(): Route53Credentials =
stsCreds.map(_.copy(longLivedCreds = longLivedCreds)).getOrElse(longLivedCreds)
private def longLivedCreds: Route53Credentials = basicCreds.getOrElse(DefaultCredentials)
}
def builder: Builder = new Builder
}

View File

@ -27,6 +27,8 @@ final case class Route53BackendConfig(
id: String,
accessKey: Option[String],
secretKey: Option[String],
roleArn: Option[String],
externalId: Option[String],
serviceEndpoint: String,
signingRegion: String
)

View File

@ -94,6 +94,7 @@ object Dependencies {
lazy val r53Dependencies = Seq(
"com.amazonaws" % "aws-java-sdk-core" % awsV withSources(),
"com.amazonaws" % "aws-java-sdk-sts" % awsV withSources(),
"com.amazonaws" % "aws-java-sdk-route53" % awsV withSources()
)