diff --git a/modules/api/src/it/scala/vinyldns/api/domain/record/RecordSetServiceIntegrationSpec.scala b/modules/api/src/it/scala/vinyldns/api/domain/record/RecordSetServiceIntegrationSpec.scala index 95c1f8704..74bc18c07 100644 --- a/modules/api/src/it/scala/vinyldns/api/domain/record/RecordSetServiceIntegrationSpec.scala +++ b/modules/api/src/it/scala/vinyldns/api/domain/record/RecordSetServiceIntegrationSpec.scala @@ -19,6 +19,7 @@ package vinyldns.api.domain.record import cats.effect._ import cats.implicits._ import cats.scalatest.EitherMatchers +import org.mockito.Matchers.any import java.time.Instant import java.time.temporal.ChronoUnit import org.mockito.Mockito._ @@ -41,6 +42,9 @@ import vinyldns.core.domain.membership.{Group, GroupRepository, User, UserReposi import vinyldns.core.domain.record.RecordType._ import vinyldns.core.domain.record._ import vinyldns.core.domain.zone._ +import vinyldns.core.notifier.{AllNotifiers, Notification, Notifier} + +import scala.concurrent.ExecutionContext class RecordSetServiceIntegrationSpec extends AnyWordSpec @@ -53,11 +57,16 @@ class RecordSetServiceIntegrationSpec with BeforeAndAfterAll with TransactionProvider { + private implicit val contextShift: ContextShift[IO] = IO.contextShift(ExecutionContext.global) + private val vinyldnsConfig = VinylDNSConfig.load().unsafeRunSync() private val recordSetRepo = recordSetRepository private val recordSetCacheRepo = recordSetCacheRepository + private val mockNotifier = mock[Notifier] + private val mockNotifiers = AllNotifiers(List(mockNotifier)) + private val zoneRepo: ZoneRepository = zoneRepository private val groupRepo: GroupRepository = groupRepository @@ -232,6 +241,36 @@ class RecordSetServiceIntegrationSpec ownerGroupId = Some("non-existent") ) + private val sharedTestRecordPendingReviewOwnerShip = RecordSet( + sharedZone.id, + "shared-record-ownerShip-pendingReview", + A, + 200, + RecordSetStatus.Active, + Instant.now.truncatedTo(ChronoUnit.MILLIS), + None, + List(AData("1.1.1.1")), + ownerGroupId = Some(sharedGroup.id), + recordSetGroupChange = Some(OwnerShipTransfer( + ownerShipTransferStatus = OwnerShipTransferStatus.PendingReview, + requestedOwnerGroupId = Some(group.id))) + ) + + private val sharedTestRecordCancelledOwnerShip = RecordSet( + sharedZone.id, + "shared-record-ownerShip-cancelled", + A, + 200, + RecordSetStatus.Active, + Instant.now.truncatedTo(ChronoUnit.MILLIS), + None, + List(AData("1.1.1.1")), + ownerGroupId = Some(sharedGroup.id), + recordSetGroupChange = Some(OwnerShipTransfer( + ownerShipTransferStatus = OwnerShipTransferStatus.Cancelled, + requestedOwnerGroupId = Some(group.id))) + ) + private val testOwnerGroupRecordInNormalZone = RecordSet( zone.id, "user-in-owner-group-but-zone-not-shared", @@ -285,7 +324,10 @@ class RecordSetServiceIntegrationSpec // Seeding records in DB val sharedRecords = List( sharedTestRecord, - sharedTestRecordBadOwnerGroup + sharedTestRecordBadOwnerGroup, + sharedTestRecordPendingReviewOwnerShip, + sharedTestRecordCancelledOwnerShip + ) val conflictRecords = List( subTestRecordNameConflict, @@ -324,7 +366,8 @@ class RecordSetServiceIntegrationSpec vinyldnsConfig.highValueDomainConfig, vinyldnsConfig.dottedHostsConfig, vinyldnsConfig.serverConfig.approvedNameServers, - useRecordSetCache = true + useRecordSetCache = true, + mockNotifiers ) } @@ -425,6 +468,227 @@ class RecordSetServiceIntegrationSpec leftValue(result) shouldBe a[InvalidRequest] } + "auto-approve ownership transfer request, if user tried to update the ownership" in { + val newRecord = sharedTestRecord.copy(recordSetGroupChange = + Some(OwnerShipTransfer(ownerShipTransferStatus = OwnerShipTransferStatus.AutoApproved, + requestedOwnerGroupId = Some(group.id)))) + + val result = testRecordSetService + .updateRecordSet(newRecord, auth2) + .value + .unsafeRunSync() + + val change = rightValue(result).asInstanceOf[RecordSetChange] + change.recordSet.name shouldBe "shared-record" + change.recordSet.ownerGroupId.get shouldBe group.id + change.recordSet.recordSetGroupChange.get.ownerShipTransferStatus shouldBe OwnerShipTransferStatus.AutoApproved + change.recordSet.recordSetGroupChange.get.requestedOwnerGroupId.get shouldBe group.id + } + + "approve ownership transfer request, if user requested for ownership transfer" in { + val newRecord = sharedTestRecordPendingReviewOwnerShip.copy(recordSetGroupChange = + Some(OwnerShipTransfer( + ownerShipTransferStatus = OwnerShipTransferStatus.ManuallyApproved))) + + doReturn(IO.unit).when(mockNotifier).notify(any[Notification[_]]) + + val result = testRecordSetService + .updateRecordSet(newRecord, auth2) + .value + .unsafeRunSync() + + val change = rightValue(result).asInstanceOf[RecordSetChange] + change.recordSet.name shouldBe "shared-record-ownerShip-pendingReview" + change.recordSet.ownerGroupId.get shouldBe group.id + change.recordSet.recordSetGroupChange.get.ownerShipTransferStatus shouldBe OwnerShipTransferStatus.ManuallyApproved + change.recordSet.recordSetGroupChange.get.requestedOwnerGroupId.get shouldBe group.id + } + + "reject ownership transfer request, if user requested for ownership transfer" in { + val newRecord = sharedTestRecordPendingReviewOwnerShip.copy(recordSetGroupChange = + Some(OwnerShipTransfer( + ownerShipTransferStatus = OwnerShipTransferStatus.ManuallyRejected))) + + doReturn(IO.unit).when(mockNotifier).notify(any[Notification[_]]) + + val result = testRecordSetService + .updateRecordSet(newRecord, auth2) + .value + .unsafeRunSync() + + val change = rightValue(result).asInstanceOf[RecordSetChange] + change.recordSet.name shouldBe "shared-record-ownerShip-pendingReview" + change.recordSet.ownerGroupId.get shouldBe sharedGroup.id + change.recordSet.recordSetGroupChange.get.ownerShipTransferStatus shouldBe OwnerShipTransferStatus.ManuallyRejected + change.recordSet.recordSetGroupChange.get.requestedOwnerGroupId.get shouldBe group.id + } + + "request ownership transfer, if user not in the owner group and wants to own the record" in { + val newRecord = sharedTestRecord.copy(recordSetGroupChange = + Some(OwnerShipTransfer( + ownerShipTransferStatus = OwnerShipTransferStatus.Requested, + requestedOwnerGroupId = Some(dummyGroup.id)))) + + doReturn(IO.unit).when(mockNotifier).notify(any[Notification[_]]) + + val result = testRecordSetService + .updateRecordSet(newRecord, dummyAuth) + .value + .unsafeRunSync() + + val change = rightValue(result).asInstanceOf[RecordSetChange] + change.recordSet.name shouldBe "shared-record" + change.recordSet.ownerGroupId.get shouldBe sharedGroup.id + change.recordSet.recordSetGroupChange.get.ownerShipTransferStatus shouldBe OwnerShipTransferStatus.PendingReview + change.recordSet.recordSetGroupChange.get.requestedOwnerGroupId.get shouldBe dummyGroup.id + } + + "fail requesting ownership transfer if user is not in owner group and tried to update other fields in record set" in { + val newRecord = sharedTestRecord.copy( + ttl = 3000, + recordSetGroupChange = + Some(OwnerShipTransfer( + ownerShipTransferStatus = OwnerShipTransferStatus.Requested, + requestedOwnerGroupId = Some(dummyGroup.id)))) + + val result = testRecordSetService + .updateRecordSet(newRecord, dummyAuth) + .value + .unsafeRunSync() + + leftValue(result) shouldBe a[InvalidRequest] + } + + "fail updating if user is not in owner group for ownership transfer approval" in { + val newRecord = sharedTestRecordPendingReviewOwnerShip.copy(recordSetGroupChange = + Some(OwnerShipTransfer( + ownerShipTransferStatus = OwnerShipTransferStatus.ManuallyApproved))) + + val result = testRecordSetService + .updateRecordSet(newRecord, dummyAuth) + .value + .unsafeRunSync() + + leftValue(result) shouldBe a[NotAuthorizedError] + } + + "fail updating if user is not in owner group for ownership transfer reject" in { + val newRecord = sharedTestRecordPendingReviewOwnerShip.copy(recordSetGroupChange = + Some(OwnerShipTransfer( + ownerShipTransferStatus = OwnerShipTransferStatus.ManuallyRejected))) + + val result = testRecordSetService + .updateRecordSet(newRecord, dummyAuth) + .value + .unsafeRunSync() + + leftValue(result) shouldBe a[NotAuthorizedError] + } + + "cancel the ownership transfer request, if user not require ownership transfer further" in { + val newRecord = sharedTestRecordPendingReviewOwnerShip.copy(recordSetGroupChange = + Some(OwnerShipTransfer( + ownerShipTransferStatus = OwnerShipTransferStatus.Cancelled))) + + doReturn(IO.unit).when(mockNotifier).notify(any[Notification[_]]) + + val result = testRecordSetService + .updateRecordSet(newRecord, auth) + .value + .unsafeRunSync() + + val change = rightValue(result).asInstanceOf[RecordSetChange] + change.recordSet.name shouldBe "shared-record-ownerShip-pendingReview" + change.recordSet.ownerGroupId.get shouldBe sharedGroup.id + change.recordSet.recordSetGroupChange.get.ownerShipTransferStatus shouldBe OwnerShipTransferStatus.Cancelled + change.recordSet.recordSetGroupChange.get.requestedOwnerGroupId.get shouldBe group.id + } + + "fail approving ownership transfer request, if user is cancelled" in { + val newRecord = sharedTestRecordCancelledOwnerShip.copy(recordSetGroupChange = + Some(OwnerShipTransfer( + ownerShipTransferStatus = OwnerShipTransferStatus.ManuallyApproved))) + val result = testRecordSetService + .updateRecordSet(newRecord, auth) + .value + .unsafeRunSync() + + leftValue(result) shouldBe a[InvalidRequest] + } + + "fail rejecting ownership transfer request, if user is cancelled" in { + val newRecord = sharedTestRecordCancelledOwnerShip.copy(recordSetGroupChange = + Some(OwnerShipTransfer( + ownerShipTransferStatus = OwnerShipTransferStatus.ManuallyRejected))) + + val result = testRecordSetService + .updateRecordSet(newRecord, auth) + .value + .unsafeRunSync() + + leftValue(result) shouldBe a[InvalidRequest] + } + + "fail auto-approving ownership transfer request, if user is cancelled" in { + val newRecord = sharedTestRecordCancelledOwnerShip.copy(recordSetGroupChange = + Some(OwnerShipTransfer( + ownerShipTransferStatus = OwnerShipTransferStatus.AutoApproved + ))) + + doReturn(IO.unit).when(mockNotifier).notify(any[Notification[_]]) + + val result = testRecordSetService + .updateRecordSet(newRecord, auth) + .value + .unsafeRunSync() + + leftValue(result) shouldBe a[InvalidRequest] + } + + "fail auto-approving ownership transfer request, if zone is not shared" in { + val newRecord = dottedTestRecord.copy(recordSetGroupChange = + Some(OwnerShipTransfer(ownerShipTransferStatus = OwnerShipTransferStatus.AutoApproved, + requestedOwnerGroupId = Some(group.id)))) + + val result = testRecordSetService + .updateRecordSet(newRecord, auth2) + .value + .unsafeRunSync() + + leftValue(result) shouldBe a[InvalidRequest] + } + + "fail approving ownership transfer request, if zone is not shared" in { + val newRecord = dottedTestRecord.copy(recordSetGroupChange = + Some(OwnerShipTransfer( + ownerShipTransferStatus = OwnerShipTransferStatus.ManuallyApproved + ))) + + val result = testRecordSetService + .updateRecordSet(newRecord, auth2) + .value + .unsafeRunSync() + + leftValue(result) shouldBe a[InvalidRequest] + } + + "fail requesting ownership transfer, if zone is not shared" in { + val newRecord = dottedTestRecord.copy(recordSetGroupChange = + Some(OwnerShipTransfer( + ownerShipTransferStatus = OwnerShipTransferStatus.Requested, + requestedOwnerGroupId = Some(dummyGroup.id) + ))) + + doReturn(IO.unit).when(mockNotifier).notify(any[Notification[_]]) + + val result = testRecordSetService + .updateRecordSet(newRecord, dummyAuth) + .value + .unsafeRunSync() + + leftValue(result) shouldBe a[InvalidRequest] + } + "update dotted record succeeds if it satisfies all dotted hosts config" in { val newRecord = dottedTestRecord.copy(ttl = 37000) diff --git a/modules/api/src/it/scala/vinyldns/api/notifier/email/EmailNotifierIntegrationSpec.scala b/modules/api/src/it/scala/vinyldns/api/notifier/email/EmailNotifierIntegrationSpec.scala index f28168966..b1bb4116c 100644 --- a/modules/api/src/it/scala/vinyldns/api/notifier/email/EmailNotifierIntegrationSpec.scala +++ b/modules/api/src/it/scala/vinyldns/api/notifier/email/EmailNotifierIntegrationSpec.scala @@ -24,7 +24,7 @@ import org.scalatest.matchers.should.Matchers import org.scalatest.wordspec.AnyWordSpecLike import vinyldns.core.domain.batch._ import vinyldns.core.domain.record.RecordType -import vinyldns.core.domain.record.AData +import vinyldns.core.domain.record.{AData, OwnerShipTransferStatus, RecordSetChange, RecordSetChangeStatus, RecordSetChangeType, RecordType} import java.time.Instant import java.time.temporal.ChronoUnit import vinyldns.core.TestMembershipData._ @@ -35,6 +35,8 @@ import cats.effect.{IO, Resource} import scala.collection.JavaConverters._ import org.scalatest.BeforeAndAfterEach import cats.implicits._ +import vinyldns.core.TestRecordSetData.{ownerShipTransfer, rsOk} +import vinyldns.core.TestZoneData.okZone class EmailNotifierIntegrationSpec extends MySqlApiIntegrationSpec @@ -57,7 +59,7 @@ class EmailNotifierIntegrationSpec "Email Notifier" should { - "send an email" taggedAs (SkipCI) in { + "send an email for batch change" taggedAs (SkipCI) in { val batchChange = BatchChange( okUser.id, okUser.userName, @@ -84,7 +86,7 @@ class EmailNotifierIntegrationSpec val program = for { _ <- userRepository.save(okUser) notifier <- new EmailNotifierProvider() - .load(NotifierConfig("", emailConfig), userRepository) + .load(NotifierConfig("", emailConfig), userRepository, groupRepository) _ <- notifier.notify(Notification(batchChange)) emailFiles <- retrieveEmailFiles(targetDirectory) } yield emailFiles @@ -94,7 +96,29 @@ class EmailNotifierIntegrationSpec files.length should be(1) } + "send an email for recordSetChange ownerShip transfer" taggedAs (SkipCI) in { + val recordSetChange = RecordSetChange( + okZone, + rsOk.copy(ownerGroupId= Some(okGroup.id),recordSetGroupChange = + Some(ownerShipTransfer.copy(ownerShipTransferStatus = OwnerShipTransferStatus.PendingReview, requestedOwnerGroupId = Some(dummyGroup.id)))), + "system", + RecordSetChangeType.Create, + RecordSetChangeStatus.Complete + ) + val program = for { + _ <- userRepository.save(okUser) + notifier <- new EmailNotifierProvider() + .load(NotifierConfig("", emailConfig), userRepository, groupRepository) + _ <- notifier.notify(Notification(recordSetChange)) + emailFiles <- retrieveEmailFiles(targetDirectory) + } yield emailFiles + + val files = program.unsafeRunSync() + + files.length should be(1) + + } } def deleteEmailFiles(path: Path): IO[Unit] = diff --git a/modules/api/src/it/scala/vinyldns/api/notifier/sns/SnsNotifierIntegrationSpec.scala b/modules/api/src/it/scala/vinyldns/api/notifier/sns/SnsNotifierIntegrationSpec.scala index a10a558a2..5fb7b41e0 100644 --- a/modules/api/src/it/scala/vinyldns/api/notifier/sns/SnsNotifierIntegrationSpec.scala +++ b/modules/api/src/it/scala/vinyldns/api/notifier/sns/SnsNotifierIntegrationSpec.scala @@ -111,7 +111,7 @@ class SnsNotifierIntegrationSpec sns.subscribe(topic, "sqs", queueUrl) } notifier <- new SnsNotifierProvider() - .load(NotifierConfig("", snsConfig), userRepository) + .load(NotifierConfig("", snsConfig), userRepository, groupRepository) _ <- notifier.notify(Notification(batchChange)) _ <- IO.sleep(1.seconds) messages <- IO { diff --git a/modules/api/src/main/scala/vinyldns/api/Boot.scala b/modules/api/src/main/scala/vinyldns/api/Boot.scala index 6cd59d894..7319c1289 100644 --- a/modules/api/src/main/scala/vinyldns/api/Boot.scala +++ b/modules/api/src/main/scala/vinyldns/api/Boot.scala @@ -96,7 +96,8 @@ object Boot extends App { msgsPerPoll <- IO.fromEither(MessageCount(vinyldnsConfig.messageQueueConfig.messagesPerPoll)) notifiers <- NotifierLoader.loadAll( vinyldnsConfig.notifierConfigs, - repositories.userRepository + repositories.userRepository, + repositories.groupRepository ) _ <- APIMetrics.initialize(vinyldnsConfig.apiMetricSettings) // Schedule the zone sync task to be executed every 5 seconds @@ -161,7 +162,8 @@ object Boot extends App { vinyldnsConfig.highValueDomainConfig, vinyldnsConfig.dottedHostsConfig, vinyldnsConfig.serverConfig.approvedNameServers, - vinyldnsConfig.serverConfig.useRecordSetCache + vinyldnsConfig.serverConfig.useRecordSetCache, + notifiers ) val zoneService = ZoneService( repositories, diff --git a/modules/api/src/main/scala/vinyldns/api/backend/dns/DnsConversions.scala b/modules/api/src/main/scala/vinyldns/api/backend/dns/DnsConversions.scala index b89d00f04..3c9d3949e 100644 --- a/modules/api/src/main/scala/vinyldns/api/backend/dns/DnsConversions.scala +++ b/modules/api/src/main/scala/vinyldns/api/backend/dns/DnsConversions.scala @@ -205,7 +205,8 @@ trait DnsConversions { ttl = r.getTTL, status = RecordSetStatus.Active, created = Instant.now.truncatedTo(ChronoUnit.MILLIS), - records = f(r) + records = f(r), + recordSetGroupChange = Some(OwnerShipTransfer(ownerShipTransferStatus = OwnerShipTransferStatus.None)) ) // if we do not know the record type, then we cannot parse the records, but we should be able to get everything else @@ -217,7 +218,8 @@ trait DnsConversions { ttl = r.getTTL, status = RecordSetStatus.Active, created = Instant.now.truncatedTo(ChronoUnit.MILLIS), - records = Nil + records = Nil, + recordSetGroupChange = Some(OwnerShipTransfer(ownerShipTransferStatus = OwnerShipTransferStatus.None)) ) def fromARecord(r: DNS.ARecord, zoneName: DNS.Name, zoneId: String): RecordSet = diff --git a/modules/api/src/main/scala/vinyldns/api/domain/batch/BatchChangeConverter.scala b/modules/api/src/main/scala/vinyldns/api/domain/batch/BatchChangeConverter.scala index 5ece82e5a..eb3c9dc06 100644 --- a/modules/api/src/main/scala/vinyldns/api/domain/batch/BatchChangeConverter.scala +++ b/modules/api/src/main/scala/vinyldns/api/domain/batch/BatchChangeConverter.scala @@ -240,7 +240,8 @@ class BatchChangeConverter(batchChangeRepo: BatchChangeRepository, messageQueue: Instant.now.truncatedTo(ChronoUnit.MILLIS), None, proposedRecordData.toList, - ownerGroupId = setOwnerGroupId + ownerGroupId = setOwnerGroupId, + recordSetGroupChange = Some(OwnerShipTransfer(ownerShipTransferStatus = OwnerShipTransferStatus.None)) ) } } diff --git a/modules/api/src/main/scala/vinyldns/api/domain/record/RecordSetService.scala b/modules/api/src/main/scala/vinyldns/api/domain/record/RecordSetService.scala index 537ff2f23..d61fac1e0 100644 --- a/modules/api/src/main/scala/vinyldns/api/domain/record/RecordSetService.scala +++ b/modules/api/src/main/scala/vinyldns/api/domain/record/RecordSetService.scala @@ -36,6 +36,7 @@ import vinyldns.core.domain.record.RecordType.RecordType import vinyldns.core.domain.DomainHelpers.ensureTrailingDot import vinyldns.core.domain.backend.{Backend, BackendResolver} import vinyldns.core.domain.record.RecordTypeSort.RecordTypeSort +import vinyldns.core.notifier.{AllNotifiers, Notification} import scala.util.matching.Regex @@ -49,7 +50,8 @@ object RecordSetService { highValueDomainConfig: HighValueDomainConfig, dottedHostsConfig: DottedHostsConfig, approvedNameServers: List[Regex], - useRecordSetCache: Boolean + useRecordSetCache: Boolean, + notifiers: AllNotifiers ): RecordSetService = new RecordSetService( dataAccessor.zoneRepository, @@ -65,7 +67,9 @@ object RecordSetService { highValueDomainConfig, dottedHostsConfig, approvedNameServers, - useRecordSetCache + useRecordSetCache, + notifiers + ) } @@ -83,12 +87,16 @@ class RecordSetService( highValueDomainConfig: HighValueDomainConfig, dottedHostsConfig: DottedHostsConfig, approvedNameServers: List[Regex], - useRecordSetCache: Boolean + useRecordSetCache: Boolean, + notifiers: AllNotifiers ) extends RecordSetServiceAlgebra { import RecordSetValidations._ import accessValidation._ + val approverOwnerShipTransferStatus = List(OwnerShipTransferStatus.ManuallyApproved , OwnerShipTransferStatus.AutoApproved, OwnerShipTransferStatus.ManuallyRejected) + val requestorOwnerShipTransferStatus = List(OwnerShipTransferStatus.Cancelled , OwnerShipTransferStatus.Requested, OwnerShipTransferStatus.PendingReview) + def addRecordSet(recordSet: RecordSet, auth: AuthPrincipal): Result[ZoneCommandResult] = for { zone <- getZone(recordSet.zoneId) @@ -143,14 +151,28 @@ class RecordSetService( _ <- unchangedRecordName(existing, recordSet, zone).toResult _ <- unchangedRecordType(existing, recordSet).toResult _ <- unchangedZoneId(existing, recordSet).toResult + _ <- if(requestorOwnerShipTransferStatus.contains(recordSet.recordSetGroupChange.map(_.ownerShipTransferStatus).getOrElse("")) + && !auth.isSuper && !auth.isGroupMember(existing.ownerGroupId.getOrElse("None"))) + unchangedRecordSet(existing, recordSet).toResult else ().toResult + _ <- if(existing.recordSetGroupChange.map(_.ownerShipTransferStatus).getOrElse("") == OwnerShipTransferStatus.Cancelled + && !auth.isSuper) + recordSetOwnerShipApproveStatus(recordSet).toResult else ().toResult + recordSet <- updateRecordSetGroupChangeStatus(recordSet, existing, zone) change <- RecordSetChangeGenerator.forUpdate(existing, recordSet, zone, Some(auth)).toResult // because changes happen to the RS in forUpdate itself, converting 1st and validating on that rsForValidations = change.recordSet superUserCanUpdateOwnerGroup = canSuperUserUpdateOwnerGroup(existing, recordSet, zone, auth) _ <- isNotHighValueDomain(recordSet, zone, highValueDomainConfig).toResult - _ <- canUpdateRecordSet(auth, existing.name, existing.typ, zone, existing.ownerGroupId, superUserCanUpdateOwnerGroup).toResult + _ <- if(requestorOwnerShipTransferStatus.contains(recordSet.recordSetGroupChange.map(_.ownerShipTransferStatus).getOrElse("")) + && !auth.isSuper && !auth.isGroupMember(existing.ownerGroupId.getOrElse("None"))) ().toResult + else canUpdateRecordSet(auth, existing.name, existing.typ, zone, existing.ownerGroupId, superUserCanUpdateOwnerGroup).toResult ownerGroup <- getGroupIfProvided(rsForValidations.ownerGroupId) - _ <- canUseOwnerGroup(rsForValidations.ownerGroupId, ownerGroup, auth).toResult + _ <- if(requestorOwnerShipTransferStatus.contains(recordSet.recordSetGroupChange.map(_.ownerShipTransferStatus).getOrElse("")) + && !auth.isSuper && !auth.isGroupMember(existing.ownerGroupId.getOrElse("None"))) + canUseOwnerGroup(rsForValidations.recordSetGroupChange.map(_.requestedOwnerGroupId).get, ownerGroup, auth).toResult + else if(approverOwnerShipTransferStatus.contains(recordSet.recordSetGroupChange.map(_.ownerShipTransferStatus).getOrElse("")) + && !auth.isSuper) canUseOwnerGroup(existing.ownerGroupId, ownerGroup, auth).toResult + else canUseOwnerGroup(rsForValidations.ownerGroupId, ownerGroup, auth).toResult _ <- notPending(existing).toResult existingRecordsWithName <- recordSetRepository .getRecordSetsByName(zone.id, rsForValidations.name) @@ -185,6 +207,11 @@ class RecordSetService( _ <- if(existing.name == rsForValidations.name) ().toResult else if(allowedZoneList.contains(zone.name)) checkAllowedDots(allowedDotsLimit, rsForValidations, zone).toResult else ().toResult _ <- if(allowedZoneList.contains(zone.name)) isNotApexEndsWithDot(rsForValidations, zone).toResult else ().toResult _ <- messageQueue.send(change).toResult[Unit] + _ <- if(recordSet.recordSetGroupChange != None && + recordSet.recordSetGroupChange.map(_.ownerShipTransferStatus).getOrElse("") != OwnerShipTransferStatus.None && + recordSet.recordSetGroupChange.map(_.ownerShipTransferStatus).getOrElse("") != OwnerShipTransferStatus.AutoApproved) + notifiers.notify(Notification(change)).toResult + else ().toResult } yield change def deleteRecordSet( @@ -203,6 +230,65 @@ class RecordSetService( _ <- messageQueue.send(change).toResult[Unit] } yield change + //update ownership transfer is zone is shared + def updateRecordSetGroupChangeStatus(recordSet: RecordSet, existing: RecordSet, zone: Zone): Result[RecordSet] = { + val existingOwnerShipTransfer = existing.recordSetGroupChange.getOrElse(OwnerShipTransfer.apply(OwnerShipTransferStatus.None, Some("none"))) + val ownerShipTransfer = recordSet.recordSetGroupChange.getOrElse(OwnerShipTransfer.apply(OwnerShipTransferStatus.None, Some("none"))) + if (recordSet.recordSetGroupChange != None && + ownerShipTransfer.ownerShipTransferStatus != OwnerShipTransferStatus.None) + if (zone.shared){ + if (approverOwnerShipTransferStatus.contains(ownerShipTransfer.ownerShipTransferStatus)) { + val recordSetOwnerApproval = + ownerShipTransfer.ownerShipTransferStatus match { + case OwnerShipTransferStatus.ManuallyApproved => + recordSet.copy(ownerGroupId = existingOwnerShipTransfer.requestedOwnerGroupId, + recordSetGroupChange = Some(ownerShipTransfer.copy(ownerShipTransferStatus = OwnerShipTransferStatus.ManuallyApproved, + requestedOwnerGroupId = existingOwnerShipTransfer.requestedOwnerGroupId))) + case OwnerShipTransferStatus.ManuallyRejected => + recordSet.copy( + recordSetGroupChange = Some(ownerShipTransfer.copy(ownerShipTransferStatus = OwnerShipTransferStatus.ManuallyRejected, + requestedOwnerGroupId = existingOwnerShipTransfer.requestedOwnerGroupId))) + case OwnerShipTransferStatus.AutoApproved => + recordSet.copy( + ownerGroupId = ownerShipTransfer.requestedOwnerGroupId, + recordSetGroupChange = Some(ownerShipTransfer.copy(ownerShipTransferStatus = OwnerShipTransferStatus.AutoApproved, + requestedOwnerGroupId = ownerShipTransfer.requestedOwnerGroupId))) + + case _ => recordSet.copy( + recordSetGroupChange = Some(ownerShipTransfer.copy( + ownerShipTransferStatus = OwnerShipTransferStatus.None, + requestedOwnerGroupId = Some("null")))) + } + for { + recordSet <- recordSetOwnerApproval.toResult + } yield recordSet + } + else { + val recordSetOwnerRequest = + ownerShipTransfer.ownerShipTransferStatus match { + case OwnerShipTransferStatus.Cancelled => + recordSet.copy(recordSetGroupChange = Some(ownerShipTransfer.copy( + ownerShipTransferStatus = OwnerShipTransferStatus.Cancelled, + requestedOwnerGroupId = existingOwnerShipTransfer.requestedOwnerGroupId))) + case OwnerShipTransferStatus.Requested | OwnerShipTransferStatus.PendingReview => recordSet.copy( + recordSetGroupChange = Some(ownerShipTransfer.copy(ownerShipTransferStatus = OwnerShipTransferStatus.PendingReview))) + } + for { + recordSet <- recordSetOwnerRequest.toResult + } yield recordSet + } + } else for { + _ <- unchangedRecordSetOwnershipStatus(recordSet, existing).toResult + } yield recordSet.copy( + recordSetGroupChange = Some(ownerShipTransfer.copy( + ownerShipTransferStatus = OwnerShipTransferStatus.None, + requestedOwnerGroupId = Some("null")))) + else recordSet.copy( + recordSetGroupChange = Some(ownerShipTransfer.copy( + ownerShipTransferStatus = OwnerShipTransferStatus.None, + requestedOwnerGroupId = Some("null")))).toResult + } + // For dotted hosts. Check if a record that may conflict with dotted host exist or not def recordFQDNDoesNotExist(newRecordSet: RecordSet, zone: Zone): IO[Boolean] = { // Use fqdn for searching through `recordset` mysql table to see if it already exist diff --git a/modules/api/src/main/scala/vinyldns/api/domain/record/RecordSetValidations.scala b/modules/api/src/main/scala/vinyldns/api/domain/record/RecordSetValidations.scala index 44b7377b9..81b7c484e 100644 --- a/modules/api/src/main/scala/vinyldns/api/domain/record/RecordSetValidations.scala +++ b/modules/api/src/main/scala/vinyldns/api/domain/record/RecordSetValidations.scala @@ -27,7 +27,7 @@ import vinyldns.core.domain.record.RecordType._ import vinyldns.api.domain.zone._ import vinyldns.core.domain.auth.AuthPrincipal import vinyldns.core.domain.membership.Group -import vinyldns.core.domain.record.{RecordSet, RecordType} +import vinyldns.core.domain.record.{OwnerShipTransferStatus, RecordSet, RecordType} import vinyldns.core.domain.zone.Zone import vinyldns.core.Messages._ @@ -462,4 +462,41 @@ object RecordSetValidations { val wildcardRegex = raw"^\s*[*%].*[*%]\s*$$".r searchRegex.findFirstIn(recordNameFilter).isDefined && wildcardRegex.findFirstIn(recordNameFilter).isEmpty } + + def unchangedRecordSet( + existing: RecordSet, + updates: RecordSet + ): Either[Throwable, Unit] = + Either.cond( + updates.typ == existing.typ && + updates.records == existing.records && + updates.id == existing.id && + updates.zoneId == existing.zoneId && + updates.name == existing.name && + updates.ownerGroupId == existing.ownerGroupId && + updates.ttl == existing.ttl, + (), + InvalidRequest("Cannot update RecordSet's if user not a member of ownership group. User can only request for ownership transfer") + ) + + def recordSetOwnerShipApproveStatus( + updates: RecordSet, + ): Either[Throwable, Unit] = + Either.cond( + updates.recordSetGroupChange.map(_.ownerShipTransferStatus).getOrElse("") != OwnerShipTransferStatus.ManuallyApproved && + updates.recordSetGroupChange.map(_.ownerShipTransferStatus).getOrElse("") != OwnerShipTransferStatus.AutoApproved && + updates.recordSetGroupChange.map(_.ownerShipTransferStatus).getOrElse("") != OwnerShipTransferStatus.ManuallyRejected, + (), + InvalidRequest("Cannot update RecordSet OwnerShip Status when request is cancelled.") + ) + + def unchangedRecordSetOwnershipStatus( + updates: RecordSet, + existing: RecordSet + ): Either[Throwable, Unit] = + Either.cond( + updates.recordSetGroupChange == existing.recordSetGroupChange, + (), + InvalidRequest("Cannot update RecordSet OwnerShip Status when zone is not shared.") + ) } diff --git a/modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneProtocol.scala b/modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneProtocol.scala index 2d1ce0dd2..94b663cf2 100644 --- a/modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneProtocol.scala +++ b/modules/api/src/main/scala/vinyldns/api/domain/zone/ZoneProtocol.scala @@ -21,7 +21,7 @@ import vinyldns.core.domain.record.RecordSetChangeStatus.RecordSetChangeStatus 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.record.{RecordData, RecordSet, RecordSetChange, OwnerShipTransfer} 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 @@ -180,6 +180,7 @@ case class RecordSetListInfo( accessLevel: AccessLevel, ownerGroupId: Option[String], ownerGroupName: Option[String], + recordSetGroupChange: Option[OwnerShipTransfer], fqdn: Option[String] ) @@ -199,6 +200,7 @@ object RecordSetListInfo { accessLevel = accessLevel, ownerGroupId = recordSet.ownerGroupId, ownerGroupName = recordSet.ownerGroupName, + recordSetGroupChange = recordSet.recordSetGroupChange, fqdn = recordSet.fqdn ) } @@ -216,6 +218,7 @@ case class RecordSetInfo( account: String, ownerGroupId: Option[String], ownerGroupName: Option[String], + recordSetGroupChange: Option[OwnerShipTransfer], fqdn: Option[String] ) @@ -234,6 +237,7 @@ object RecordSetInfo { account = recordSet.account, ownerGroupId = recordSet.ownerGroupId, ownerGroupName = groupName, + recordSetGroupChange = recordSet.recordSetGroupChange, fqdn = recordSet.fqdn ) } @@ -251,6 +255,7 @@ case class RecordSetGlobalInfo( account: String, ownerGroupId: Option[String], ownerGroupName: Option[String], + recordSetGroupChange: Option[OwnerShipTransfer], fqdn: Option[String], zoneName: String, zoneShared: Boolean @@ -276,6 +281,7 @@ object RecordSetGlobalInfo { account = recordSet.account, ownerGroupId = recordSet.ownerGroupId, ownerGroupName = groupName, + recordSetGroupChange = recordSet.recordSetGroupChange, fqdn = recordSet.fqdn, zoneName = zoneName, zoneShared = zoneShared diff --git a/modules/api/src/main/scala/vinyldns/api/notifier/email/EmailNotifier.scala b/modules/api/src/main/scala/vinyldns/api/notifier/email/EmailNotifier.scala index dd0318a75..3abcd5ca8 100644 --- a/modules/api/src/main/scala/vinyldns/api/notifier/email/EmailNotifier.scala +++ b/modules/api/src/main/scala/vinyldns/api/notifier/email/EmailNotifier.scala @@ -18,33 +18,25 @@ package vinyldns.api.notifier.email import vinyldns.core.notifier.{Notification, Notifier} import cats.effect.IO -import vinyldns.core.domain.batch.{ - BatchChange, - BatchChangeApprovalStatus, - SingleAddChange, - SingleChange, - SingleDeleteRRSetChange -} -import vinyldns.core.domain.membership.UserRepository -import vinyldns.core.domain.membership.User +import cats.implicits._ +import cats.effect.IO +import vinyldns.core.domain.batch.{BatchChange, BatchChangeApprovalStatus, SingleAddChange, SingleChange, SingleDeleteRRSetChange} +import vinyldns.core.domain.membership.{GroupRepository, User, UserRepository} import org.slf4j.LoggerFactory + import javax.mail.internet.{InternetAddress, MimeMessage} import javax.mail.{Address, Message, Session} - import scala.util.Try -import vinyldns.core.domain.record.AData -import vinyldns.core.domain.record.AAAAData -import vinyldns.core.domain.record.CNAMEData -import vinyldns.core.domain.record.MXData -import vinyldns.core.domain.record.TXTData -import vinyldns.core.domain.record.PTRData -import vinyldns.core.domain.record.RecordData +import vinyldns.core.domain.record.{AAAAData, AData, CNAMEData, MXData, OwnerShipTransferStatus, PTRData, RecordData, RecordSetChange, TXTData} +import vinyldns.core.domain.record.OwnerShipTransferStatus.OwnerShipTransferStatus + import java.time.format.{DateTimeFormatter, FormatStyle} import vinyldns.core.domain.batch.BatchChangeStatus._ import vinyldns.core.domain.batch.BatchChangeApprovalStatus._ + import java.time.ZoneId -class EmailNotifier(config: EmailNotifierConfig, session: Session, userRepository: UserRepository) +class EmailNotifier(config: EmailNotifierConfig, session: Session, userRepository: UserRepository, groupRepository: GroupRepository) extends Notifier { private val logger = LoggerFactory.getLogger(classOf[EmailNotifier]) @@ -52,12 +44,15 @@ class EmailNotifier(config: EmailNotifierConfig, session: Session, userRepositor def notify(notification: Notification[_]): IO[Unit] = notification.change match { case bc: BatchChange => sendBatchChangeNotification(bc) + case rsc: RecordSetChange => sendRecordSetOwnerTransferNotification(rsc) case _ => IO.unit } - def send(addresses: Address*)(buildMessage: Message => Message): IO[Unit] = IO { + + def send(toAddresses: Address*)(ccAddresses: Address*)(buildMessage: Message => Message): IO[Unit] = IO { val message = new MimeMessage(session) - message.setRecipients(Message.RecipientType.TO, addresses.toArray) + message.setRecipients(Message.RecipientType.TO, toAddresses.toArray) + message.setRecipients(Message.RecipientType.CC, ccAddresses.toArray) message.setFrom(config.from) buildMessage(message) message.saveChanges() @@ -67,10 +62,10 @@ class EmailNotifier(config: EmailNotifierConfig, session: Session, userRepositor transport.close() } - def sendBatchChangeNotification(bc: BatchChange): IO[Unit] = + def sendBatchChangeNotification(bc: BatchChange): IO[Unit] = { userRepository.getUser(bc.userId).flatMap { - case Some(UserWithEmail(email)) => - send(email) { message => + case Some(UserWithEmail(email)) => + send(email)() { message => message.setSubject(s"VinylDNS Batch change ${bc.id} results") message.setContent(formatBatchChange(bc), "text/html") message @@ -81,9 +76,58 @@ class EmailNotifier(config: EmailNotifierConfig, session: Session, userRepositor s"Unable to properly parse email for ${user.id}: ${user.email.getOrElse("")}" ) } - case None => IO { logger.warn(s"Unable to find user: ${bc.userId}") } + case None => IO { + logger.warn(s"Unable to find user: ${bc.userId}") + } case _ => IO.unit } + } + + def sendRecordSetOwnerTransferNotification(rsc: RecordSetChange): IO[Unit] = { + for { + toGroup <- groupRepository.getGroup(rsc.recordSet.ownerGroupId.getOrElse("")) + ccGroup <- groupRepository.getGroup(rsc.recordSet.recordSetGroupChange.map(_.requestedOwnerGroupId.getOrElse("")).getOrElse("")) + _ <- toGroup match { + case Some(group) => + group.memberIds.toList.traverse { id => + userRepository.getUser(id).flatMap { + case Some(UserWithEmail(toEmail)) => + ccGroup match { + case Some(ccg) => + ccg.memberIds.toList.traverse { id => + userRepository.getUser(id).flatMap { + case Some(ccUser) => + val ccEmail = ccUser.email.getOrElse("") + send(toEmail)(new InternetAddress(ccEmail)) { message => + message.setSubject(s"VinylDNS RecordSet change ${rsc.id} results") + message.setContent(formatRecordSetChange(rsc), "text/html") + message + } + case None => + IO.unit + } + } + case None => IO.unit + } + case Some(user: User) if user.email.isDefined => + IO { + logger.warn( + s"Unable to properly parse email for ${user.id}: ${user.email.getOrElse("")}" + ) + } + case None => + IO { + logger.warn(s"Unable to find user: ${rsc.userId}") + } + case _ => + IO.unit + } + } + case None => IO.unit // Handle case where toGroup is None + } + } yield () + } + def formatBatchChange(bc: BatchChange): String = { val sb = new StringBuilder @@ -93,7 +137,7 @@ class EmailNotifier(config: EmailNotifierConfig, session: Session, userRepositor | ${bc.comments.map(comments => s"Description: $comments
").getOrElse("")} | Created: ${DateTimeFormatter.ofLocalizedDateTime(FormatStyle.FULL).withZone(ZoneId.systemDefault()).format(bc.createdTimestamp)}
| Id: ${bc.id}
- | Status: ${formatStatus(bc.approvalStatus, bc.status)}
""".stripMargin) + | Status: ${formatBatchStatus(bc.approvalStatus, bc.status)}
""".stripMargin) // For manually reviewed e-mails, add additional info; e-mails are not sent for pending batch changes if (bc.approvalStatus != AutoApproved) { @@ -125,7 +169,8 @@ class EmailNotifier(config: EmailNotifierConfig, session: Session, userRepositor sb.toString } - def formatStatus(approval: BatchChangeApprovalStatus, status: BatchChangeStatus): String = + + def formatBatchStatus(approval: BatchChangeApprovalStatus, status: BatchChangeStatus): String = (approval, status) match { case (ManuallyRejected, _) => "Rejected" case (BatchChangeApprovalStatus.PendingReview, _) => "Pending Review" @@ -133,6 +178,28 @@ class EmailNotifier(config: EmailNotifierConfig, session: Session, userRepositor case (_, status) => status.toString } + def formatRecordSetChange(rsc: RecordSetChange): String = { + + val sb = new StringBuilder + sb.append(s"""

RecordSet Ownership Transfer

+ | Submitter: ${ userRepository.getUser(rsc.userId).map(_.get.userName)} + | Id: ${rsc.id}
+ | Submitted time: ${DateTimeFormatter.ofLocalizedDateTime(FormatStyle.FULL).withZone(ZoneId.systemDefault()).format(rsc.created)}
+ | OwnerShip Current Group: ${rsc.recordSet.ownerGroupId.getOrElse("none")}
+ | OwnerShip Transfer Group: ${rsc.recordSet.recordSetGroupChange.map(_.requestedOwnerGroupId.getOrElse("none")).getOrElse("none")}
+ | OwnerShip Transfer Status: ${formatOwnerShipStatus(rsc.recordSet.recordSetGroupChange.map(_.ownerShipTransferStatus).get)}
+ """.stripMargin) + sb.toString + } + + def formatOwnerShipStatus(status: OwnerShipTransferStatus): String = + status match { + case OwnerShipTransferStatus.ManuallyRejected => "Rejected" + case OwnerShipTransferStatus.PendingReview => "Pending Review" + case OwnerShipTransferStatus.ManuallyApproved => "Approved" + case OwnerShipTransferStatus.Cancelled => "Cancelled" + } + def formatSingleChange(sc: SingleChange, index: Int): String = sc match { case SingleAddChange( _, diff --git a/modules/api/src/main/scala/vinyldns/api/notifier/email/EmailNotifierProvider.scala b/modules/api/src/main/scala/vinyldns/api/notifier/email/EmailNotifierProvider.scala index e10768e17..bf82bce92 100644 --- a/modules/api/src/main/scala/vinyldns/api/notifier/email/EmailNotifierProvider.scala +++ b/modules/api/src/main/scala/vinyldns/api/notifier/email/EmailNotifierProvider.scala @@ -17,7 +17,7 @@ package vinyldns.api.notifier.email import vinyldns.core.notifier.{Notifier, NotifierConfig, NotifierProvider} -import vinyldns.core.domain.membership.UserRepository +import vinyldns.core.domain.membership.{GroupRepository, UserRepository} import pureconfig._ import pureconfig.generic.auto._ import pureconfig.module.catseffect.syntax._ @@ -30,13 +30,13 @@ class EmailNotifierProvider extends NotifierProvider { private implicit val cs: ContextShift[IO] = IO.contextShift(scala.concurrent.ExecutionContext.global) - def load(config: NotifierConfig, userRepository: UserRepository): IO[Notifier] = + def load(config: NotifierConfig, userRepository: UserRepository, groupRepository: GroupRepository): IO[Notifier] = for { emailConfig <- Blocker[IO].use( ConfigSource.fromConfig(config.settings).loadF[IO, EmailNotifierConfig](_) ) session <- createSession(emailConfig) - } yield new EmailNotifier(emailConfig, session, userRepository) + } yield new EmailNotifier(emailConfig, session, userRepository, groupRepository) def createSession(config: EmailNotifierConfig): IO[Session] = IO { Session.getInstance(config.smtp) diff --git a/modules/api/src/main/scala/vinyldns/api/notifier/sns/SnsNotifierProvider.scala b/modules/api/src/main/scala/vinyldns/api/notifier/sns/SnsNotifierProvider.scala index dccd3c56d..bd72c5650 100644 --- a/modules/api/src/main/scala/vinyldns/api/notifier/sns/SnsNotifierProvider.scala +++ b/modules/api/src/main/scala/vinyldns/api/notifier/sns/SnsNotifierProvider.scala @@ -17,7 +17,7 @@ package vinyldns.api.notifier.sns import vinyldns.core.notifier.{Notifier, NotifierConfig, NotifierProvider} -import vinyldns.core.domain.membership.UserRepository +import vinyldns.core.domain.membership.{GroupRepository, UserRepository} import pureconfig._ import pureconfig.generic.auto._ import pureconfig.module.catseffect.syntax._ @@ -35,7 +35,7 @@ class SnsNotifierProvider extends NotifierProvider { IO.contextShift(scala.concurrent.ExecutionContext.global) private val logger = LoggerFactory.getLogger(classOf[SnsNotifierProvider]) - def load(config: NotifierConfig, userRepository: UserRepository): IO[Notifier] = + def load(config: NotifierConfig, userRepository: UserRepository, groupRepository: GroupRepository): IO[Notifier] = for { snsConfig <- Blocker[IO].use( ConfigSource.fromConfig(config.settings).loadF[IO, SnsNotifierConfig](_) diff --git a/modules/api/src/main/scala/vinyldns/api/route/DnsJsonProtocol.scala b/modules/api/src/main/scala/vinyldns/api/route/DnsJsonProtocol.scala index 7c0961326..98669c4c2 100644 --- a/modules/api/src/main/scala/vinyldns/api/route/DnsJsonProtocol.scala +++ b/modules/api/src/main/scala/vinyldns/api/route/DnsJsonProtocol.scala @@ -31,6 +31,8 @@ import vinyldns.core.domain.{EncryptFromJson, Encrypted, Fqdn} import vinyldns.core.domain.record._ import vinyldns.core.domain.zone._ import vinyldns.core.Messages._ +import vinyldns.core.domain.record.OwnerShipTransferStatus +import vinyldns.core.domain.record.OwnerShipTransferStatus.OwnerShipTransferStatus trait DnsJsonProtocol extends JsonValidation { import vinyldns.core.domain.record.RecordType._ @@ -42,11 +44,13 @@ trait DnsJsonProtocol extends JsonValidation { AlgorithmSerializer, EncryptedSerializer, RecordSetSerializer, + ownerShipTransferSerializer, RecordSetListInfoSerializer, RecordSetGlobalInfoSerializer, RecordSetInfoSerializer, RecordSetChangeSerializer, JsonEnumV(ZoneStatus), + JsonEnumV(OwnerShipTransferStatus), JsonEnumV(ZoneChangeStatus), JsonEnumV(RecordSetStatus), JsonEnumV(RecordSetChangeStatus), @@ -232,6 +236,7 @@ trait DnsJsonProtocol extends JsonValidation { (js \ "id").default[String](UUID.randomUUID().toString), (js \ "account").default[String]("system"), (js \ "ownerGroupId").optional[String], + (js \ "recordSetGroupChange").optional[OwnerShipTransfer], (js \ "fqdn").optional[String] ).mapN(RecordSet.apply) @@ -256,9 +261,23 @@ trait DnsJsonProtocol extends JsonValidation { ("id" -> rs.id) ~ ("account" -> rs.account) ~ ("ownerGroupId" -> rs.ownerGroupId) ~ + ("recordSetGroupChange" -> Extraction.decompose(rs.recordSetGroupChange)) ~ ("fqdn" -> rs.fqdn) } + + case object ownerShipTransferSerializer extends ValidationSerializer[OwnerShipTransfer] { + override def fromJson(js: JValue): ValidatedNel[String, OwnerShipTransfer] = + ( + (js \ "ownerShipTransferStatus").required[OwnerShipTransferStatus]("Missing ownerShipTransfer.ownerShipTransferStatus"), + (js \ "requestedOwnerGroupId").optional[String], + ).mapN(OwnerShipTransfer.apply) + + override def toJson(rsa: OwnerShipTransfer): JValue = + ("ownerShipTransferStatus" -> Extraction.decompose(rsa.ownerShipTransferStatus)) ~ + ("requestedOwnerGroupId" -> Extraction.decompose(rsa.requestedOwnerGroupId)) + } + case object RecordSetListInfoSerializer extends ValidationSerializer[RecordSetListInfo] { override def fromJson(js: JValue): ValidatedNel[String, RecordSetListInfo] = ( @@ -280,6 +299,7 @@ trait DnsJsonProtocol extends JsonValidation { ("accessLevel" -> rs.accessLevel.toString) ~ ("ownerGroupId" -> rs.ownerGroupId) ~ ("ownerGroupName" -> rs.ownerGroupName) ~ + ("recordSetGroupChange" -> Extraction.decompose(rs.recordSetGroupChange)) ~ ("fqdn" -> rs.fqdn) } @@ -301,6 +321,7 @@ trait DnsJsonProtocol extends JsonValidation { ("account" -> rs.account) ~ ("ownerGroupId" -> rs.ownerGroupId) ~ ("ownerGroupName" -> rs.ownerGroupName) ~ + ("recordSetGroupChange" -> Extraction.decompose(rs.recordSetGroupChange)) ~ ("fqdn" -> rs.fqdn) } @@ -326,6 +347,7 @@ trait DnsJsonProtocol extends JsonValidation { ("account" -> rs.account) ~ ("ownerGroupId" -> rs.ownerGroupId) ~ ("ownerGroupName" -> rs.ownerGroupName) ~ + ("recordSetGroupChange" -> Extraction.decompose(rs.recordSetGroupChange)) ~ ("fqdn" -> rs.fqdn) ~ ("zoneName" -> rs.zoneName) ~ ("zoneShared" -> rs.zoneShared) diff --git a/modules/api/src/test/functional/tests/recordsets/update_recordset_test.py b/modules/api/src/test/functional/tests/recordsets/update_recordset_test.py index 823dcecba..4139ce260 100644 --- a/modules/api/src/test/functional/tests/recordsets/update_recordset_test.py +++ b/modules/api/src/test/functional/tests/recordsets/update_recordset_test.py @@ -1927,6 +1927,561 @@ def test_update_from_acl_for_shared_zone_passes(shared_zone_test_context): delete_result = shared_client.delete_recordset(zone["id"], update_rs["id"], status=202) shared_client.wait_until_recordset_change_status(delete_result, "Complete") +def test_update_owner_group_transfer_auto_approved(shared_zone_test_context): + """ + Test auto approve ownerShip transfer, for shared zones + """ + shared_client = shared_zone_test_context.shared_zone_vinyldns_client + zone = shared_zone_test_context.shared_zone + shared_group = shared_zone_test_context.shared_record_group + ok_group = shared_zone_test_context.ok_group + + update_rs = None + + try: + record_json = create_recordset(zone, "test_shared_admin_update_success", "A", [{"address": "1.1.1.1"}]) + record_json["ownerGroupId"] = shared_group["id"] + create_response = shared_client.create_recordset(record_json, status=202) + update = shared_client.wait_until_recordset_change_status(create_response, "Complete")["recordSet"] + assert_that(update["ownerGroupId"], is_(shared_group["id"])) + + recordset_group_change_json = {"ownerShipTransferStatus": "AutoApproved", + "requestedOwnerGroupId": ok_group["id"]} + + update["recordSetGroupChange"] = recordset_group_change_json + update_response = shared_client.update_recordset(update, status=202) + update_rs = shared_client.wait_until_recordset_change_status(update_response, "Complete")["recordSet"] + assert_that(update_rs["recordSetGroupChange"], is_(recordset_group_change_json)) + assert_that(update_rs["ownerGroupId"], is_(ok_group["id"])) + finally: + if update_rs: + delete_result = shared_client.delete_recordset(zone["id"], update_rs["id"], status=202) + shared_client.wait_until_recordset_change_status(delete_result, "Complete") + + +def test_update_owner_group_transfer_request(shared_zone_test_context): + """ + Test requesting ownerShip transfer, for shared zones + """ + shared_client = shared_zone_test_context.shared_zone_vinyldns_client + dummy_client = shared_zone_test_context.dummy_vinyldns_client + zone = shared_zone_test_context.shared_zone + shared_group = shared_zone_test_context.shared_record_group + dummy_group = shared_zone_test_context.dummy_group + + update_rs = None + + try: + record_json = create_recordset(zone, "test_shared_admin_update_success", "A", [{"address": "1.1.1.1"}]) + record_json["ownerGroupId"] = dummy_group["id"] + + create_response = dummy_client.create_recordset(record_json, status=202) + update = dummy_client.wait_until_recordset_change_status(create_response, "Complete")["recordSet"] + assert_that(update["ownerGroupId"], is_(dummy_group["id"])) + + recordset_group_change_json = {"ownerShipTransferStatus": "Requested", + "requestedOwnerGroupId": shared_group["id"]} + recordset_group_change_pending_review_json = {"ownerShipTransferStatus": "PendingReview", + "requestedOwnerGroupId": shared_group["id"]} + update["recordSetGroupChange"] = recordset_group_change_json + + update_response = shared_client.update_recordset(update, status=202) + update_rs = shared_client.wait_until_recordset_change_status(update_response, "Complete")["recordSet"] + assert_that(update_rs["recordSetGroupChange"], is_(recordset_group_change_pending_review_json)) + assert_that(update_rs["ownerGroupId"], is_(dummy_group["id"])) + + finally: + if update_rs: + delete_result = shared_client.delete_recordset(zone["id"], update_rs["id"], status=202) + shared_client.wait_until_recordset_change_status(delete_result, "Complete") + + +def test_update_request_owner_group_transfer_manually_approved(shared_zone_test_context): + """ + Test approving ownerShip transfer request, for shared zones + """ + shared_client = shared_zone_test_context.shared_zone_vinyldns_client + ok_client = shared_zone_test_context.ok_vinyldns_client + zone = shared_zone_test_context.shared_zone + shared_group = shared_zone_test_context.shared_record_group + ok_group = shared_zone_test_context.ok_group + + update_rs = None + + try: + record_json = create_recordset(zone, "test_shared_admin_update_success", "A", [{"address": "1.1.1.1"}]) + record_json["ownerGroupId"] = shared_group["id"] + + create_response = shared_client.create_recordset(record_json, status=202) + update = shared_client.wait_until_recordset_change_status(create_response, "Complete")["recordSet"] + assert_that(update["ownerGroupId"], is_(shared_group["id"])) + + recordset_group_change_json = {"ownerShipTransferStatus": "Requested", + "requestedOwnerGroupId": ok_group["id"]} + recordset_group_change_pending_review_json = {"ownerShipTransferStatus": "PendingReview", + "requestedOwnerGroupId": ok_group["id"]} + update["recordSetGroupChange"] = recordset_group_change_json + + update_response = ok_client.update_recordset(update, status=202) + update_rs = ok_client.wait_until_recordset_change_status(update_response, "Complete")["recordSet"] + assert_that(update_rs["recordSetGroupChange"], is_(recordset_group_change_pending_review_json)) + assert_that(update_rs["ownerGroupId"], is_(shared_group["id"])) + + recordset_group_change_json = {"ownerShipTransferStatus": "ManuallyApproved"} + recordset_group_change_manually_approved_json = {"ownerShipTransferStatus": "ManuallyApproved", + "requestedOwnerGroupId": ok_group["id"]} + update_rs["recordSetGroupChange"] = recordset_group_change_json + update_rs_response = shared_client.update_recordset(update_rs, status=202) + update_rs_ownership = shared_client.wait_until_recordset_change_status(update_rs_response, "Complete")[ + "recordSet"] + assert_that(update_rs_ownership["recordSetGroupChange"], is_(recordset_group_change_manually_approved_json)) + assert_that(update_rs_ownership["ownerGroupId"], is_(ok_group["id"])) + + finally: + if update_rs: + delete_result = shared_client.delete_recordset(zone["id"], update_rs["id"], status=202) + shared_client.wait_until_recordset_change_status(delete_result, "Complete") + + +def test_update_request_owner_group_transfer_manually_rejected(shared_zone_test_context): + """ + Test rejecting ownerShip transfer request, for shared zones + """ + shared_client = shared_zone_test_context.shared_zone_vinyldns_client + ok_client = shared_zone_test_context.ok_vinyldns_client + zone = shared_zone_test_context.shared_zone + shared_group = shared_zone_test_context.shared_record_group + ok_group = shared_zone_test_context.ok_group + + update_rs = None + + try: + record_json = create_recordset(zone, "test_shared_admin_update_success", "A", [{"address": "1.1.1.1"}]) + record_json["ownerGroupId"] = shared_group["id"] + + create_response = shared_client.create_recordset(record_json, status=202) + update = shared_client.wait_until_recordset_change_status(create_response, "Complete")["recordSet"] + assert_that(update["ownerGroupId"], is_(shared_group["id"])) + + recordset_group_change_json = {"ownerShipTransferStatus": "Requested", + "requestedOwnerGroupId": ok_group["id"]} + recordset_group_change_pending_review_json = {"ownerShipTransferStatus": "PendingReview", + "requestedOwnerGroupId": ok_group["id"]} + update["recordSetGroupChange"] = recordset_group_change_json + + update_response = ok_client.update_recordset(update, status=202) + update_rs = ok_client.wait_until_recordset_change_status(update_response, "Complete")["recordSet"] + assert_that(update_rs["recordSetGroupChange"], is_(recordset_group_change_pending_review_json)) + assert_that(update_rs["ownerGroupId"], is_(shared_group["id"])) + + recordset_group_change_json = {"ownerShipTransferStatus": "ManuallyRejected"} + recordset_group_change_manually_rejected_json = {"ownerShipTransferStatus": "ManuallyRejected", + "requestedOwnerGroupId": ok_group["id"]} + update_rs["recordSetGroupChange"] = recordset_group_change_json + update_rs_response = shared_client.update_recordset(update_rs, status=202) + update_rs_ownership = shared_client.wait_until_recordset_change_status(update_rs_response, "Complete")[ + "recordSet"] + assert_that(update_rs_ownership["recordSetGroupChange"], is_(recordset_group_change_manually_rejected_json)) + assert_that(update_rs_ownership["ownerGroupId"], is_(shared_group["id"])) + + finally: + if update_rs: + delete_result = shared_client.delete_recordset(zone["id"], update_rs["id"], status=202) + shared_client.wait_until_recordset_change_status(delete_result, "Complete") + + +def test_update_request_owner_group_transfer_cancelled(shared_zone_test_context): + """ + Test cancelling ownerShip transfer request + """ + shared_client = shared_zone_test_context.shared_zone_vinyldns_client + ok_client = shared_zone_test_context.ok_vinyldns_client + zone = shared_zone_test_context.shared_zone + shared_group = shared_zone_test_context.shared_record_group + ok_group = shared_zone_test_context.ok_group + + update_rs = None + + try: + record_json = create_recordset(zone, "test_shared_admin_update_success", "A", [{"address": "1.1.1.1"}]) + record_json["ownerGroupId"] = shared_group["id"] + + create_response = shared_client.create_recordset(record_json, status=202) + update = shared_client.wait_until_recordset_change_status(create_response, "Complete")["recordSet"] + assert_that(update["ownerGroupId"], is_(shared_group["id"])) + + recordset_group_change_json = {"ownerShipTransferStatus": "Requested", + "requestedOwnerGroupId": ok_group["id"]} + recordset_group_change_pending_review_json = {"ownerShipTransferStatus": "PendingReview", + "requestedOwnerGroupId": ok_group["id"]} + update["recordSetGroupChange"] = recordset_group_change_json + + update_response = ok_client.update_recordset(update, status=202) + update_rs = ok_client.wait_until_recordset_change_status(update_response, "Complete")["recordSet"] + assert_that(update_rs["recordSetGroupChange"], is_(recordset_group_change_pending_review_json)) + assert_that(update_rs["ownerGroupId"], is_(shared_group["id"])) + + recordset_group_change_json = {"ownerShipTransferStatus": "Cancelled"} + recordset_group_change_cancelled_json = {"ownerShipTransferStatus": "Cancelled", + "requestedOwnerGroupId": ok_group["id"]} + update_rs["recordSetGroupChange"] = recordset_group_change_json + update_rs_response = ok_client.update_recordset(update_rs, status=202) + update_rs_ownership = ok_client.wait_until_recordset_change_status(update_rs_response, "Complete")["recordSet"] + assert_that(update_rs_ownership["recordSetGroupChange"], is_(recordset_group_change_cancelled_json)) + assert_that(update_rs_ownership["ownerGroupId"], is_(shared_group["id"])) + + finally: + if update_rs: + delete_result = shared_client.delete_recordset(zone["id"], update_rs["id"], status=202) + shared_client.wait_until_recordset_change_status(delete_result, "Complete") + + +def test_update_owner_group_transfer_approval_to_group_a_user_is_not_in_fails(shared_zone_test_context): + """ + Test approving ownerShip transfer request, for user not a member of owner group + """ + shared_client = shared_zone_test_context.shared_zone_vinyldns_client + dummy_client = shared_zone_test_context.dummy_vinyldns_client + zone = shared_zone_test_context.shared_zone + shared_group = shared_zone_test_context.shared_record_group + dummy_group = shared_zone_test_context.dummy_group + + update_rs = None + + try: + record_json = create_recordset(zone, "test_shared_admin_update_success", "A", [{"address": "1.1.1.1"}]) + record_json["ownerGroupId"] = dummy_group["id"] + + create_response = dummy_client.create_recordset(record_json, status=202) + update = dummy_client.wait_until_recordset_change_status(create_response, "Complete")["recordSet"] + assert_that(update["ownerGroupId"], is_(dummy_group["id"])) + + recordset_group_change_json = {"ownerShipTransferStatus": "Requested", + "requestedOwnerGroupId": shared_group["id"]} + recordset_group_change_pending_review_json = {"ownerShipTransferStatus": "PendingReview", + "requestedOwnerGroupId": shared_group["id"]} + update["recordSetGroupChange"] = recordset_group_change_json + + update_response = shared_client.update_recordset(update, status=202) + update_rs = shared_client.wait_until_recordset_change_status(update_response, "Complete")["recordSet"] + assert_that(update_rs["recordSetGroupChange"], is_(recordset_group_change_pending_review_json)) + assert_that(update_rs["ownerGroupId"], is_(dummy_group["id"])) + + recordset_group_change_approved_json = {"ownerShipTransferStatus": "ManuallyApproved"} + + update_rs["recordSetGroupChange"] = recordset_group_change_approved_json + error = shared_client.update_recordset(update_rs, status=422) + assert_that(error, is_(f"User not in record owner group with id \"{dummy_group['id']}\"")) + + finally: + if update_rs: + delete_result = shared_client.delete_recordset(zone["id"], update_rs["id"], status=202) + shared_client.wait_until_recordset_change_status(delete_result, "Complete") + + +def test_update_owner_group_transfer_reject_to_group_a_user_is_not_in_fails(shared_zone_test_context): + """ + Test rejecting ownerShip transfer request, for user not a member of owner group + """ + shared_client = shared_zone_test_context.shared_zone_vinyldns_client + dummy_client = shared_zone_test_context.dummy_vinyldns_client + zone = shared_zone_test_context.shared_zone + shared_group = shared_zone_test_context.shared_record_group + dummy_group = shared_zone_test_context.dummy_group + + update_rs = None + + try: + record_json = create_recordset(zone, "test_shared_admin_update_success", "A", [{"address": "1.1.1.1"}]) + record_json["ownerGroupId"] = dummy_group["id"] + + create_response = dummy_client.create_recordset(record_json, status=202) + update = dummy_client.wait_until_recordset_change_status(create_response, "Complete")["recordSet"] + assert_that(update["ownerGroupId"], is_(dummy_group["id"])) + + recordset_group_change_json = {"ownerShipTransferStatus": "Requested", + "requestedOwnerGroupId": shared_group["id"]} + recordset_group_change_pending_review_json = {"ownerShipTransferStatus": "PendingReview", + "requestedOwnerGroupId": shared_group["id"]} + update["recordSetGroupChange"] = recordset_group_change_json + + update_response = shared_client.update_recordset(update, status=202) + update_rs = shared_client.wait_until_recordset_change_status(update_response, "Complete")["recordSet"] + assert_that(update_rs["recordSetGroupChange"], is_(recordset_group_change_pending_review_json)) + assert_that(update_rs["ownerGroupId"], is_(dummy_group["id"])) + + recordset_group_change_approved_json = {"ownerShipTransferStatus": "ManuallyRejected"} + update_rs["recordSetGroupChange"] = recordset_group_change_approved_json + error = shared_client.update_recordset(update_rs, status=422) + assert_that(error, is_(f"User not in record owner group with id \"{dummy_group['id']}\"")) + + finally: + if update_rs: + delete_result = shared_client.delete_recordset(zone["id"], update_rs["id"], status=202) + shared_client.wait_until_recordset_change_status(delete_result, "Complete") + + +def test_update_owner_group_transfer_auto_approved_to_group_a_user_is_not_in_fails(shared_zone_test_context): + """ + Test approving ownerShip transfer request, for user not a member of owner group + """ + shared_client = shared_zone_test_context.shared_zone_vinyldns_client + dummy_client = shared_zone_test_context.dummy_vinyldns_client + zone = shared_zone_test_context.shared_zone + shared_group = shared_zone_test_context.shared_record_group + dummy_group = shared_zone_test_context.dummy_group + + update_rs = None + + try: + record_json = create_recordset(zone, "test_shared_admin_update_success", "A", [{"address": "1.1.1.1"}]) + record_json["ownerGroupId"] = dummy_group["id"] + + create_response = dummy_client.create_recordset(record_json, status=202) + update = dummy_client.wait_until_recordset_change_status(create_response, "Complete")["recordSet"] + assert_that(update["ownerGroupId"], is_(dummy_group["id"])) + + recordset_group_change_json = {"ownerShipTransferStatus": "Requested", + "requestedOwnerGroupId": shared_group["id"]} + recordset_group_change_pending_review_json = {"ownerShipTransferStatus": "PendingReview", + "requestedOwnerGroupId": shared_group["id"]} + update["recordSetGroupChange"] = recordset_group_change_json + + update_response = shared_client.update_recordset(update, status=202) + update_rs = shared_client.wait_until_recordset_change_status(update_response, "Complete")["recordSet"] + assert_that(update_rs["recordSetGroupChange"], is_(recordset_group_change_pending_review_json)) + assert_that(update_rs["ownerGroupId"], is_(dummy_group["id"])) + + recordset_group_change_approved_json = {"ownerShipTransferStatus": "AutoApproved"} + + update_rs["recordSetGroupChange"] = recordset_group_change_approved_json + error = shared_client.update_recordset(update_rs, status=422) + assert_that(error, is_(f"Record owner group with id \"{dummy_group['id']}\" not found")) + + finally: + if update_rs: + delete_result = shared_client.delete_recordset(zone["id"], update_rs["id"], status=202) + shared_client.wait_until_recordset_change_status(delete_result, "Complete") + + +def test_update_owner_group_transfer_approved_when_request_cancelled_in_fails(shared_zone_test_context): + """ + Test approving ownerShip transfer, for cancelled request + """ + shared_client = shared_zone_test_context.shared_zone_vinyldns_client + ok_client = shared_zone_test_context.ok_vinyldns_client + zone = shared_zone_test_context.shared_zone + shared_group = shared_zone_test_context.shared_record_group + ok_group = shared_zone_test_context.ok_group + + update_rs = None + + try: + record_json = create_recordset(zone, "test_shared_admin_update_success", "A", [{"address": "1.1.1.1"}]) + record_json["ownerGroupId"] = shared_group["id"] + + create_response = shared_client.create_recordset(record_json, status=202) + update = shared_client.wait_until_recordset_change_status(create_response, "Complete")["recordSet"] + assert_that(update["ownerGroupId"], is_(shared_group["id"])) + + recordset_group_change_json = {"ownerShipTransferStatus": "Requested", + "requestedOwnerGroupId": ok_group["id"]} + recordset_group_change_pending_review_json = {"ownerShipTransferStatus": "PendingReview", + "requestedOwnerGroupId": ok_group["id"]} + update["recordSetGroupChange"] = recordset_group_change_json + + update_response = ok_client.update_recordset(update, status=202) + update_rs = ok_client.wait_until_recordset_change_status(update_response, "Complete")["recordSet"] + assert_that(update_rs["recordSetGroupChange"], is_(recordset_group_change_pending_review_json)) + assert_that(update_rs["ownerGroupId"], is_(shared_group["id"])) + + recordset_group_change_json = {"ownerShipTransferStatus": "Cancelled"} + recordset_group_change_cancelled_json = {"ownerShipTransferStatus": "Cancelled", + "requestedOwnerGroupId": ok_group["id"]} + update_rs["recordSetGroupChange"] = recordset_group_change_json + update_rs_response = ok_client.update_recordset(update_rs, status=202) + update_rs_ownership = ok_client.wait_until_recordset_change_status(update_rs_response, "Complete")["recordSet"] + assert_that(update_rs_ownership["recordSetGroupChange"], is_(recordset_group_change_cancelled_json)) + assert_that(update_rs_ownership["ownerGroupId"], is_(shared_group["id"])) + + recordset_group_change_json = {"ownerShipTransferStatus": "ManuallyApproved"} + + update_rs["recordSetGroupChange"] = recordset_group_change_json + error = ok_client.update_recordset(update_rs, status=422) + assert_that(error, is_("Cannot update RecordSet OwnerShip Status when request is cancelled.")) + + finally: + if update_rs: + delete_result = shared_client.delete_recordset(zone["id"], update_rs["id"], status=202) + shared_client.wait_until_recordset_change_status(delete_result, "Complete") + + +def test_update_owner_group_transfer_rejected_when_request_cancelled_in_fails(shared_zone_test_context): + """ + Test rejecting ownerShip transfer, for cancelled request + """ + shared_client = shared_zone_test_context.shared_zone_vinyldns_client + ok_client = shared_zone_test_context.ok_vinyldns_client + zone = shared_zone_test_context.shared_zone + shared_group = shared_zone_test_context.shared_record_group + ok_group = shared_zone_test_context.ok_group + + update_rs = None + + try: + record_json = create_recordset(zone, "test_shared_admin_update_success", "A", [{"address": "1.1.1.1"}]) + record_json["ownerGroupId"] = shared_group["id"] + + create_response = shared_client.create_recordset(record_json, status=202) + update = shared_client.wait_until_recordset_change_status(create_response, "Complete")["recordSet"] + assert_that(update["ownerGroupId"], is_(shared_group["id"])) + + recordset_group_change_json = {"ownerShipTransferStatus": "Requested", + "requestedOwnerGroupId": ok_group["id"]} + recordset_group_change_pending_review_json = {"ownerShipTransferStatus": "PendingReview", + "requestedOwnerGroupId": ok_group["id"]} + update["recordSetGroupChange"] = recordset_group_change_json + + update_response = ok_client.update_recordset(update, status=202) + update_rs = ok_client.wait_until_recordset_change_status(update_response, "Complete")["recordSet"] + assert_that(update_rs["recordSetGroupChange"], is_(recordset_group_change_pending_review_json)) + assert_that(update_rs["ownerGroupId"], is_(shared_group["id"])) + + recordset_group_change_json = {"ownerShipTransferStatus": "Cancelled"} + recordset_group_change_cancelled_json = {"ownerShipTransferStatus": "Cancelled", + "requestedOwnerGroupId": ok_group["id"]} + update_rs["recordSetGroupChange"] = recordset_group_change_json + update_rs_response = ok_client.update_recordset(update_rs, status=202) + update_rs_ownership = ok_client.wait_until_recordset_change_status(update_rs_response, "Complete")["recordSet"] + assert_that(update_rs_ownership["recordSetGroupChange"], is_(recordset_group_change_cancelled_json)) + assert_that(update_rs_ownership["ownerGroupId"], is_(shared_group["id"])) + + recordset_group_change_json = {"ownerShipTransferStatus": "ManuallyRejected"} + + update_rs["recordSetGroupChange"] = recordset_group_change_json + error = ok_client.update_recordset(update_rs, status=422) + assert_that(error, is_("Cannot update RecordSet OwnerShip Status when request is cancelled.")) + + finally: + if update_rs: + delete_result = shared_client.delete_recordset(zone["id"], update_rs["id"], status=202) + shared_client.wait_until_recordset_change_status(delete_result, "Complete") + + +def test_update_owner_group_transfer_auto_approved_when_request_cancelled_in_fails(shared_zone_test_context): + """ + Test auto_approving ownerShip transfer, for cancelled request + """ + shared_client = shared_zone_test_context.shared_zone_vinyldns_client + ok_client = shared_zone_test_context.ok_vinyldns_client + zone = shared_zone_test_context.shared_zone + shared_group = shared_zone_test_context.shared_record_group + ok_group = shared_zone_test_context.ok_group + + update_rs = None + + try: + record_json = create_recordset(zone, "test_shared_admin_update_success", "A", [{"address": "1.1.1.1"}]) + record_json["ownerGroupId"] = shared_group["id"] + + create_response = shared_client.create_recordset(record_json, status=202) + update = shared_client.wait_until_recordset_change_status(create_response, "Complete")["recordSet"] + assert_that(update["ownerGroupId"], is_(shared_group["id"])) + + recordset_group_change_json = {"ownerShipTransferStatus": "Requested", + "requestedOwnerGroupId": ok_group["id"]} + recordset_group_change_pending_review_json = {"ownerShipTransferStatus": "PendingReview", + "requestedOwnerGroupId": ok_group["id"]} + update["recordSetGroupChange"] = recordset_group_change_json + + update_response = ok_client.update_recordset(update, status=202) + update_rs = ok_client.wait_until_recordset_change_status(update_response, "Complete")["recordSet"] + assert_that(update_rs["recordSetGroupChange"], is_(recordset_group_change_pending_review_json)) + assert_that(update_rs["ownerGroupId"], is_(shared_group["id"])) + + recordset_group_change_json = {"ownerShipTransferStatus": "Cancelled"} + recordset_group_change_cancelled_json = {"ownerShipTransferStatus": "Cancelled", + "requestedOwnerGroupId": ok_group["id"]} + update_rs["recordSetGroupChange"] = recordset_group_change_json + update_rs_response = ok_client.update_recordset(update_rs, status=202) + update_rs_ownership = ok_client.wait_until_recordset_change_status(update_rs_response, "Complete")["recordSet"] + assert_that(update_rs_ownership["recordSetGroupChange"], is_(recordset_group_change_cancelled_json)) + assert_that(update_rs_ownership["ownerGroupId"], is_(shared_group["id"])) + + recordset_group_change_json = {"ownerShipTransferStatus": "AutoApproved"} + + update_rs["recordSetGroupChange"] = recordset_group_change_json + error = ok_client.update_recordset(update_rs, status=422) + assert_that(error, is_("Cannot update RecordSet OwnerShip Status when request is cancelled.")) + + finally: + if update_rs: + delete_result = shared_client.delete_recordset(zone["id"], update_rs["id"], status=202) + shared_client.wait_until_recordset_change_status(delete_result, "Complete") + + +def test_update_owner_group_transfer_on_non_shared_zones_in_fails(shared_zone_test_context): + """ + Test that requesting ownerShip transfer for non shared zones + """ + shared_client = shared_zone_test_context.shared_zone_vinyldns_client + ok_client = shared_zone_test_context.ok_vinyldns_client + shared_group = shared_zone_test_context.shared_record_group + ok_zone = shared_zone_test_context.ok_zone + + update_rs = None + + try: + record_json = create_recordset(ok_zone, "test_update_success", "A", [{"address": "1.1.1.1"}]) + + create_response = ok_client.create_recordset(record_json, status=202) + update = ok_client.wait_until_recordset_change_status(create_response, "Complete")["recordSet"] + + recordset_group_change_json = {"ownerShipTransferStatus": "Requested", + "requestedOwnerGroupId": shared_group["id"]} + update["recordSetGroupChange"] = recordset_group_change_json + + error = shared_client.update_recordset(update, status=422) + assert_that(error, is_("Cannot update RecordSet OwnerShip Status when zone is not shared.")) + + finally: + if update_rs: + delete_result = shared_client.delete_recordset(zone["id"], update_rs["id"], status=202) + shared_client.wait_until_recordset_change_status(delete_result, "Complete") + + +def test_update_owner_group_transfer_and_ttl_on_user_not_in_owner_group_in_fails(shared_zone_test_context): + """ + Test that updating record "i.e.ttl" with requesting ownerShip transfer, where user not in the member of the owner group + """ + shared_client = shared_zone_test_context.shared_zone_vinyldns_client + dummy_client = shared_zone_test_context.dummy_vinyldns_client + zone = shared_zone_test_context.shared_zone + shared_group = shared_zone_test_context.shared_record_group + dummy_group = shared_zone_test_context.dummy_group + update_rs = None + + try: + record_json = create_recordset(zone, "test_shared_admin_update_success", "A", [{"address": "1.1.1.1"}]) + record_json["ownerGroupId"] = shared_group["id"] + + create_response = shared_client.create_recordset(record_json, status=202) + update = shared_client.wait_until_recordset_change_status(create_response, "Complete")["recordSet"] + assert_that(update["ownerGroupId"], is_(shared_group["id"])) + + recordset_group_change_json = {"ownerShipTransferStatus": "Requested", + "requestedOwnerGroupId": dummy_group["id"]} + update["recordSetGroupChange"] = recordset_group_change_json + update["ttl"] = update["ttl"] + 100 + + error = dummy_client.update_recordset(update, status=422) + assert_that(error, is_(f"Cannot update RecordSet's if user not a member of ownership group. User can only request for ownership transfer")) + + finally: + if update_rs: + delete_result = shared_client.delete_recordset(zone["id"], update_rs["id"], status=202) + shared_client.wait_until_recordset_change_status(delete_result, "Complete") + def test_update_to_no_group_owner_passes(shared_zone_test_context): """ diff --git a/modules/api/src/test/scala/vinyldns/api/domain/record/RecordSetServiceSpec.scala b/modules/api/src/test/scala/vinyldns/api/domain/record/RecordSetServiceSpec.scala index 68be5f775..2d76e6f88 100644 --- a/modules/api/src/test/scala/vinyldns/api/domain/record/RecordSetServiceSpec.scala +++ b/modules/api/src/test/scala/vinyldns/api/domain/record/RecordSetServiceSpec.scala @@ -40,6 +40,8 @@ import vinyldns.core.domain.membership.{GroupRepository, ListUsersResults, UserR import vinyldns.core.domain.record._ import vinyldns.core.domain.zone._ import vinyldns.core.queue.MessageQueue +import vinyldns.core.notifier.{AllNotifiers, Notification, Notifier} +import scala.concurrent.ExecutionContext class RecordSetServiceSpec extends AnyWordSpec @@ -47,6 +49,7 @@ class RecordSetServiceSpec with Matchers with MockitoSugar with BeforeAndAfterEach { + private implicit val contextShift: ContextShift[IO] = IO.contextShift(ExecutionContext.global) private val mockZoneRepo = mock[ZoneRepository] private val mockGroupRepo = mock[GroupRepository] @@ -58,6 +61,8 @@ class RecordSetServiceSpec private val mockBackend = mock[Backend] private val mockBackendResolver = mock[BackendResolver] + private val mockNotifier = mock[Notifier] + private val mockNotifiers = AllNotifiers(List(mockNotifier)) doReturn(IO.pure(Some(okZone))).when(mockZoneRepo).getZone(okZone.id) doReturn(IO.pure(Some(zoneNotAuthorized))) @@ -85,7 +90,8 @@ class RecordSetServiceSpec VinylDNSTestHelpers.highValueDomainConfig, VinylDNSTestHelpers.dottedHostsConfig, VinylDNSTestHelpers.approvedNameServers, - true + true, + mockNotifiers ) val underTestWithDnsBackendValidations = new RecordSetService( @@ -104,7 +110,8 @@ class RecordSetServiceSpec VinylDNSTestHelpers.highValueDomainConfig, VinylDNSTestHelpers.dottedHostsConfig, VinylDNSTestHelpers.approvedNameServers, - true + true, + mockNotifiers ) val underTestWithEmptyDottedHostsConfig = new RecordSetService( @@ -123,7 +130,8 @@ class RecordSetServiceSpec VinylDNSTestHelpers.highValueDomainConfig, VinylDNSTestHelpers.emptyDottedHostsConfig, VinylDNSTestHelpers.approvedNameServers, - true + true, + mockNotifiers ) def getDottedHostsConfigGroupsAllowed(zone: Zone, config: DottedHostsConfig): List[String] = { @@ -1177,6 +1185,9 @@ class RecordSetServiceSpec doReturn(IO.pure(Some(oldRecord))) .when(mockRecordRepo) .getRecordSet(oldRecord.id) + doReturn(IO.pure(Some(newRecord))) + .when(mockRecordRepo) + .getRecordSet(newRecord.id) doReturn(IO.pure(List(oldRecord))) .when(mockRecordRepo) .getRecordSetsByName(zone.id, oldRecord.name) @@ -1185,7 +1196,7 @@ class RecordSetServiceSpec .getGroup(okGroup.id) val result = underTest.updateRecordSet(newRecord, auth).value.unsafeRunSync().swap.toOption.get - result shouldBe an[InvalidRequest] + result shouldBe an[NotAuthorizedError] } "succeed if user is in owner group and zone is shared" in { val zone = okZone.copy(shared = true, id = "test-owner-group") @@ -1314,6 +1325,9 @@ class RecordSetServiceSpec doReturn(IO.pure(Some(oldRecord))) .when(mockRecordRepo) .getRecordSet(newRecord.id) + doReturn(IO.pure(Some(newRecord))) + .when(mockRecordRepo) + .getRecordSet(newRecord.id) doReturn(IO.pure(List(oldRecord))) .when(mockRecordRepo) .getRecordSetsByName(zone.id, newRecord.name) @@ -2200,4 +2214,514 @@ class RecordSetServiceSpec "thing.com." } } + "ownerShipTransfer" should { + "success if user request AutoApproved for the owner group is null" in { + val zone = okZone.copy(shared = true, id = "test-owner-group") + val auth = AuthPrincipal(listOfDummyUsers.head, Seq(oneUserDummyGroup.id)) + val oldRecord = aaaa.copy( + name = "test-owner-group-success", + zoneId = zone.id, + status = RecordSetStatus.Active, + ownerGroupId = None + ) + + val newRecord = oldRecord.copy(recordSetGroupChange = + Some(ownerShipTransfer.copy(ownerShipTransferStatus = OwnerShipTransferStatus.AutoApproved,requestedOwnerGroupId = Some(okGroup.id)))) + + doReturn(IO.pure(Some(zone))) + .when(mockZoneRepo) + .getZone(zone.id) + doReturn(IO.pure(Some(oldRecord))) + .when(mockRecordRepo) + .getRecordSet(newRecord.id) + doReturn(IO.pure(List(oldRecord))) + .when(mockRecordRepo) + .getRecordSetsByName(zone.id, newRecord.name) + doReturn(IO.pure(Some(oneUserDummyGroup))) + .when(mockGroupRepo) + .getGroup(oneUserDummyGroup.id) + doReturn(IO.pure(Some(okGroup))) + .when(mockGroupRepo) + .getGroup(okGroup.id) + doReturn(IO.pure(Set(dottedZone, abcZone, xyzZone, dotZone))) + .when(mockZoneRepo) + .getZonesByNames(dottedHostsConfigZonesAllowed.toSet) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(Set.empty) + doReturn(IO.pure(None)) + .when(mockZoneRepo) + .getZoneByName(newRecord.name + "." + okZone.name) + doReturn(IO.pure(List())) + .when(mockRecordRepo) + .getRecordSetsByFQDNs(Set(newRecord.name + "." + okZone.name)) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(Set.empty) + doReturn(IO.pure(Set())) + .when(mockGroupRepo) + .getGroupsByName(dottedHostsConfigGroupsAllowed.toSet) + doReturn(IO.pure(ListUsersResults(Seq(), None))) + .when(mockUserRepo) + .getUsers(Set.empty, None, None) + + val result = + underTest.updateRecordSet(newRecord, auth).map(_.asInstanceOf[RecordSetChange]).value.unsafeRunSync().toOption.get + + result.recordSet.ownerGroupId shouldBe Some(okGroup.id) + result.recordSet.recordSetGroupChange.map(_.requestedOwnerGroupId.get) shouldBe Some(okGroup.id) + result.recordSet.recordSetGroupChange.map(_.ownerShipTransferStatus) shouldBe Some(OwnerShipTransferStatus.AutoApproved) + } + + "fail if user request ownership transfer for non shared zone" in { + val zone = okZone.copy(id = "test-owner-group") + val oldRecord = aaaa.copy( + name = "test-owner-group-failure", + zoneId = zone.id, + status = RecordSetStatus.Active, + ownerGroupId = Some(oneUserDummyGroup.id) + ) + + val newRecord = oldRecord.copy(recordSetGroupChange = + Some(ownerShipTransfer.copy(ownerShipTransferStatus = OwnerShipTransferStatus.Requested, requestedOwnerGroupId = Some(okGroup.id)))) + doReturn(IO.pure(Some(zone))) + .when(mockZoneRepo) + .getZone(zone.id) + doReturn(IO.pure(Some(oldRecord))) + .when(mockRecordRepo) + .getRecordSet(newRecord.id) + doReturn(IO.pure(List(oldRecord))) + .when(mockRecordRepo) + .getRecordSetsByName(zone.id, oldRecord.name) + + val result = underTest.updateRecordSet(newRecord, okAuth).value.unsafeRunSync().swap.toOption.get + result shouldBe an[InvalidRequest] + } + + "fail if user not a member of owner group and tried to Approve ownership transfer request" in { + val zone = okZone.copy(shared = true, id = "test-owner-group") + val oldRecord = aaaa.copy( + name = "test-owner-group-failure", + zoneId = zone.id, + status = RecordSetStatus.Active, + ownerGroupId = Some(oneUserDummyGroup.id), + recordSetGroupChange = + Some(ownerShipTransfer.copy(ownerShipTransferStatus = OwnerShipTransferStatus.PendingReview,requestedOwnerGroupId = Some(okGroup.id))) + ) + + val newRecord = oldRecord.copy(recordSetGroupChange = + Some(ownerShipTransfer.copy(ownerShipTransferStatus = OwnerShipTransferStatus.ManuallyApproved))) + doReturn(IO.pure(Some(zone))) + .when(mockZoneRepo) + .getZone(zone.id) + doReturn(IO.pure(Some(oldRecord))) + .when(mockRecordRepo) + .getRecordSet(newRecord.id) + doReturn(IO.pure(List(oldRecord))) + .when(mockRecordRepo) + .getRecordSetsByName(zone.id, oldRecord.name) + doReturn(IO.pure(Some(okGroup))) + .when(mockGroupRepo) + .getGroup(okGroup.id) + + val result = underTest.updateRecordSet(newRecord, okAuth).value.unsafeRunSync().swap.toOption.get + result shouldBe an[InvalidRequest] + } + + "fail if user not a member of owner group and tried to Reject ownership transfer request" in { + val zone = okZone.copy(shared = true, id = "test-owner-group") + val oldRecord = aaaa.copy( + name = "test-owner-group-failure", + zoneId = zone.id, + status = RecordSetStatus.Active, + ownerGroupId = Some(oneUserDummyGroup.id), + recordSetGroupChange = + Some(ownerShipTransfer.copy(ownerShipTransferStatus = OwnerShipTransferStatus.PendingReview,requestedOwnerGroupId = Some(okGroup.id))) + ) + + val newRecord = oldRecord.copy(recordSetGroupChange = + Some(ownerShipTransfer.copy(ownerShipTransferStatus = OwnerShipTransferStatus.ManuallyRejected))) + doReturn(IO.pure(Some(zone))) + .when(mockZoneRepo) + .getZone(zone.id) + doReturn(IO.pure(Some(oldRecord))) + .when(mockRecordRepo) + .getRecordSet(newRecord.id) + doReturn(IO.pure(List(oldRecord))) + .when(mockRecordRepo) + .getRecordSetsByName(zone.id, oldRecord.name) + doReturn(IO.pure(Some(okGroup))) + .when(mockGroupRepo) + .getGroup(okGroup.id) + + val result = underTest.updateRecordSet(newRecord, okAuth).value.unsafeRunSync().swap.toOption.get + result shouldBe an[InvalidRequest] + } + + "success if user not a member of owner group and tried to Request ownership transfer request" in { + val zone = okZone.copy(shared = true, id = "test-owner-group") + val oldRecord = aaaa.copy( + name = "test-owner-group-success", + zoneId = zone.id, + status = RecordSetStatus.Active, + ownerGroupId = Some(oneUserDummyGroup.id) + ) + + val newRecord = oldRecord.copy(recordSetGroupChange = + Some(ownerShipTransfer.copy(ownerShipTransferStatus = OwnerShipTransferStatus.Requested,requestedOwnerGroupId = Some(okGroup.id)))) + + doReturn(IO.pure(Some(zone))) + .when(mockZoneRepo) + .getZone(zone.id) + doReturn(IO.pure(Some(oldRecord))) + .when(mockRecordRepo) + .getRecordSet(newRecord.id) + doReturn(IO.pure(List(oldRecord))) + .when(mockRecordRepo) + .getRecordSetsByName(zone.id, newRecord.name) + doReturn(IO.pure(Some(oneUserDummyGroup))) + .when(mockGroupRepo) + .getGroup(oneUserDummyGroup.id) + doReturn(IO.pure(Some(okGroup))) + .when(mockGroupRepo) + .getGroup(okGroup.id) + doReturn(IO.pure(Set(dottedZone, abcZone, xyzZone, dotZone))) + .when(mockZoneRepo) + .getZonesByNames(dottedHostsConfigZonesAllowed.toSet) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(Set.empty) + doReturn(IO.pure(None)) + .when(mockZoneRepo) + .getZoneByName(newRecord.name + "." + okZone.name) + doReturn(IO.pure(List())) + .when(mockRecordRepo) + .getRecordSetsByFQDNs(Set(newRecord.name + "." + okZone.name)) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(Set.empty) + doReturn(IO.pure(Set())) + .when(mockGroupRepo) + .getGroupsByName(dottedHostsConfigGroupsAllowed.toSet) + doReturn(IO.pure(ListUsersResults(Seq(), None))) + .when(mockUserRepo) + .getUsers(Set.empty, None, None) + doReturn(IO.unit).when(mockNotifier).notify(any[Notification[_]]) + + val result = + underTest.updateRecordSet(newRecord, okAuth).map(_.asInstanceOf[RecordSetChange]).value.unsafeRunSync().toOption.get + + result.recordSet.ownerGroupId shouldBe Some(oneUserDummyGroup.id) + result.recordSet.recordSetGroupChange.map(_.requestedOwnerGroupId.get) shouldBe Some(okGroup.id) + result.recordSet.recordSetGroupChange.map(_.ownerShipTransferStatus) shouldBe Some(OwnerShipTransferStatus.PendingReview) + } + + "success if user not a member of owner group and tried to Cancel ownership transfer request" in { + val zone = okZone.copy(shared = true, id = "test-owner-group") + val oldRecord = aaaa.copy( + name = "test-owner-group-success", + zoneId = zone.id, + status = RecordSetStatus.Active, + ownerGroupId = Some(oneUserDummyGroup.id), + recordSetGroupChange = + Some(ownerShipTransfer.copy(ownerShipTransferStatus = OwnerShipTransferStatus.PendingReview, requestedOwnerGroupId = Some(okGroup.id))) + ) + + val newRecord = oldRecord.copy(recordSetGroupChange = + Some(ownerShipTransfer.copy(ownerShipTransferStatus = OwnerShipTransferStatus.Cancelled))) + + doReturn(IO.pure(Some(zone))) + .when(mockZoneRepo) + .getZone(zone.id) + doReturn(IO.pure(Some(oldRecord))) + .when(mockRecordRepo) + .getRecordSet(newRecord.id) + doReturn(IO.pure(List(oldRecord))) + .when(mockRecordRepo) + .getRecordSetsByName(zone.id, newRecord.name) + doReturn(IO.pure(Some(oneUserDummyGroup))) + .when(mockGroupRepo) + .getGroup(oneUserDummyGroup.id) + doReturn(IO.pure(Some(okGroup))) + .when(mockGroupRepo) + .getGroup(okGroup.id) + doReturn(IO.pure(Set(dottedZone, abcZone, xyzZone, dotZone))) + .when(mockZoneRepo) + .getZonesByNames(dottedHostsConfigZonesAllowed.toSet) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(Set.empty) + doReturn(IO.pure(None)) + .when(mockZoneRepo) + .getZoneByName(newRecord.name + "." + okZone.name) + doReturn(IO.pure(List())) + .when(mockRecordRepo) + .getRecordSetsByFQDNs(Set(newRecord.name + "." + okZone.name)) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(Set.empty) + doReturn(IO.pure(Set())) + .when(mockGroupRepo) + .getGroupsByName(dottedHostsConfigGroupsAllowed.toSet) + doReturn(IO.pure(ListUsersResults(Seq(), None))) + .when(mockUserRepo) + .getUsers(Set.empty, None, None) + doReturn(IO.unit).when(mockNotifier).notify(any[Notification[_]]) + + val result = + underTest.updateRecordSet(newRecord, okAuth).map(_.asInstanceOf[RecordSetChange]).value.unsafeRunSync().toOption.get + + result.recordSet.ownerGroupId shouldBe Some(oneUserDummyGroup.id) + result.recordSet.recordSetGroupChange.map(_.requestedOwnerGroupId.get) shouldBe Some(okGroup.id) + result.recordSet.recordSetGroupChange.map(_.ownerShipTransferStatus) shouldBe Some(OwnerShipTransferStatus.Cancelled) + } + + "fail if user not a member of owner group and tried to update ttl while requesting ownership transfer" in { + val zone = okZone.copy(shared = true, id = "test-owner-group") + val oldRecord = + aaaa.copy(zoneId = zone.id, status = RecordSetStatus.Active, ownerGroupId = Some(oneUserDummyGroup.id)) + val newRecord = oldRecord.copy( + ttl = oldRecord.ttl + 1000, + recordSetGroupChange = + Some(ownerShipTransfer.copy(ownerShipTransferStatus = OwnerShipTransferStatus.Requested,requestedOwnerGroupId = Some(okGroup.id)))) + + doReturn(IO.pure(Some(okZone))) + .when(mockZoneRepo) + .getZone(newRecord.zoneId) + doReturn(IO.pure(Some(oldRecord))) + .when(mockRecordRepo) + .getRecordSet(newRecord.id) + + val result = underTest.updateRecordSet(newRecord, okAuth).value.unsafeRunSync().swap.toOption.get + result shouldBe a[InvalidRequest] + } + + "fail if user not a member of owner group and tried to update ttl while cancel ownership transfer" in { + val zone = okZone.copy(shared = true, id = "test-owner-group") + val oldRecord = + aaaa.copy(zoneId = zone.id, status = RecordSetStatus.Active, ownerGroupId = Some(oneUserDummyGroup.id),recordSetGroupChange = + Some(ownerShipTransfer.copy(ownerShipTransferStatus = OwnerShipTransferStatus.PendingReview, requestedOwnerGroupId = Some(okGroup.id))) + ) + val newRecord = oldRecord.copy( + records = List(AAAAData("1:2:3:4:5:6:7:9")), + recordSetGroupChange = + Some(ownerShipTransfer.copy(ownerShipTransferStatus = OwnerShipTransferStatus.Cancelled))) + + doReturn(IO.pure(Some(okZone))) + .when(mockZoneRepo) + .getZone(newRecord.zoneId) + doReturn(IO.pure(Some(oldRecord))) + .when(mockRecordRepo) + .getRecordSet(newRecord.id) + + val result = underTest.updateRecordSet(newRecord, okAuth).value.unsafeRunSync().swap.toOption.get + result shouldBe a[InvalidRequest] + } + + "fail if user cancelled the ownership request and group member tried to approve the request" in { + val zone = okZone.copy(shared = true, id = "test-owner-group") + val auth = AuthPrincipal(listOfDummyUsers.head, Seq(oneUserDummyGroup.id)) + + val oldRecord = aaaa.copy( + name = "test-owner-group-failure", + zoneId = zone.id, + status = RecordSetStatus.Active, + ownerGroupId = Some(oneUserDummyGroup.id), + recordSetGroupChange = + Some(ownerShipTransfer.copy(ownerShipTransferStatus = OwnerShipTransferStatus.Cancelled,requestedOwnerGroupId = Some(okGroup.id))) + ) + + val newRecord = oldRecord.copy(recordSetGroupChange = + Some(ownerShipTransfer.copy(ownerShipTransferStatus = OwnerShipTransferStatus.ManuallyApproved))) + + doReturn(IO.pure(Some(okZone))) + .when(mockZoneRepo) + .getZone(newRecord.zoneId) + doReturn(IO.pure(Some(oldRecord))) + .when(mockRecordRepo) + .getRecordSet(newRecord.id) + + val result = underTest.updateRecordSet(newRecord, auth).value.unsafeRunSync().swap.toOption.get + result shouldBe a[InvalidRequest] + } + + "fail if user cancelled the ownership request and group member tried to reject the request" in { + val zone = okZone.copy(shared = true, id = "test-owner-group") + val auth = AuthPrincipal(listOfDummyUsers.head, Seq(oneUserDummyGroup.id)) + + val oldRecord = aaaa.copy( + name = "test-owner-group-failure", + zoneId = zone.id, + status = RecordSetStatus.Active, + ownerGroupId = Some(oneUserDummyGroup.id), + recordSetGroupChange = + Some(ownerShipTransfer.copy(ownerShipTransferStatus = OwnerShipTransferStatus.Cancelled,requestedOwnerGroupId = Some(okGroup.id))) + ) + + val newRecord = oldRecord.copy(recordSetGroupChange = + Some(ownerShipTransfer.copy(ownerShipTransferStatus = OwnerShipTransferStatus.ManuallyRejected))) + + doReturn(IO.pure(Some(okZone))) + .when(mockZoneRepo) + .getZone(newRecord.zoneId) + doReturn(IO.pure(Some(oldRecord))) + .when(mockRecordRepo) + .getRecordSet(newRecord.id) + + val result = underTest.updateRecordSet(newRecord, auth).value.unsafeRunSync().swap.toOption.get + result shouldBe a[InvalidRequest] + } + + "fail if user cancelled the ownership request and group member tried to reject the auto-approve" in { + val zone = okZone.copy(shared = true, id = "test-owner-group") + val auth = AuthPrincipal(listOfDummyUsers.head, Seq(oneUserDummyGroup.id)) + + val oldRecord = aaaa.copy( + name = "test-owner-group-failure", + zoneId = zone.id, + status = RecordSetStatus.Active, + ownerGroupId = Some(oneUserDummyGroup.id), + recordSetGroupChange = + Some(ownerShipTransfer.copy(ownerShipTransferStatus = OwnerShipTransferStatus.Cancelled,requestedOwnerGroupId = Some(okGroup.id))) + ) + + val newRecord = oldRecord.copy(recordSetGroupChange = + Some(ownerShipTransfer.copy(ownerShipTransferStatus = OwnerShipTransferStatus.AutoApproved))) + + doReturn(IO.pure(Some(okZone))) + .when(mockZoneRepo) + .getZone(newRecord.zoneId) + doReturn(IO.pure(Some(oldRecord))) + .when(mockRecordRepo) + .getRecordSet(newRecord.id) + + val result = underTest.updateRecordSet(newRecord, auth).value.unsafeRunSync().swap.toOption.get + result shouldBe a[InvalidRequest] + } + + "success if user Approve a ownership transfer request Manually" in { + val zone = okZone.copy(shared = true, id = "test-owner-group") + val auth = AuthPrincipal(listOfDummyUsers.head, Seq(oneUserDummyGroup.id)) + val oldRecord = aaaa.copy( + name = "test-owner-group-success", + zoneId = zone.id, + status = RecordSetStatus.Active, + ownerGroupId = Some(oneUserDummyGroup.id), + recordSetGroupChange = + Some(ownerShipTransfer.copy(ownerShipTransferStatus = OwnerShipTransferStatus.PendingReview,requestedOwnerGroupId = Some(okGroup.id))) + ) + + val newRecord = oldRecord.copy(recordSetGroupChange = + Some(ownerShipTransfer.copy(ownerShipTransferStatus = OwnerShipTransferStatus.ManuallyApproved))) + + doReturn(IO.pure(Some(zone))) + .when(mockZoneRepo) + .getZone(zone.id) + doReturn(IO.pure(Some(oldRecord))) + .when(mockRecordRepo) + .getRecordSet(newRecord.id) + doReturn(IO.pure(List(oldRecord))) + .when(mockRecordRepo) + .getRecordSetsByName(zone.id, newRecord.name) + doReturn(IO.pure(Some(oneUserDummyGroup))) + .when(mockGroupRepo) + .getGroup(oneUserDummyGroup.id) + doReturn(IO.pure(Some(okGroup))) + .when(mockGroupRepo) + .getGroup(okGroup.id) + doReturn(IO.pure(Set(dottedZone, abcZone, xyzZone, dotZone))) + .when(mockZoneRepo) + .getZonesByNames(dottedHostsConfigZonesAllowed.toSet) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(Set.empty) + doReturn(IO.pure(None)) + .when(mockZoneRepo) + .getZoneByName(newRecord.name + "." + okZone.name) + doReturn(IO.pure(List())) + .when(mockRecordRepo) + .getRecordSetsByFQDNs(Set(newRecord.name + "." + okZone.name)) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(Set.empty) + doReturn(IO.pure(Set())) + .when(mockGroupRepo) + .getGroupsByName(dottedHostsConfigGroupsAllowed.toSet) + doReturn(IO.pure(ListUsersResults(Seq(), None))) + .when(mockUserRepo) + .getUsers(Set.empty, None, None) + doReturn(IO.unit).when(mockNotifier).notify(any[Notification[_]]) + + val result = { + underTest.updateRecordSet(newRecord, auth).map(_.asInstanceOf[RecordSetChange]).value.unsafeRunSync().toOption.get + } + + result.recordSet.ownerGroupId shouldBe Some(okGroup.id) + result.recordSet.recordSetGroupChange.map(_.requestedOwnerGroupId.get) shouldBe Some(okGroup.id) + result.recordSet.recordSetGroupChange.map(_.ownerShipTransferStatus) shouldBe Some(OwnerShipTransferStatus.ManuallyApproved) + } + + "success if user Reject a ownership transfer request Manually" in { + val zone = okZone.copy(shared = true, id = "test-owner-group") + val auth = AuthPrincipal(listOfDummyUsers.head, Seq(oneUserDummyGroup.id)) + val oldRecord = aaaa.copy( + name = "test-owner-group-success", + zoneId = zone.id, + status = RecordSetStatus.Active, + ownerGroupId = Some(oneUserDummyGroup.id), + recordSetGroupChange = + Some(ownerShipTransfer.copy(ownerShipTransferStatus = OwnerShipTransferStatus.PendingReview,requestedOwnerGroupId = Some(okGroup.id))) + ) + + val newRecord = oldRecord.copy(recordSetGroupChange = + Some(ownerShipTransfer.copy(ownerShipTransferStatus = OwnerShipTransferStatus.ManuallyRejected))) + + doReturn(IO.pure(Some(zone))) + .when(mockZoneRepo) + .getZone(zone.id) + doReturn(IO.pure(Some(oldRecord))) + .when(mockRecordRepo) + .getRecordSet(newRecord.id) + doReturn(IO.pure(List(oldRecord))) + .when(mockRecordRepo) + .getRecordSetsByName(zone.id, newRecord.name) + doReturn(IO.pure(Some(oneUserDummyGroup))) + .when(mockGroupRepo) + .getGroup(oneUserDummyGroup.id) + doReturn(IO.pure(Some(okGroup))) + .when(mockGroupRepo) + .getGroup(okGroup.id) + doReturn(IO.pure(Set(dottedZone, abcZone, xyzZone, dotZone))) + .when(mockZoneRepo) + .getZonesByNames(dottedHostsConfigZonesAllowed.toSet) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(Set.empty) + doReturn(IO.pure(None)) + .when(mockZoneRepo) + .getZoneByName(newRecord.name + "." + okZone.name) + doReturn(IO.pure(List())) + .when(mockRecordRepo) + .getRecordSetsByFQDNs(Set(newRecord.name + "." + okZone.name)) + doReturn(IO.pure(Set())) + .when(mockZoneRepo) + .getZonesByFilters(Set.empty) + doReturn(IO.pure(Set())) + .when(mockGroupRepo) + .getGroupsByName(dottedHostsConfigGroupsAllowed.toSet) + doReturn(IO.pure(ListUsersResults(Seq(), None))) + .when(mockUserRepo) + .getUsers(Set.empty, None, None) + doReturn(IO.unit).when(mockNotifier).notify(any[Notification[_]]) + + val result = { + underTest.updateRecordSet(newRecord, auth).map(_.asInstanceOf[RecordSetChange]).value.unsafeRunSync().toOption.get + } + + result.recordSet.ownerGroupId shouldBe Some(oneUserDummyGroup.id) + result.recordSet.recordSetGroupChange.map(_.requestedOwnerGroupId.get) shouldBe Some(okGroup.id) + result.recordSet.recordSetGroupChange.map(_.ownerShipTransferStatus) shouldBe Some(OwnerShipTransferStatus.ManuallyRejected) + } + } } + diff --git a/modules/api/src/test/scala/vinyldns/api/domain/record/RecordSetValidationsSpec.scala b/modules/api/src/test/scala/vinyldns/api/domain/record/RecordSetValidationsSpec.scala index 16fa80833..b104f0c3f 100644 --- a/modules/api/src/test/scala/vinyldns/api/domain/record/RecordSetValidationsSpec.scala +++ b/modules/api/src/test/scala/vinyldns/api/domain/record/RecordSetValidationsSpec.scala @@ -722,5 +722,85 @@ class RecordSetValidationsSpec canSuperUserUpdateOwnerGroup(existing, rs, zone, superUserAuth) should be(false) } } + "unchangedRecordSet" should { + "return invalid request when given zone ID does not match existing recordset zone ID" in { + val existing = rsOk + val rs = rsOk.copy(zoneId = "not-real") + val error = leftValue(unchangedRecordSet(existing, rs)) + error shouldBe an[InvalidRequest] + error.getMessage() shouldBe "Cannot update RecordSet's if user not a member of ownership group. User can only request for ownership transfer" + } + "return invalid request when given record type does not match existing recordset record type" in { + val existing = rsOk + val rs = rsOk.copy(typ = RecordType.AAAA) + val error = leftValue(unchangedRecordSet(existing, rs)) + error shouldBe an[InvalidRequest] + error.getMessage() shouldBe "Cannot update RecordSet's if user not a member of ownership group. User can only request for ownership transfer" + } + "return invalid request when given records does not match existing recordset records" in { + val existing = rsOk + val rs = rsOk.copy(records = List(AData("10.1.1.0"))) + val error = leftValue(unchangedRecordSet(existing, rs)) + error shouldBe an[InvalidRequest] + error.getMessage() shouldBe "Cannot update RecordSet's if user not a member of ownership group. User can only request for ownership transfer" + } + "return invalid request when given recordset id does not match existing recordset ID" in { + val existing = rsOk + val rs = rsOk.copy(id = abcRecord.id) + val error = leftValue(unchangedRecordSet(existing, rs)) + error shouldBe an[InvalidRequest] + error.getMessage() shouldBe "Cannot update RecordSet's if user not a member of ownership group. User can only request for ownership transfer" + } + "return invalid request when given recordset name does not match existing recordset name" in { + val existing = rsOk + val rs = rsOk.copy(name = "abc") + val error = leftValue(unchangedRecordSet(existing, rs)) + error shouldBe an[InvalidRequest] + error.getMessage() shouldBe "Cannot update RecordSet's if user not a member of ownership group. User can only request for ownership transfer" + } + "return invalid request when given owner group ID does not match existing recordset owner group ID" in { + val existing = rsOk + val rs = rsOk.copy(ownerGroupId = Some(abcGroup.id)) + val error = leftValue(unchangedRecordSet(existing, rs)) + error shouldBe an[InvalidRequest] + error.getMessage() shouldBe "Cannot update RecordSet's if user not a member of ownership group. User can only request for ownership transfer" + } + "return invalid request when given ttl does not match existing recordset ttl" in { + val existing = rsOk + val rs = rsOk.copy(ttl = 3000) + val error = leftValue(unchangedRecordSet(existing, rs)) + error shouldBe an[InvalidRequest] + error.getMessage() shouldBe "Cannot update RecordSet's if user not a member of ownership group. User can only request for ownership transfer" + } + } + "recordSetOwnerShipApproveStatus" should { + "return invalid request when given ownership transfer does not match OwnerShipTransferStatus as ManuallyRejected" in { + val rs = rsOk.copy(recordSetGroupChange = Some(ownerShipTransfer.copy(OwnerShipTransferStatus.ManuallyRejected))) + val error = leftValue(recordSetOwnerShipApproveStatus(rs)) + error shouldBe an[InvalidRequest] + error.getMessage() shouldBe "Cannot update RecordSet OwnerShip Status when request is cancelled." + } + "return invalid request when given ownership transfer does not match OwnerShipTransferStatus as ManuallyApproved" in { + val rs = rsOk.copy(recordSetGroupChange = Some(ownerShipTransfer.copy(OwnerShipTransferStatus.ManuallyApproved))) + val error = leftValue(recordSetOwnerShipApproveStatus(rs)) + error shouldBe an[InvalidRequest] + error.getMessage() shouldBe "Cannot update RecordSet OwnerShip Status when request is cancelled." + } + "return invalid request when given ownership transfer does not match OwnerShipTransferStatus as AutoApproved" in { + val rs = rsOk.copy(recordSetGroupChange = Some(ownerShipTransfer.copy(OwnerShipTransferStatus.AutoApproved))) + val error = leftValue(recordSetOwnerShipApproveStatus(rs)) + error shouldBe an[InvalidRequest] + error.getMessage() shouldBe "Cannot update RecordSet OwnerShip Status when request is cancelled." + } + } + "unchangedRecordSetOwnershipStatus" should { + "return invalid request when given ownership transfer status does not match existing recordset ownership transfer status for non shared zones" in { + val existing = rsOk + val rs = rsOk.copy(recordSetGroupChange = Some(ownerShipTransfer.copy(OwnerShipTransferStatus.AutoApproved))) + val error = leftValue(unchangedRecordSetOwnershipStatus(existing, rs)) + error shouldBe an[InvalidRequest] + error.getMessage() shouldBe "Cannot update RecordSet OwnerShip Status when zone is not shared." + } + } } } diff --git a/modules/api/src/test/scala/vinyldns/api/notifier/email/EmailNotifierSpec.scala b/modules/api/src/test/scala/vinyldns/api/notifier/email/EmailNotifierSpec.scala index 3a2c10d2b..243ac3546 100644 --- a/modules/api/src/test/scala/vinyldns/api/notifier/email/EmailNotifierSpec.scala +++ b/modules/api/src/test/scala/vinyldns/api/notifier/email/EmailNotifierSpec.scala @@ -20,9 +20,10 @@ import org.scalatest.wordspec.AnyWordSpec import org.scalatest.BeforeAndAfterEach import org.scalatestplus.mockito.MockitoSugar import vinyldns.api.CatsHelpers + import javax.mail.{Provider, Session, Transport, URLName} import java.util.Properties -import vinyldns.core.domain.membership.{User, UserRepository} +import vinyldns.core.domain.membership.{GroupRepository, User, UserRepository} import vinyldns.core.notifier.Notification import javax.mail.internet.InternetAddress @@ -30,18 +31,22 @@ import org.mockito.Matchers.{eq => eqArg, _} import org.mockito.Mockito._ import org.mockito.ArgumentCaptor import cats.effect.IO + import javax.mail.{Address, Message} import _root_.vinyldns.core.domain.batch._ + import java.time.Instant import java.time.temporal.ChronoUnit -import vinyldns.core.domain.record.RecordType -import vinyldns.core.domain.record.AData +import vinyldns.core.domain.record.{AData, OwnerShipTransferStatus, RecordSetChange, RecordSetChangeStatus, RecordSetChangeType, RecordType} import com.typesafe.config.Config import com.typesafe.config.ConfigFactory import vinyldns.core.domain.Encrypted import scala.collection.JavaConverters._ import vinyldns.core.notifier.NotifierConfig +import vinyldns.core.TestMembershipData.{dummyGroup, dummyUser, okGroup, okUser} +import vinyldns.core.TestRecordSetData.{ownerShipTransfer, rsOk} +import vinyldns.core.TestZoneData.okZone object MockTransport extends MockitoSugar { val mockTransport: Transport = mock[Transport] @@ -68,6 +73,7 @@ class EmailNotifierSpec import MockTransport._ val mockUserRepository: UserRepository = mock[UserRepository] + val mockGroupRepository: GroupRepository = mock[GroupRepository] val session: Session = Session.getInstance(new Properties()) session.setProvider( new Provider( @@ -100,6 +106,17 @@ class EmailNotifierSpec "testBatch" ) + + def reccordSetChange: RecordSetChange = + RecordSetChange( + okZone, + rsOk.copy(ownerGroupId= Some(okGroup.id),recordSetGroupChange = + Some(ownerShipTransfer.copy(ownerShipTransferStatus = OwnerShipTransferStatus.PendingReview, requestedOwnerGroupId = Some(dummyGroup.id)))), + "system", + RecordSetChangeType.Create, + RecordSetChangeStatus.Complete + ) + "Email Notifier" should { "do nothing for unsupported Notifications" in { val emailConfig: Config = ConfigFactory.parseMap( @@ -110,7 +127,7 @@ class EmailNotifierSpec ).asJava ) val notifier = new EmailNotifierProvider() - .load(NotifierConfig("", emailConfig), mockUserRepository) + .load(NotifierConfig("", emailConfig), mockUserRepository, mockGroupRepository) .unsafeRunSync() notifier.notify(new Notification("this won't be supported ever")) should be(IO.unit) @@ -120,7 +137,8 @@ class EmailNotifierSpec val notifier = new EmailNotifier( EmailNotifierConfig(new InternetAddress("test@test.com"), new Properties()), session, - mockUserRepository + mockUserRepository, + mockGroupRepository ) doReturn(IO.pure(Some(User("testUser", "access", Encrypted("secret"))))) @@ -132,11 +150,60 @@ class EmailNotifierSpec verify(mockUserRepository).getUser("test") } + "send an email to a user for recordSet ownership transfer" in { + val fromAddress = new InternetAddress("test@test.com") + + val notifier = new EmailNotifier( + EmailNotifierConfig(fromAddress, new Properties()), + session, + mockUserRepository, + mockGroupRepository + ) + val expectedAddresses = Array[Address](new InternetAddress("test@test.com"),new InternetAddress("test@test.com")) + val messageArgument = ArgumentCaptor.forClass(classOf[Message]) + val dummyGrp = dummyGroup.copy(memberIds = Set(dummyUser.id)) + val dummyUsr = dummyUser.copy(id=dummyUser.id,email = Some("test@test.com")) + + doNothing().when(mockTransport).connect() + doNothing() + .when(mockTransport) + .sendMessage(messageArgument.capture(), eqArg(expectedAddresses)) + doNothing().when(mockTransport).close() + doReturn(IO.pure(Some(okGroup))) + .when(mockGroupRepository) + .getGroup(okGroup.id) + doReturn(IO.pure(Some(dummyGrp))) + .when(mockGroupRepository) + .getGroup(dummyGrp.id) + doReturn(IO.pure(Some(okUser))) + .when(mockUserRepository) + .getUser(okUser.id) + doReturn(IO.pure(Some(dummyUsr))) + .when(mockUserRepository) + .getUser(dummyGrp.memberIds.head) + + val rsc = reccordSetChange.copy(userId = okUser.id) + + notifier.notify(Notification(rsc)).unsafeRunSync() + val message = messageArgument.getValue + message.getFrom should be(Array(fromAddress)) + message.getContentType should be("text/html; charset=us-ascii") + message.getAllRecipients should be(expectedAddresses) + message.getSubject should be(s"VinylDNS RecordSet change ${rsc.id} results") + + val content = message.getContent.asInstanceOf[String] + + content.contains(rsc.id) should be(true) + content.contains(rsc.recordSet.ownerGroupId.get) should be(true) + content.contains(rsc.recordSet.recordSetGroupChange.map(_.requestedOwnerGroupId.get).get) should be(true) + } + "do nothing when user not found" in { val notifier = new EmailNotifier( EmailNotifierConfig(new InternetAddress("test@test.com"), new Properties()), session, - mockUserRepository + mockUserRepository, + mockGroupRepository ) doReturn(IO.pure(None)) @@ -148,12 +215,13 @@ class EmailNotifierSpec verify(mockUserRepository).getUser("test") } - "send an email to a user" in { + "send an email to a user for batch change" in { val fromAddress = new InternetAddress("test@test.com") val notifier = new EmailNotifier( EmailNotifierConfig(fromAddress, new Properties()), session, - mockUserRepository + mockUserRepository, + mockGroupRepository ) doReturn( diff --git a/modules/api/src/test/scala/vinyldns/api/notifier/sns/SnsNotifierSpec.scala b/modules/api/src/test/scala/vinyldns/api/notifier/sns/SnsNotifierSpec.scala index 2adc41e7c..8f8277417 100644 --- a/modules/api/src/test/scala/vinyldns/api/notifier/sns/SnsNotifierSpec.scala +++ b/modules/api/src/test/scala/vinyldns/api/notifier/sns/SnsNotifierSpec.scala @@ -20,6 +20,7 @@ import org.scalatest.wordspec.AnyWordSpec import org.scalatest.BeforeAndAfterEach import org.scalatestplus.mockito.MockitoSugar import vinyldns.api.CatsHelpers +import vinyldns.core.domain.membership.{GroupRepository, UserRepository} import vinyldns.core.domain.membership.UserRepository import vinyldns.core.notifier.Notification import org.mockito.Matchers._ @@ -51,6 +52,7 @@ class SnsNotifierSpec with CatsHelpers { val mockUserRepository = mock[UserRepository] + val mockGroupRepository = mock[GroupRepository] val mockSns = mock[AmazonSNS] override protected def beforeEach(): Unit = @@ -86,7 +88,7 @@ class SnsNotifierSpec ).asJava ) val notifier = new SnsNotifierProvider() - .load(NotifierConfig("", snsConfig), mockUserRepository) + .load(NotifierConfig("", snsConfig), mockUserRepository, mockGroupRepository) .unsafeRunSync() notifier.notify(Notification("this won't be supported ever")) should be(IO.unit) diff --git a/modules/api/src/test/scala/vinyldns/api/route/VinylDNSJsonProtocolSpec.scala b/modules/api/src/test/scala/vinyldns/api/route/VinylDNSJsonProtocolSpec.scala index 3d9576b56..dd0ce0259 100644 --- a/modules/api/src/test/scala/vinyldns/api/route/VinylDNSJsonProtocolSpec.scala +++ b/modules/api/src/test/scala/vinyldns/api/route/VinylDNSJsonProtocolSpec.scala @@ -680,6 +680,206 @@ class VinylDNSJsonProtocolSpec val thrown = the[MappingException] thrownBy recordSetJValue.extract[RecordSet] thrown.msg should include("Digest Type 0 is not a supported DS record digest type") } + "auto-approve a owner ship transfer request" in { + val recordSetJValue: JValue = + ("zoneId" -> "1") ~~ + ("name" -> "TestRecordName") ~~ + ("type" -> "CNAME") ~~ + ("ttl" -> 1000) ~~ + ("status" -> "Pending") ~~ + ("records" -> List("cname" -> "cname.data ")) ~~ + ("recordSetGroupChange" -> Some("ownerShipTransferStatus" -> "AutoApproved")) + + + val expected = RecordSet( + "1", + "TestRecordName", + RecordType.CNAME, + 1000, + RecordSetStatus.Pending, + LocalDateTime.of(2010, Month.JANUARY, 1, 0, 0).toInstant(ZoneOffset.UTC), + records = List(CNAMEData(Fqdn("cname.data."))), + recordSetGroupChange = Some(OwnerShipTransfer(ownerShipTransferStatus = OwnerShipTransferStatus.AutoApproved)) + + ) + + val actual = recordSetJValue.extract[RecordSet] + anonymize(actual) shouldBe anonymize(expected) + anonymize(actual).recordSetGroupChange.get.ownerShipTransferStatus shouldBe OwnerShipTransferStatus.AutoApproved + } + + "manually-approve a owner ship transfer request" in { + val recordSetJValue: JValue = + ("zoneId" -> "1") ~~ + ("name" -> "TestRecordName") ~~ + ("type" -> "CNAME") ~~ + ("ttl" -> 1000) ~~ + ("status" -> "Pending") ~~ + ("records" -> List("cname" -> "cname.data ")) ~~ + ("ownerGroupId" -> "updated-admin-group-id") ~~ + ("recordSetGroupChange" -> Some(("ownerShipTransferStatus" -> "ManuallyApproved")~~ + ("requestedOwnerGroupId" -> "updated-admin-group-id"))) + + + val expected = RecordSet( + "1", + "TestRecordName", + RecordType.CNAME, + 1000, + RecordSetStatus.Pending, + LocalDateTime.of(2010, Month.JANUARY, 1, 0, 0).toInstant(ZoneOffset.UTC), + records = List(CNAMEData(Fqdn("cname.data."))), + ownerGroupId = Some("updated-admin-group-id"), + recordSetGroupChange = Some(OwnerShipTransfer(ownerShipTransferStatus = OwnerShipTransferStatus.ManuallyApproved,requestedOwnerGroupId = Some("updated-admin-group-id"))) + + ) + + val actual = recordSetJValue.extract[RecordSet] + anonymize(actual) shouldBe anonymize(expected) + anonymize(actual).recordSetGroupChange.get.ownerShipTransferStatus shouldBe OwnerShipTransferStatus.ManuallyApproved + anonymize(actual).recordSetGroupChange.get.requestedOwnerGroupId shouldBe Some("updated-admin-group-id") + anonymize(actual).ownerGroupId shouldBe Some("updated-admin-group-id") + } + + "request a owner ship transfer" in { + val recordSetJValue: JValue = + ("zoneId" -> "1") ~~ + ("name" -> "TestRecordName") ~~ + ("type" -> "CNAME") ~~ + ("ttl" -> 1000) ~~ + ("status" -> "Pending") ~~ + ("records" -> List("cname" -> "cname.data ")) ~~ + ("ownerGroupId" -> "updated-ok-group-id") ~~ + ("recordSetGroupChange" -> Some(("ownerShipTransferStatus" -> "Requested")~~ + ("requestedOwnerGroupId" -> "updated-admin-group-id"))) + + + val expected = RecordSet( + "1", + "TestRecordName", + RecordType.CNAME, + 1000, + RecordSetStatus.Pending, + LocalDateTime.of(2010, Month.JANUARY, 1, 0, 0).toInstant(ZoneOffset.UTC), + records = List(CNAMEData(Fqdn("cname.data."))), + ownerGroupId = Some("updated-ok-group-id"), + recordSetGroupChange = Some(OwnerShipTransfer( + ownerShipTransferStatus = OwnerShipTransferStatus.Requested, + requestedOwnerGroupId = Some("updated-admin-group-id"))) + + ) + + val actual = recordSetJValue.extract[RecordSet] + anonymize(actual) shouldBe anonymize(expected) + anonymize(actual).recordSetGroupChange.get.ownerShipTransferStatus shouldBe OwnerShipTransferStatus.Requested + anonymize(actual).recordSetGroupChange.get.requestedOwnerGroupId shouldBe Some("updated-admin-group-id") + anonymize(actual).ownerGroupId shouldBe Some("updated-ok-group-id") + } + + "request a owner ship transfer which will change to pending review" in { + val recordSetJValue: JValue = + ("zoneId" -> "1") ~~ + ("name" -> "TestRecordName") ~~ + ("type" -> "CNAME") ~~ + ("ttl" -> 1000) ~~ + ("status" -> "Pending") ~~ + ("records" -> List("cname" -> "cname.data ")) ~~ + ("ownerGroupId" -> "updated-ok-group-id") ~~ + ("recordSetGroupChange" -> Some(("ownerShipTransferStatus" -> "PendingReview")~~ + ("requestedOwnerGroupId" -> "updated-admin-group-id"))) + + + val expected = RecordSet( + "1", + "TestRecordName", + RecordType.CNAME, + 1000, + RecordSetStatus.Pending, + LocalDateTime.of(2010, Month.JANUARY, 1, 0, 0).toInstant(ZoneOffset.UTC), + records = List(CNAMEData(Fqdn("cname.data."))), + ownerGroupId = Some("updated-ok-group-id"), + recordSetGroupChange = Some(OwnerShipTransfer( + ownerShipTransferStatus = OwnerShipTransferStatus.PendingReview, + requestedOwnerGroupId = Some("updated-admin-group-id"))) + + ) + + val actual = recordSetJValue.extract[RecordSet] + anonymize(actual) shouldBe anonymize(expected) + anonymize(actual).recordSetGroupChange.get.ownerShipTransferStatus shouldBe OwnerShipTransferStatus.PendingReview + anonymize(actual).recordSetGroupChange.get.requestedOwnerGroupId shouldBe Some("updated-admin-group-id") + anonymize(actual).ownerGroupId shouldBe Some("updated-ok-group-id") + } + + "cancel a owner ship transfer request" in { + val recordSetJValue: JValue = + ("zoneId" -> "1") ~~ + ("name" -> "TestRecordName") ~~ + ("type" -> "CNAME") ~~ + ("ttl" -> 1000) ~~ + ("status" -> "Pending") ~~ + ("records" -> List("cname" -> "cname.data ")) ~~ + ("ownerGroupId" -> "updated-ok-group-id") ~~ + ("recordSetGroupChange" -> Some(("ownerShipTransferStatus" -> "Cancelled")~~ + ("requestedOwnerGroupId" -> "updated-admin-group-id"))) + + + val expected = RecordSet( + "1", + "TestRecordName", + RecordType.CNAME, + 1000, + RecordSetStatus.Pending, + LocalDateTime.of(2010, Month.JANUARY, 1, 0, 0).toInstant(ZoneOffset.UTC), + records = List(CNAMEData(Fqdn("cname.data."))), + ownerGroupId = Some("updated-ok-group-id"), + recordSetGroupChange = Some(OwnerShipTransfer( + ownerShipTransferStatus = OwnerShipTransferStatus.Cancelled, + requestedOwnerGroupId = Some("updated-admin-group-id"))) + + ) + + val actual = recordSetJValue.extract[RecordSet] + anonymize(actual) shouldBe anonymize(expected) + anonymize(actual).recordSetGroupChange.get.ownerShipTransferStatus shouldBe OwnerShipTransferStatus.Cancelled + anonymize(actual).recordSetGroupChange.get.requestedOwnerGroupId shouldBe Some("updated-admin-group-id") + anonymize(actual).ownerGroupId shouldBe Some("updated-ok-group-id") + } + + "manually-reject a owner ship transfer request" in { + val recordSetJValue: JValue = + ("zoneId" -> "1") ~~ + ("name" -> "TestRecordName") ~~ + ("type" -> "CNAME") ~~ + ("ttl" -> 1000) ~~ + ("status" -> "Pending") ~~ + ("records" -> List("cname" -> "cname.data ")) ~~ + ("ownerGroupId" -> "updated-ok-group-id") ~~ + ("recordSetGroupChange" -> Some(("ownerShipTransferStatus" -> "ManuallyRejected")~~ + ("requestedOwnerGroupId" -> "updated-admin-group-id"))) + + + val expected = RecordSet( + "1", + "TestRecordName", + RecordType.CNAME, + 1000, + RecordSetStatus.Pending, + LocalDateTime.of(2010, Month.JANUARY, 1, 0, 0).toInstant(ZoneOffset.UTC), + records = List(CNAMEData(Fqdn("cname.data."))), + ownerGroupId = Some("updated-ok-group-id"), + recordSetGroupChange = Some(OwnerShipTransfer( + ownerShipTransferStatus = OwnerShipTransferStatus.ManuallyRejected, + requestedOwnerGroupId = Some("updated-admin-group-id"))) + + ) + + val actual = recordSetJValue.extract[RecordSet] + anonymize(actual) shouldBe anonymize(expected) + anonymize(actual).recordSetGroupChange.get.ownerShipTransferStatus shouldBe OwnerShipTransferStatus.ManuallyRejected + anonymize(actual).recordSetGroupChange.get.requestedOwnerGroupId shouldBe Some("updated-admin-group-id") + anonymize(actual).ownerGroupId shouldBe Some("updated-ok-group-id") + } } } diff --git a/modules/core/src/main/protobuf/VinylDNSProto.proto b/modules/core/src/main/protobuf/VinylDNSProto.proto index 94883d599..4d710aa24 100644 --- a/modules/core/src/main/protobuf/VinylDNSProto.proto +++ b/modules/core/src/main/protobuf/VinylDNSProto.proto @@ -141,7 +141,13 @@ message RecordSet { repeated RecordData record = 9; required string account = 10; optional string ownerGroupId = 11; - optional string fqdn = 12; + optional ownerShipTransfer recordSetGroupChange = 12; + optional string fqdn = 13; +} + +message ownerShipTransfer { + optional string requestedOwnerGroupId = 1; + optional string ownerShipTransferStatus = 2; } message RecordSetChange { diff --git a/modules/core/src/main/scala/vinyldns/core/domain/record/RecordSet.scala b/modules/core/src/main/scala/vinyldns/core/domain/record/RecordSet.scala index da227dbca..6cdc2482c 100644 --- a/modules/core/src/main/scala/vinyldns/core/domain/record/RecordSet.scala +++ b/modules/core/src/main/scala/vinyldns/core/domain/record/RecordSet.scala @@ -16,6 +16,8 @@ package vinyldns.core.domain.record +import vinyldns.core.domain.record.OwnerShipTransferStatus.OwnerShipTransferStatus + import java.util.UUID import java.time.Instant @@ -33,6 +35,11 @@ object RecordSetStatus extends Enumeration { val Active, Inactive, Pending, PendingUpdate, PendingDelete = Value } +object OwnerShipTransferStatus extends Enumeration { + type OwnerShipTransferStatus = Value + val AutoApproved, Cancelled, ManuallyApproved, ManuallyRejected, Requested, PendingReview, None = Value +} + import RecordSetStatus._ import RecordType._ @@ -48,6 +55,7 @@ case class RecordSet( id: String = UUID.randomUUID().toString, account: String = "system", ownerGroupId: Option[String] = None, + recordSetGroupChange: Option[OwnerShipTransfer] = None, fqdn: Option[String] = None ) { @@ -69,10 +77,25 @@ case class RecordSet( sb.append("account=\"").append(account).append("\"; ") sb.append("status=\"").append(status.toString).append("\"; ") sb.append("records=\"").append(records.toString).append("\"; ") - sb.append("ownerGroupId=\"").append(ownerGroupId).append("\"") + sb.append("ownerGroupId=\"").append(ownerGroupId).append("\"; ") + sb.append("recordSetGroupChange=\"").append(recordSetGroupChange.toString).append("\"; ") sb.append("fqdn=\"").append(fqdn).append("\"") sb.append("]") sb.toString } } +case class OwnerShipTransfer( + ownerShipTransferStatus: OwnerShipTransferStatus, + requestedOwnerGroupId: Option[String] = None + ){ + + override def toString: String = { + val sb = new StringBuilder + sb.append("OwnerShipTransfer: [") + sb.append("ownerShipTransferStatus=\"").append(ownerShipTransferStatus.toString).append("\"; ") + sb.append("requestedOwnerGroupId=\"").append(requestedOwnerGroupId.toString).append("\"") + sb.append("]") + sb.toString + }} + diff --git a/modules/core/src/main/scala/vinyldns/core/notifier/NotifierLoader.scala b/modules/core/src/main/scala/vinyldns/core/notifier/NotifierLoader.scala index 78a65cc4a..49f69a321 100644 --- a/modules/core/src/main/scala/vinyldns/core/notifier/NotifierLoader.scala +++ b/modules/core/src/main/scala/vinyldns/core/notifier/NotifierLoader.scala @@ -15,21 +15,21 @@ */ package vinyldns.core.notifier -import vinyldns.core.domain.membership.UserRepository +import vinyldns.core.domain.membership.{GroupRepository, UserRepository} import cats.effect.IO import cats.implicits._ import cats.effect.ContextShift object NotifierLoader { - def loadAll(configs: List[NotifierConfig], userRepository: UserRepository)( - implicit cs: ContextShift[IO] + def loadAll(configs: List[NotifierConfig], userRepository: UserRepository, groupRepository: GroupRepository)( + implicit cs: ContextShift[IO] ): IO[AllNotifiers] = for { - notifiers <- configs.parTraverse(load(_, userRepository)) + notifiers <- configs.parTraverse(load(_, userRepository, groupRepository)) } yield AllNotifiers(notifiers) - def load(config: NotifierConfig, userRepository: UserRepository): IO[Notifier] = + def load(config: NotifierConfig, userRepository: UserRepository, groupRepository: GroupRepository): IO[Notifier] = for { provider <- IO( Class @@ -38,7 +38,7 @@ object NotifierLoader { .newInstance() .asInstanceOf[NotifierProvider] ) - notifier <- provider.load(config, userRepository) + notifier <- provider.load(config, userRepository, groupRepository) } yield notifier } diff --git a/modules/core/src/main/scala/vinyldns/core/notifier/NotifierProvider.scala b/modules/core/src/main/scala/vinyldns/core/notifier/NotifierProvider.scala index c5fe76a98..71d2ba0e0 100644 --- a/modules/core/src/main/scala/vinyldns/core/notifier/NotifierProvider.scala +++ b/modules/core/src/main/scala/vinyldns/core/notifier/NotifierProvider.scala @@ -15,9 +15,9 @@ */ package vinyldns.core.notifier -import vinyldns.core.domain.membership.UserRepository +import vinyldns.core.domain.membership.{GroupRepository, UserRepository} import cats.effect.IO trait NotifierProvider { - def load(config: NotifierConfig, userRepository: UserRepository): IO[Notifier] + def load(config: NotifierConfig, userRepository: UserRepository, groupRepository: GroupRepository): IO[Notifier] } diff --git a/modules/core/src/main/scala/vinyldns/core/protobuf/ProtobufConversions.scala b/modules/core/src/main/scala/vinyldns/core/protobuf/ProtobufConversions.scala index 3ff0f76ec..2e0a852c9 100644 --- a/modules/core/src/main/scala/vinyldns/core/protobuf/ProtobufConversions.scala +++ b/modules/core/src/main/scala/vinyldns/core/protobuf/ProtobufConversions.scala @@ -100,9 +100,23 @@ trait ProtobufConversions { records = rs.getRecordList.asScala.map(rd => fromPB(rd, RecordType.withName(rs.getTyp))).toList, account = rs.getAccount, - ownerGroupId = if (rs.hasOwnerGroupId) Some(rs.getOwnerGroupId) else None + ownerGroupId = if (rs.hasOwnerGroupId) Some(rs.getOwnerGroupId) else None , + recordSetGroupChange = if (rs.hasRecordSetGroupChange) Some(fromPB(rs.getRecordSetGroupChange)) else None, ) + def fromPB(rsa: VinylDNSProto.ownerShipTransfer): OwnerShipTransfer = + record.OwnerShipTransfer( + ownerShipTransferStatus = OwnerShipTransferStatus.withName(rsa.getOwnerShipTransferStatus), + requestedOwnerGroupId = if (rsa.hasRequestedOwnerGroupId) Some(rsa.getRequestedOwnerGroupId) else None) + + def toPB(rsa: OwnerShipTransfer): VinylDNSProto.ownerShipTransfer = { + val builder = VinylDNSProto.ownerShipTransfer + .newBuilder() + .setOwnerShipTransferStatus(rsa.ownerShipTransferStatus.toString) + rsa.requestedOwnerGroupId.foreach(id => builder.setRequestedOwnerGroupId(id)) + builder.build() + } + def fromPB(zn: VinylDNSProto.Zone): Zone = { val pbStatus = zn.getStatus val status = @@ -377,6 +391,7 @@ trait ProtobufConversions { rs.updated.foreach(dt => builder.setUpdated(dt.toEpochMilli)) rs.ownerGroupId.foreach(id => builder.setOwnerGroupId(id)) + rs.recordSetGroupChange.foreach(rsg => builder.setRecordSetGroupChange(toPB(rsg))) // Map the records, first map to bytes, and then map the bytes to a record data instance rs.records.map(toRecordData).foreach(rd => builder.addRecord(rd)) diff --git a/modules/core/src/test/scala/vinyldns/core/TestRecordSetData.scala b/modules/core/src/test/scala/vinyldns/core/TestRecordSetData.scala index 1c74f0b86..2b2d4f2b0 100644 --- a/modules/core/src/test/scala/vinyldns/core/TestRecordSetData.scala +++ b/modules/core/src/test/scala/vinyldns/core/TestRecordSetData.scala @@ -37,7 +37,8 @@ object TestRecordSetData { RecordSetStatus.Active, Instant.now.truncatedTo(ChronoUnit.MILLIS), None, - List(AData("10.1.1.1")) + List(AData("10.1.1.1")), + recordSetGroupChange = None ) val abcRecord: RecordSet = RecordSet( @@ -48,7 +49,8 @@ object TestRecordSetData { RecordSetStatus.Pending, Instant.now.truncatedTo(ChronoUnit.MILLIS), None, - List(AAAAData("1:2:3:4:5:6:7:8")) + List(AAAAData("1:2:3:4:5:6:7:8")), + recordSetGroupChange= None ) val aaaa: RecordSet = RecordSet( @@ -59,7 +61,8 @@ object TestRecordSetData { RecordSetStatus.Pending, Instant.now.truncatedTo(ChronoUnit.MILLIS), None, - List(AAAAData("1:2:3:4:5:6:7:8")) + List(AAAAData("1:2:3:4:5:6:7:8")), + recordSetGroupChange= None ) val aaaaOrigin: RecordSet = RecordSet( @@ -70,7 +73,8 @@ object TestRecordSetData { RecordSetStatus.Pending, Instant.now.truncatedTo(ChronoUnit.MILLIS), None, - List(AAAAData("1:2:3:4:5:6:7:8")) + List(AAAAData("1:2:3:4:5:6:7:8")), + recordSetGroupChange= None ) val cname: RecordSet = RecordSet( @@ -81,7 +85,8 @@ object TestRecordSetData { RecordSetStatus.Pending, Instant.now.truncatedTo(ChronoUnit.MILLIS), None, - List(CNAMEData(Fqdn("cname"))) + List(CNAMEData(Fqdn("cname"))), + recordSetGroupChange= None ) val ptrIp4: RecordSet = RecordSet( @@ -92,7 +97,8 @@ object TestRecordSetData { RecordSetStatus.Active, Instant.now.truncatedTo(ChronoUnit.MILLIS), None, - List(PTRData(Fqdn("ptr"))) + List(PTRData(Fqdn("ptr"))), + recordSetGroupChange= None ) val ptrIp6: RecordSet = RecordSet( @@ -103,7 +109,8 @@ object TestRecordSetData { RecordSetStatus.Active, Instant.now.truncatedTo(ChronoUnit.MILLIS), None, - List(PTRData(Fqdn("ptr"))) + List(PTRData(Fqdn("ptr"))), + recordSetGroupChange= None ) val srv: RecordSet = RecordSet( @@ -114,7 +121,8 @@ object TestRecordSetData { RecordSetStatus.Active, Instant.now.truncatedTo(ChronoUnit.MILLIS), None, - List(SRVData(1, 2, 3, Fqdn("target"))) + List(SRVData(1, 2, 3, Fqdn("target"))), + recordSetGroupChange= None ) val naptr: RecordSet = RecordSet( @@ -125,7 +133,8 @@ object TestRecordSetData { RecordSetStatus.Active, Instant.now.truncatedTo(ChronoUnit.MILLIS), None, - List(NAPTRData(1, 2, "S", "E2U+sip", "", Fqdn("target"))) + List(NAPTRData(1, 2, "S", "E2U+sip", "", Fqdn("target"))), + recordSetGroupChange= None ) val mx: RecordSet = RecordSet( @@ -136,7 +145,8 @@ object TestRecordSetData { RecordSetStatus.Pending, Instant.now.truncatedTo(ChronoUnit.MILLIS), None, - List(MXData(3, Fqdn("mx"))) + List(MXData(3, Fqdn("mx"))), + recordSetGroupChange= None ) val ns: RecordSet = RecordSet( @@ -147,7 +157,8 @@ object TestRecordSetData { RecordSetStatus.Active, Instant.now.truncatedTo(ChronoUnit.MILLIS), None, - records = List(NSData(Fqdn("ns1.test.com")), NSData(Fqdn("ns2.test.com"))) + records = List(NSData(Fqdn("ns1.test.com")), NSData(Fqdn("ns2.test.com"))), + recordSetGroupChange= None ) val txt: RecordSet = RecordSet( @@ -158,7 +169,8 @@ object TestRecordSetData { RecordSetStatus.Pending, Instant.now.truncatedTo(ChronoUnit.MILLIS), None, - List(TXTData("txt")) + List(TXTData("txt")), + recordSetGroupChange= None ) // example at https://tools.ietf.org/html/rfc4034#page-18 @@ -198,7 +210,12 @@ object TestRecordSetData { Instant.now.truncatedTo(ChronoUnit.MILLIS), None, List(AAAAData("1:2:3:4:5:6:7:8")), - ownerGroupId = Some(okGroup.id) + ownerGroupId = Some(okGroup.id), + recordSetGroupChange= None + ) + + val ownerShipTransfer: OwnerShipTransfer = OwnerShipTransfer( + OwnerShipTransferStatus.None ) val sharedZoneRecordNoOwnerGroup: RecordSet = diff --git a/modules/core/src/test/scala/vinyldns/core/notifier/NotifierLoaderSpec.scala b/modules/core/src/test/scala/vinyldns/core/notifier/NotifierLoaderSpec.scala index 849d46948..daa151044 100644 --- a/modules/core/src/test/scala/vinyldns/core/notifier/NotifierLoaderSpec.scala +++ b/modules/core/src/test/scala/vinyldns/core/notifier/NotifierLoaderSpec.scala @@ -19,7 +19,7 @@ package vinyldns.core.notifier import cats.scalatest.{EitherMatchers, EitherValues, ValidatedMatchers} import com.typesafe.config.{Config, ConfigFactory} import org.scalatestplus.mockito.MockitoSugar -import vinyldns.core.domain.membership.UserRepository +import vinyldns.core.domain.membership.{GroupRepository, UserRepository} import cats.effect.IO import org.mockito.Mockito._ @@ -37,13 +37,13 @@ object MockNotifierProvider extends MockitoSugar { class MockNotifierProvider extends NotifierProvider { - def load(config: NotifierConfig, userRepo: UserRepository): IO[Notifier] = + def load(config: NotifierConfig, userRepo: UserRepository, groupRepo: GroupRepository): IO[Notifier] = IO.pure(MockNotifierProvider.mockNotifier) } class FailingProvider extends NotifierProvider { - def load(config: NotifierConfig, userRepo: UserRepository): IO[Notifier] = + def load(config: NotifierConfig, userRepo: UserRepository, groupRepo: GroupRepository): IO[Notifier] = IO.raiseError(new IllegalStateException("always failing")) } @@ -63,6 +63,7 @@ class NotifierLoaderSpec val goodConfig = NotifierConfig("vinyldns.core.notifier.MockNotifierProvider", placeholderConfig) val mockUserRepository: UserRepository = mock[UserRepository] + val mockGroupRepository: GroupRepository = mock[GroupRepository] import MockNotifierProvider._ @@ -73,14 +74,13 @@ class NotifierLoaderSpec "return some notifier with no configs" in { - val notifier = NotifierLoader.loadAll(List.empty, mockUserRepository).unsafeRunSync() - + val notifier = NotifierLoader.loadAll(List.empty, mockUserRepository, mockGroupRepository).unsafeRunSync() notifier shouldBe a[AllNotifiers] notifier.notify(Notification(3)).unsafeRunSync() shouldBe (()) } "return a notifier for valid config of one notifier" in { - val notifier = NotifierLoader.loadAll(List(goodConfig), mockUserRepository).unsafeRunSync() + val notifier = NotifierLoader.loadAll(List(goodConfig), mockUserRepository, mockGroupRepository).unsafeRunSync() notifier shouldNot be(null) @@ -95,7 +95,7 @@ class NotifierLoaderSpec "return a notifier for valid config of multiple notifiers" in { val notifier = - NotifierLoader.loadAll(List(goodConfig, goodConfig), mockUserRepository).unsafeRunSync() + NotifierLoader.loadAll(List(goodConfig, goodConfig), mockUserRepository, mockGroupRepository).unsafeRunSync() notifier shouldNot be(null) @@ -113,7 +113,7 @@ class NotifierLoaderSpec val badProvider = NotifierConfig("vinyldns.core.notifier.NotFoundNotifierProvider", placeholderConfig) - val load = NotifierLoader.loadAll(List(goodConfig, badProvider), mockUserRepository) + val load = NotifierLoader.loadAll(List(goodConfig, badProvider), mockUserRepository, mockGroupRepository) a[ClassNotFoundException] shouldBe thrownBy(load.unsafeRunSync()) } @@ -123,7 +123,7 @@ class NotifierLoaderSpec val exceptionProvider = NotifierConfig("vinyldns.core.notifier.FailingProvider", placeholderConfig) - val load = NotifierLoader.loadAll(List(goodConfig, exceptionProvider), mockUserRepository) + val load = NotifierLoader.loadAll(List(goodConfig, exceptionProvider), mockUserRepository, mockGroupRepository) a[IllegalStateException] shouldBe thrownBy(load.unsafeRunSync()) } diff --git a/modules/portal/app/views/zones/zoneTabs/manageRecords.scala.html b/modules/portal/app/views/zones/zoneTabs/manageRecords.scala.html index 55f186476..3da87bbda 100644 --- a/modules/portal/app/views/zones/zoneTabs/manageRecords.scala.html +++ b/modules/portal/app/views/zones/zoneTabs/manageRecords.scala.html @@ -133,6 +133,7 @@ Record Data @if(meta.sharedDisplayEnabled) { Owner Group Name + OwnerShip Transfer Status } Actions @@ -148,8 +149,8 @@ {{record.name}} - {{record.type}} - {{record.ttl}} + {{record.type}} + {{record.ttl}} @@ -336,19 +337,42 @@ @if(meta.sharedDisplayEnabled) { - - Unowned + Unowned Group deleted + + Approved + Rejected + + Cancelled + Pending Action + }
+ + +
+ + + + + + + + diff --git a/modules/portal/public/lib/controllers/controller.records.js b/modules/portal/public/lib/controllers/controller.records.js index ec9959024..9b2c3121a 100644 --- a/modules/portal/public/lib/controllers/controller.records.js +++ b/modules/portal/public/lib/controllers/controller.records.js @@ -31,6 +31,7 @@ angular.module('controller.records', []) $scope.alerts = []; $scope.recordTypes = ['A', 'AAAA', 'CNAME', 'DS', 'MX', 'NS', 'PTR', 'SRV', 'NAPTR', 'SSHFP', 'TXT']; + $scope.ownerShipTransferStatus = ['AutoApproved', 'Cancelled', 'ManuallyApproved', 'ManuallyRejected', 'Requested', 'PendingReview']; $scope.readRecordTypes = ['A', 'AAAA', 'CNAME', 'DS', 'MX', 'NS', 'PTR', "SOA", 'SRV', 'NAPTR', 'SSHFP', 'TXT']; $scope.selectedRecordTypes = []; $scope.naptrFlags = ["U", "S", "A", "P"]; @@ -46,6 +47,7 @@ angular.module('controller.records', []) {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.records = {}; + $scope.isOwnerShipRequest = true; $scope.recordsetChangesPreview = {}; $scope.recordsetChanges = {}; $scope.currentRecord = {}; @@ -57,6 +59,12 @@ angular.module('controller.records', []) var loadZonesPromise; var loadRecordsPromise; + $scope.ownerShipTransferApproverStatus = [{value: 'ManuallyApproved' , label: 'Approve'}, + {value: 'ManuallyRejected', label: 'Reject'}]; + + $scope.ownerShipTransferRequestorStatus = [{value: 'Requested', label: 'Request'}, + {value: 'Cancelled', label: 'Cancel'}]; + $scope.recordModalState = { CREATE: 0, UPDATE: 1, @@ -83,6 +91,7 @@ angular.module('controller.records', []) $scope.isZoneAdmin = false; $scope.canReadZone = false; $scope.canCreateRecords = false; + $scope.isCurrentRecordSetOwner = false; // paging status for recordsets var recordsPaging = pagingService.getNewPagingParams(100); @@ -94,6 +103,43 @@ angular.module('controller.records', []) * Modal control functions */ + $scope.recordSetGroupOwnerShipStatus = function recordSetGroupOwnerShipStatus(groupId, profileId, record) { + function success(response) { + var ownerShipTransferStatus; + if (response.data.members.some(x => x.id === profileId)){ + ownerShipTransferStatus = $scope.ownerShipTransferApproverStatus; + $scope.currentOwnerShipTransferApprover= true; + record.isCurrentRecordSetOwner = true;} + else{ownerShipTransferStatus = $scope.ownerShipTransferRequestorStatus; + $scope.currentOwnerShipTransferApprover= false; + record.isCurrentRecordSetOwner= false;} + $scope.ownerShipTransferStatus = ownerShipTransferStatus + } + return groupsService + .getGroupMemberList(groupId) + .then(success) + .catch(function (error) { + handleError(error, 'groupsService::getGroupMemberList-failure'); + }); + }; + + function getGroup(groupId) { + if (groupId != undefined && groupId != "null"){ + $log.log('groupsService::getGroup-success'); + function success(response) { + $scope.recordSetRequestedOwnerShipName = response.data.name; + + } + return groupsService + .getGroup(groupId) + .then(success) + .catch(function (error) { + handleError(error, 'groupsService::getGroup-failure'); + }); + } + else {$scope.recordSetRequestedOwnerShipName = "None";} + }; + $scope.deleteRecord = function(record) { $scope.currentRecord = angular.copy(record); $scope.recordModal = { @@ -132,6 +178,8 @@ angular.module('controller.records', []) $scope.editRecord = function(record) { $scope.currentRecord = angular.copy(record); + $scope.currentRecord.recordSetGroupChange = angular.copy(record.recordSetGroupChange); + getGroup($scope.currentRecord.recordSetGroupChange.requestedOwnerGroupId); $scope.recordModal = { previous: angular.copy(record), action: $scope.recordModalState.UPDATE, @@ -145,6 +193,81 @@ angular.module('controller.records', []) $("#record_modal").modal("show"); }; + $scope.requestOwnerShip = function(record) { + $scope.currentRecord = angular.copy(record); + $scope.currentRecord.recordSetGroupChange.ownerShipTransferStatus = angular.copy("AutoApproved") + $scope.recordModal = { + action: $scope.recordModalState.UPDATE, + title: "Request OwnerShip transfer", + basics: $scope.recordModalParams.readOnly, + details: $scope.recordModalParams.editable, + sharedZone: $scope.zoneInfo.shared, + sharedDisplayEnabled: $scope.sharedDisplayEnabled, + isCurrentRecordOwnerGroup : false + }; + $scope.recordOwnerShipForm.$setPristine(); + $("#record_modal_ownership").modal("show"); + }; + + $scope.requestOwnerShipTransfer = function(record, isOwnerShipRequest) { + $scope.currentRecord = angular.copy(record); + $scope.currentRecord.recordSetGroupChange = angular.copy(record.recordSetGroupChange); + $scope.recordModal = { + previous: angular.copy(record), + action: $scope.recordModalState.UPDATE, + title: "Request OwnerShip transfer", + basics: $scope.recordModalParams.readOnly, + details: $scope.recordModalParams.editable, + sharedZone: $scope.zoneInfo.shared, + sharedDisplayEnabled: $scope.sharedDisplayEnabled, + isCurrentRecordOwnerGroup : false + }; + + var currentRecordOwnerGroupId = $scope.currentRecord.ownerGroupId; + if (isOwnerShipRequest && $scope.currentRecord.recordSetGroupChange.ownerShipTransferStatus != "PendingReview") { + $scope.currentRecord.recordSetGroupChange.requestedOwnerGroupId = angular.copy(null); + $scope.currentRecord.recordSetGroupChange.ownerShipTransferStatus = angular.copy(null); + }else ($scope.currentRecord.recordSetGroupChange.ownerShipTransferStatus = angular.copy(null)) + getGroup($scope.currentRecord.recordSetGroupChange.requestedOwnerGroupId); + $scope.ownerShipTransferApprover = false; + $scope.ownerShipTransferRequestor = false; + + if (currentRecordOwnerGroupId != undefined){$scope.recordModal.isCurrentRecordOwnerGroup = true + }else{ $scope.recordModal.isCurrentRecordOwnerGroup = false } + + if ($scope.zoneInfo.shared == true && $scope.recordModal.isCurrentRecordOwnerGroup){ + $scope.recordSetGroupOwnerShipStatus(currentRecordOwnerGroupId, $scope.profile.id, record); + $scope.ownerShipTransferApproverStatus.forEach(function(ownerShipTransferApproverStatus, index) { + if (ownerShipTransferApproverStatus.value.indexOf($scope.currentRecord.recordSetGroupChange.ownerShipTransferStatus) > -1) + {$scope.ownerShipTransferApprover = true}else{$scope.ownerShipTransferRequestor = true}}) + } + $scope.recordOwnerShipForm.$setPristine(); + $("#record_modal_ownership_transfer").modal("show"); + }; + + $scope.requestedOwnerShip = function() { + + var record = angular.copy($scope.currentRecord); + record['onlyFour'] = true; + if ($scope.recordOwnerShipForm.$valid) { + updateRecordSet(record); + $scope.recordOwnerShipForm.$setPristine(); + $("#record_modal_ownership").modal('hide'); + } + }; + + $scope.submitRequestedOwnerShipTransfer = function () { + var record = angular.copy($scope.currentRecord); + record['onlyFour'] = true; + var invalidRecordOwnerShipForm = $scope.recordOwnerShipForm.ownerGroupStatus.$viewValue != null && + $scope.recordOwnerShipForm.ownerGroupStatus.$viewValue + if ($scope.recordOwnerShipForm.$valid && invalidRecordOwnerShipForm) { + updateRecordSet(record); + $scope.recordOwnerShipForm.$setPristine(); + $("#record_modal_ownership_transfer").modal('hide'); + } + }; + $scope.confirmUpdate = function() { $scope.recordModal.action = $scope.recordModalState.CONFIRM_UPDATE; $scope.recordModal.details = $scope.recordModalParams.readOnly; @@ -190,11 +313,15 @@ angular.module('controller.records', []) $scope.submitUpdateRecord = function () { var record = angular.copy($scope.currentRecord); + if(record.recordSetGroupChange.requestedOwnerGroupId != undefined){ + if (record.ownerGroupId != $scope.recordModal.previous.ownerGroupId && $scope.isZoneAdmin){ + record.recordSetGroupChange.requestedOwnerGroupId = angular.copy(record.ownerGroupId); + record.recordSetGroupChange.ownerShipTransferStatus = angular.copy("ManuallyApproved"); + } + } record['onlyFour'] = true; - if ($scope.addRecordForm.$valid) { updateRecordSet(record); - $scope.addRecordForm.$setPristine(); $("#record_modal").modal('hide'); } @@ -496,6 +623,11 @@ angular.module('controller.records', []) angular.forEach(records, function(record) { newRecords.push(recordsService.toDisplayRecord(record, $scope.zoneInfo.name)); }); + angular.forEach(newRecords, function(record) { + if(record.ownerGroupId != undefined) { + $scope.recordSetGroupOwnerShipStatus(record.ownerGroupId, $scope.profile.id, record); + }else {record.isCurrentRecordSetOwner= null;} + }); $scope.records = newRecords; $scope.getRecordSetCount(); if($scope.records.length > 0) { diff --git a/modules/portal/public/lib/services/records/service.records.js b/modules/portal/public/lib/services/records/service.records.js index 8c8a17f75..f5b5be1c3 100644 --- a/modules/portal/public/lib/services/records/service.records.js +++ b/modules/portal/public/lib/services/records/service.records.js @@ -280,7 +280,10 @@ angular.module('service.records', []) "id": record.id, "name": record.name, "type": record.type, - "ttl": Number(record.ttl) + "ttl": Number(record.ttl), + "isCurrentRecordSetOwner": record.isCurrentRecordSetOwner, + "recordSetGroupChange": record.recordSetGroupChange + }; switch (record.type) { case 'A': diff --git a/modules/portal/public/lib/services/records/service.records.spec.js b/modules/portal/public/lib/services/records/service.records.spec.js index 01744f757..36c9e95cc 100644 --- a/modules/portal/public/lib/services/records/service.records.spec.js +++ b/modules/portal/public/lib/services/records/service.records.spec.js @@ -111,7 +111,9 @@ describe('Service: recordsService', function () { "type": 'SSHFP', "ttl": '300', "sshfpItems": [{algorithm: '1', type: '1', fingerprint: '123456789ABCDEF67890123456789ABCDEF67890'}, - {algorithm: '2', type: '1', fingerprint: '123456789ABCDEF67890123456789ABCDEF67890'}] + {algorithm: '2', type: '1', fingerprint: '123456789ABCDEF67890123456789ABCDEF67890'}], + "recordSetGroupChange": 'None', + "isCurrentRecordSetOwner": 'null' }; expectedRecord = { "id": 'recordId', @@ -119,7 +121,9 @@ describe('Service: recordsService', function () { "type": 'SSHFP', "ttl": 300, "records": [{algorithm: 1, type: 1, fingerprint: '123456789ABCDEF67890123456789ABCDEF67890'}, - {algorithm: 2, type: 1, fingerprint: '123456789ABCDEF67890123456789ABCDEF67890'}] + {algorithm: 2, type: 1, fingerprint: '123456789ABCDEF67890123456789ABCDEF67890'}], + "recordSetGroupChange": 'None', + "isCurrentRecordSetOwner": 'null' }; var actualRecord = this.recordsService.toVinylRecord(sentRecord); @@ -133,7 +137,8 @@ describe('Service: recordsService', function () { "type": 'SSHFP', "ttl": 300, "records": [{algorithm: 1, type: 1, fingerprint: '123456789ABCDEF67890123456789ABCDEF67890'}, - {algorithm: 2, type: 1, fingerprint: 'F23456789ABCDEF67890123456789ABCDEF67890'}] + {algorithm: 2, type: 1, fingerprint: 'F23456789ABCDEF67890123456789ABCDEF67890'}], + "recordSetGroupChange": 'None' }; displayRecord = { @@ -144,6 +149,7 @@ describe('Service: recordsService', function () { "records": undefined, "sshfpItems": [{algorithm: 1, type: 1, fingerprint: '123456789ABCDEF67890123456789ABCDEF67890'}, {algorithm: 2, type: 1, fingerprint: 'F23456789ABCDEF67890123456789ABCDEF67890'}], + "recordSetGroupChange": 'None', "onlyFour": true, "isDotted": false, "canBeEdited": true @@ -160,7 +166,8 @@ describe('Service: recordsService', function () { "type": 'SSHFP', "ttl": 300, "records": [{algorithm: 1, type: 1, fingerprint: '123456789ABCDEF67890123456789ABCDEF67890'}, - {algorithm: 2, type: 1, fingerprint: 'F23456789ABCDEF67890123456789ABCDEF67890'}] + {algorithm: 2, type: 1, fingerprint: 'F23456789ABCDEF67890123456789ABCDEF67890'}], + "recordSetGroupChange": 'None' }; displayRecord = { @@ -171,6 +178,7 @@ describe('Service: recordsService', function () { "records": undefined, "sshfpItems": [{algorithm: 1, type: 1, fingerprint: '123456789ABCDEF67890123456789ABCDEF67890'}, {algorithm: 2, type: 1, fingerprint: 'F23456789ABCDEF67890123456789ABCDEF67890'}], + "recordSetGroupChange": 'None', "onlyFour": true, "isDotted": true, "canBeEdited": true @@ -187,7 +195,8 @@ describe('Service: recordsService', function () { "type": 'SSHFP', "ttl": 300, "records": [{algorithm: 1, type: 1, fingerprint: '123456789ABCDEF67890123456789ABCDEF67890'}, - {algorithm: 2, type: 1, fingerprint: 'F23456789ABCDEF67890123456789ABCDEF67890'}] + {algorithm: 2, type: 1, fingerprint: 'F23456789ABCDEF67890123456789ABCDEF67890'}], + "recordSetGroupChange": 'None' }; displayRecord = { @@ -198,6 +207,7 @@ describe('Service: recordsService', function () { "records": undefined, "sshfpItems": [{algorithm: 1, type: 1, fingerprint: '123456789ABCDEF67890123456789ABCDEF67890'}, {algorithm: 2, type: 1, fingerprint: 'F23456789ABCDEF67890123456789ABCDEF67890'}], + "recordSetGroupChange": 'None', "onlyFour": true, "isDotted": false, "canBeEdited": true @@ -213,7 +223,8 @@ describe('Service: recordsService', function () { "name": 'apex.with.dot.', "type": 'NS', "ttl": 300, - "records": [{nsdname: "ns1.com."}, {nsdname: "ns2.com."}] + "records": [{nsdname: "ns1.com."}, {nsdname: "ns2.com."}], + "recordSetGroupChange": 'None' }; displayRecord = { @@ -223,6 +234,7 @@ describe('Service: recordsService', function () { "ttl": 300, "records": undefined, "nsRecordData": ["ns1.com.", "ns2.com."], + "recordSetGroupChange": 'None', "onlyFour": true, "isDotted": false, "canBeEdited": false @@ -239,7 +251,8 @@ describe('Service: recordsService', function () { "type": 'SSHFP', "ttl": 300, "records": [{algorithm: 1, type: 1, fingerprint: '123456789ABCDEF67890123456789ABCDEF67890'}, - {algorithm: 2, type: 1, fingerprint: 'F23456789ABCDEF67890123456789ABCDEF67890'}] + {algorithm: 2, type: 1, fingerprint: 'F23456789ABCDEF67890123456789ABCDEF67890'}], + "recordSetGroupChange": 'None' }; displayRecord = { @@ -250,6 +263,7 @@ describe('Service: recordsService', function () { "records": undefined, "sshfpItems": [{algorithm: 1, type: 1, fingerprint: '123456789ABCDEF67890123456789ABCDEF67890'}, {algorithm: 2, type: 1, fingerprint: 'F23456789ABCDEF67890123456789ABCDEF67890'}], + "recordSetGroupChange": 'None', "onlyFour": true, "isDotted": false, "canBeEdited": true diff --git a/modules/portal/public/templates/record-modal.html b/modules/portal/public/templates/record-modal.html index 4a636690c..5197a859f 100644 --- a/modules/portal/public/templates/record-modal.html +++ b/modules/portal/public/templates/record-modal.html @@ -1,3 +1,5 @@ +
+
@@ -468,10 +470,19 @@ - Record Owner Group is required for records in shared zones + Owner Group transfer status is required for records in shared zones - + + + @@ -506,3 +517,97 @@
+
+ + + + + + + Requesting Record Owner Group is required for records in shared zones + + + + + + + + + + + + + + + + + Record Owner Group is required for records in shared zones + + + + + + + + + Owner Group transfer status is required for records in shared zones + + + + + + + + + + +
+
\ No newline at end of file diff --git a/modules/portal/test/controllers/TestApplicationData.scala b/modules/portal/test/controllers/TestApplicationData.scala index fee7bd4eb..53a497010 100644 --- a/modules/portal/test/controllers/TestApplicationData.scala +++ b/modules/portal/test/controllers/TestApplicationData.scala @@ -296,6 +296,7 @@ trait TestApplicationData { this: Mockito => | "ttl": "200", | "status": "${RecordSetStatus.Active}", | "records": [ { "address": "10.1.1.1" } ], + | "recordSetGroupChange": "None", | "id": "$hobbitRecordSetId" | } """.stripMargin) diff --git a/modules/r53/src/main/scala/vinyldns/route53/backend/Route53Conversions.scala b/modules/r53/src/main/scala/vinyldns/route53/backend/Route53Conversions.scala index 87f02d0f5..79ec1403b 100644 --- a/modules/r53/src/main/scala/vinyldns/route53/backend/Route53Conversions.scala +++ b/modules/r53/src/main/scala/vinyldns/route53/backend/Route53Conversions.scala @@ -25,7 +25,7 @@ import com.amazonaws.services.route53.model.{ import java.time.temporal.ChronoUnit import java.time.Instant import vinyldns.core.domain.Fqdn -import vinyldns.core.domain.record.{NSData, RecordData, RecordSet, RecordSetStatus, RecordType} +import vinyldns.core.domain.record.{NSData, OwnerShipTransfer, OwnerShipTransferStatus, RecordData, RecordSet, RecordSetStatus, RecordType} import vinyldns.core.domain.record.RecordType.RecordType import vinyldns.core.domain.record.RecordType._ import vinyldns.core.domain.zone.Zone @@ -82,6 +82,7 @@ trait Route53Conversions { Instant.now.truncatedTo(ChronoUnit.MILLIS), Some(Instant.now.truncatedTo(ChronoUnit.MILLIS)), r53RecordSet.getResourceRecords.asScala.toList.flatMap(toVinyl(typ, _)), + recordSetGroupChange=Some(OwnerShipTransfer(ownerShipTransferStatus = OwnerShipTransferStatus.AutoApproved)), fqdn = Some(r53RecordSet.getName) ) } @@ -110,6 +111,7 @@ trait Route53Conversions { Instant.now.truncatedTo(ChronoUnit.MILLIS), Some(Instant.now.truncatedTo(ChronoUnit.MILLIS)), nsData, + recordSetGroupChange = Some(OwnerShipTransfer(ownerShipTransferStatus = OwnerShipTransferStatus.AutoApproved)), fqdn = Some(Fqdn(zoneName).fqdn) ) }