2
0
mirror of https://github.com/VinylDNS/vinyldns synced 2025-08-21 17:37:15 +00:00

Merge branch 'master' into dependency_upgrade

This commit is contained in:
Nicholas Spadaccino 2025-07-24 17:50:56 -04:00 committed by GitHub
commit 7eed595bd4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
312 changed files with 14989 additions and 2448 deletions

149
.github/workflows/release-beta.yml vendored Normal file
View File

@ -0,0 +1,149 @@
name: VinylDNS Beta Release
concurrency:
cancel-in-progress: true
group: "release"
defaults:
run:
shell: bash
on:
workflow_dispatch:
inputs:
verify-first:
description: 'Verify First?'
required: true
default: 'true'
create-gh-release:
description: 'Create a GitHub Release?'
required: true
default: 'true'
publish-images:
description: 'Publish Docker Images?'
required: true
default: 'true'
pre-release:
description: 'Is this a pre-release?'
required: true
default: 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
jobs:
verify:
name: Verify Release
runs-on: ubuntu-latest
steps:
- name: Checkout current branch
if: github.event.inputs.verify-first == 'true'
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run Tests
id: verify
if: github.event.inputs.verify-first == 'true'
run: cd build/ && ./assemble_api.sh && ./run_all_tests.sh
create-gh-release:
name: Create GitHub Release
needs: verify
runs-on: ubuntu-latest
if: github.event.inputs.create-gh-release == 'true'
permissions:
contents: write
steps:
- name: Checkout current branch
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build Artifacts
id: build
run: cd build/ && ./assemble_api.sh && ./assemble_portal.sh
- name: Get Version
id: get-version
run: echo "::set-output name=vinyldns_version::$(awk -F'"' '{print $2}' ./version.sbt)"
- name: Create GitHub Release
id: create_release
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ steps.get-version.outputs.vinyldns_version }}
generate_release_notes: true
files: artifacts/*
prerelease: ${{ github.event.inputs['pre-release'] == 'true' }}
docker-release-api:
name: Release API Docker Image
needs: [ verify, create-gh-release ]
runs-on: ubuntu-latest
if: github.event.inputs.publish-images == 'true'
steps:
- name: Get Version
id: get-version
run: echo "::set-output name=vinyldns_version::$(curl -s https://api.github.com/repos/vinyldns/vinyldns/releases | jq -rc '.[0].tag_name')"
- name: Checkout current branch (full)
uses: actions/checkout@v4
with:
ref: ${{ steps.get-version.outputs.vinyldns_version }}
fetch-depth: 0
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Import Content Trust Key
run: docker trust key load <(echo "${SIGNING_KEY}") --name vinyldns_svc
env:
SIGNING_KEY: ${{ secrets.SIGNING_KEY }}
DOCKER_CONTENT_TRUST_REPOSITORY_PASSPHRASE: ${{ secrets.DOCKER_CONTENT_TRUST_REPOSITORY_PASSPHRASE }}
# This will publish the latest release
- name: Publish API Docker Image
run: make -C build/docker/api publish
env:
DOCKER_CONTENT_TRUST_REPOSITORY_PASSPHRASE: ${{ secrets.DOCKER_CONTENT_TRUST_REPOSITORY_PASSPHRASE }}
docker-release-portal:
name: Release Portal Docker Image
needs: [ verify, create-gh-release ]
runs-on: ubuntu-latest
if: github.event.inputs.publish-images == 'true'
steps:
- name: Get Version
id: get-version
run: echo "::set-output name=vinyldns_version::$(curl -s https://api.github.com/repos/vinyldns/vinyldns/releases | jq -rc '.[0].tag_name')"
- name: Checkout current branch (full)
uses: actions/checkout@v4
with:
ref: ${{ steps.get-version.outputs.vinyldns_version }}
fetch-depth: 0
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Import Content Trust Key
run: docker trust key load <(echo "${SIGNING_KEY}") --name vinyldns_svc
env:
SIGNING_KEY: ${{ secrets.SIGNING_KEY }}
DOCKER_CONTENT_TRUST_REPOSITORY_PASSPHRASE: ${{ secrets.DOCKER_CONTENT_TRUST_REPOSITORY_PASSPHRASE }}
# This will publish the latest release
- name: Publish Portal Docker Image
run: make -C build/docker/portal publish
env:
DOCKER_CONTENT_TRUST_REPOSITORY_PASSPHRASE: ${{ secrets.DOCKER_CONTENT_TRUST_REPOSITORY_PASSPHRASE }}

View File

@ -34,7 +34,7 @@ jobs:
steps:
- name: Checkout current branch
if: github.event.inputs.verify-first == 'true'
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
fetch-depth: 0
@ -53,7 +53,7 @@ jobs:
steps:
- name: Checkout current branch
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
fetch-depth: 0
@ -67,7 +67,7 @@ jobs:
- name: Create GitHub Release
id: create_release
uses: softprops/action-gh-release@1e07f4398721186383de40550babbdf2b84acfc5 # v0.1.14
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ steps.get-version.outputs.vinyldns_version }}
generate_release_notes: true
@ -85,13 +85,13 @@ jobs:
run: echo "::set-output name=vinyldns_version::$(curl -s https://api.github.com/repos/vinyldns/vinyldns/releases | jq -rc '.[0].tag_name')"
- name: Checkout current branch (full)
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
ref: ${{ steps.get-version.outputs.vinyldns_version }}
fetch-depth: 0
- name: Login to Docker Hub
uses: docker/login-action@v1
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
@ -120,13 +120,13 @@ jobs:
run: echo "::set-output name=vinyldns_version::$(curl -s https://api.github.com/repos/vinyldns/vinyldns/releases | jq -rc '.[0].tag_name')"
- name: Checkout current branch (full)
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
ref: ${{ steps.get-version.outputs.vinyldns_version }}
fetch-depth: 0
- name: Login to Docker Hub
uses: docker/login-action@v1
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}

View File

@ -31,28 +31,28 @@ jobs:
- name: Codecov
id: codecov0
uses: codecov/codecov-action@v2
uses: codecov/codecov-action@v4
continue-on-error: true
with:
token: ${{ secrets.CODECOV_TOKEN }}
- name: Codecov Retry
id: codecov1
if: steps.codecov0.outcome=='failure'
uses: codecov/codecov-action@v2
uses: codecov/codecov-action@v4
continue-on-error: true
with:
token: ${{ secrets.CODECOV_TOKEN }}
- name: Codecov Retry 2
id: codecov2
if: steps.codecov1.outcome=='failure'
uses: codecov/codecov-action@v2
uses: codecov/codecov-action@v4
continue-on-error: true
with:
token: ${{ secrets.CODECOV_TOKEN }}
- name: Codecov Retry 3
id: codecov3
if: steps.codecov2.outcome=='failure'
uses: codecov/codecov-action@v2
uses: codecov/codecov-action@v4
continue-on-error: true
with:
token: ${{ secrets.CODECOV_TOKEN }}

View File

@ -21,12 +21,14 @@ in any way, but do not see your name here, please open a PR to add yourself (in
- Joe Crowe
- Jearvon Dharrie
- Andrew Dunn
- Josh Edwards
- Ryan Emerle
- David Grizzanti
- Alejandro Guirao
- Daniel Jin
- Harry Kauffman
- Krista Khare
- Sokitha Krishnan
- Patrick Lee
- Sheree Liu
- Michael Ly
@ -41,8 +43,9 @@ in any way, but do not see your name here, please open a PR to add yourself (in
- Khalid Reid
- Timo Schmid
- Trent Schmidt
- Nick Spadaccino
- Arpit Shah
- Ghafar Shah
- Nick Spadaccino
- Rebecca Star
- Jess Stodola
- Juan Valencia

View File

@ -145,9 +145,9 @@ See the [Contributing Guide](CONTRIBUTING.md).
The current maintainers (people who can merge pull requests) are:
- Ryan Emerle ([@remerle](https://github.com/remerle))
- Sriram Ramakrishnan ([@sramakr](https://github.com/sramakr))
- Jim Wakemen ([@jwakemen](https://github.com/jwakemen))
- Arpit Shah ([@arpit4ever](https://github.com/arpit4ever))
- Nick Spadaccino ([@nspadaccino](https://github.com/nspadaccino))
- Jay Velkumar ([@Jay07GIT](https://github.com/Jay07GIT))
See [AUTHORS.md](AUTHORS.md) for the full list of contributors to VinylDNS.

View File

@ -72,6 +72,9 @@ lazy val apiAssemblySettings = Seq(
MergeStrategy.discard
case PathList("scala", "tools", "nsc", "doc", "html", "resource", "lib", "template.js") =>
MergeStrategy.discard
case "simulacrum/op.class" | "simulacrum/op$.class" | "simulacrum/typeclass$.class"
| "simulacrum/typeclass.class" | "simulacrum/noop.class" =>
MergeStrategy.discard
case x if x.endsWith("module-info.class") => MergeStrategy.discard
case x =>
val oldStrategy = (assemblyMergeStrategy in assembly).value

View File

@ -27,10 +27,16 @@ vinyldns {
scheduled-changes-enabled = ${?SCHEDULED_CHANGES_ENABLED}
multi-record-batch-change-enabled = true
multi-record-batch-change-enabled = ${?MULTI_RECORD_BATCH_CHANGE_ENABLED}
# Server settings
use-recordset-cache = true
use-recordset-cache = ${?USE_RECORDSET_CACHE}
load-test-data = false
load-test-data = ${?LOAD_TEST_DATA}
# should be true while running locally or when we have only one api server/instance, for zone sync scheduler to work
is-zone-sync-schedule-allowed = true
# should be set to true only on a single server/instance else automated sync will be performed at every server/instance
is-zone-sync-schedule-allowed = ${?IS_ZONE_SYNC_SCHEDULE_ALLOWED}
# configured backend providers
backend {
@ -155,6 +161,17 @@ vinyldns {
port=${?API_SERVICE_PORT}
}
api {
limits {
batchchange-routing-max-items-limit = 100
membership-routing-default-max-items = 100
membership-routing-max-items-limit = 1000
membership-routing-max-groups-list-limit = 3000
recordset-routing-default-max-items= 100
zone-routing-default-max-items = 100
zone-routing-max-items-limit = 100
}
}
approved-name-servers = [
"172.17.42.1.",
@ -182,9 +199,9 @@ vinyldns {
name = ${?DATABASE_NAME}
driver = "org.mariadb.jdbc.Driver"
driver = ${?JDBC_DRIVER}
migration-url = "jdbc:mariadb://localhost:19002/?user=root&password=pass"
migration-url = "jdbc:mariadb://localhost:19002/?user=root&password=pass&socketTimeout=20000"
migration-url = ${?JDBC_MIGRATION_URL}
url = "jdbc:mariadb://localhost:19002/vinyldns?user=root&password=pass"
url = "jdbc:mariadb://localhost:19002/vinyldns?user=root&password=pass&socketTimeout=20000"
url = ${?JDBC_URL}
user = "root"
user = ${?JDBC_USER}
@ -192,6 +209,12 @@ vinyldns {
password = ${?JDBC_PASSWORD}
flyway-out-of-order = false
flyway-out-of-order = ${?FLYWAY_OUT_OF_ORDER}
max-lifetime = 300000
connection-timeout-millis = 30000
idle-timeout = 150000
maximum-pool-size = 20
minimum-idle = 5
}
# TODO: Remove the need for these useless configuration blocks
@ -328,6 +351,10 @@ akka.http {
# Set to `infinite` to disable.
bind-timeout = 5s
# A default request timeout is applied globally to all routes and can be configured using the
# akka.http.server.request-timeout setting (which defaults to 20 seconds).
# request-timeout = 60s
# Show verbose error messages back to the client
verbose-error-messages = on
}

View File

@ -9,10 +9,15 @@
</encoder>
</appender>
<logger name="vinyldns.core.route.Monitor" level="OFF"/>
<logger name="scalikejdbc.StatementExecutor$$anon$1" level="OFF"/>
<logger name="com.zaxxer.hikari" level="ERROR">
<appender-ref ref="CONSOLE"/>
</logger>
<root level="${VINYLDNS_LOG_LEVEL}">
<appender-ref ref="CONSOLE"/>
</root>

View File

@ -50,9 +50,9 @@ mysql {
name = ${?DATABASE_NAME}
driver = "org.mariadb.jdbc.Driver"
driver = ${?JDBC_DRIVER}
migration-url = "jdbc:mariadb://"${mysql.endpoint}"/?user=root&password=pass"
migration-url = "jdbc:mariadb://"${mysql.endpoint}"/?user=root&password=pass&socketTimeout=20000"
migration-url = ${?JDBC_MIGRATION_URL}
url = "jdbc:mariadb://"${mysql.endpoint}"/vinyldns?user=root&password=pass"
url = "jdbc:mariadb://"${mysql.endpoint}"/vinyldns?user=root&password=pass&socketTimeout=20000"
url = ${?JDBC_URL}
user = "root"
user = ${?JDBC_USER}
@ -60,6 +60,12 @@ mysql {
password = ${?JDBC_PASSWORD}
flyway-out-of-order = false
flyway-out-of-order = ${?FLYWAY_OUT_OF_ORDER}
max-lifetime = 300000
connection-timeout-millis = 30000
idle-timeout = 150000
maximum-pool-size = 20
minimum-idle = 5
}
}
@ -70,6 +76,18 @@ crypto {
secret = ${?CRYPTO_SECRET}
}
api {
limits {
batchchange-routing-max-items-limit = 100
membership-routing-default-max-items = 100
membership-routing-max-items-limit = 1000
membership-routing-max-groups-list-limit = 3000
recordset-routing-default-max-items= 100
zone-routing-default-max-items = 100
zone-routing-max-items-limit = 100
}
}
http.port = 9001
http.port = ${?PORTAL_PORT}
@ -83,6 +101,12 @@ shared-display-enabled = ${?SHARED_ZONES_ENABLED}
play.http.secret.key = "changeme"
play.http.secret.key = ${?PLAY_HTTP_SECRET_KEY}
# See https://www.playframework.com/documentation/2.8.x/AllowedHostsFilter for more details.
# Note: allowed = ["."] matches all hosts hence would not be recommended in a production environment.
play.filters.hosts {
allowed = ["."]
}
# You can provide configuration overrides via local.conf if you don't want to replace everything in
# this configuration file
include "local.conf"

View File

@ -15,6 +15,10 @@
<logger name="play" level="INFO" />
<logger name="application" level="DEBUG" />
<logger name="com.zaxxer.hikari" level="ERROR">
<appender-ref ref="CONSOLE"/>
</logger>
<root level="${VINYLDNS_LOG_LEVEL}">
<appender-ref ref="CONSOLE" />
</root>

View File

@ -348,6 +348,10 @@ akka.http {
# Set to `infinite` to disable.
bind-timeout = 5s
# A default request timeout is applied globally to all routes and can be configured using the
# akka.http.server.request-timeout setting (which defaults to 20 seconds).
# request-timeout = 60s
# Show verbose error messages back to the client
verbose-error-messages = on
}

View File

@ -34,6 +34,6 @@ trait MySqlApiIntegrationSpec extends MySqlIntegrationSpec {
def clearGroupRepo(): Unit =
DB.localTx { s =>
s.executeUpdate("DELETE FROM groups")
s.executeUpdate("DELETE FROM `groups`")
}
}

View File

@ -22,14 +22,8 @@ import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpec
import vinyldns.api.backend.dns.DnsProtocol.NoError
import vinyldns.core.crypto.NoOpCrypto
import vinyldns.core.domain.record.{
AData,
RecordSet,
RecordSetChange,
RecordSetChangeType,
RecordSetStatus,
RecordType
}
import vinyldns.core.domain.Encrypted
import vinyldns.core.domain.record.{AData, RecordSet, RecordSetChange, RecordSetChangeType, RecordSetStatus, RecordType}
import vinyldns.core.domain.zone.{Algorithm, Zone, ZoneConnection}
class DnsBackendIntegrationSpec extends AnyWordSpec with Matchers {
@ -37,7 +31,7 @@ class DnsBackendIntegrationSpec extends AnyWordSpec with Matchers {
private val testConnection = ZoneConnection(
"vinyldns.",
"vinyldns.",
"nzisn+4G2ldMn0q1CV3vsg==",
Encrypted("nzisn+4G2ldMn0q1CV3vsg=="),
sys.env.getOrElse("DEFAULT_DNS_ADDRESS", "127.0.0.1:19001"),
Algorithm.HMAC_MD5
)

View File

@ -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._
@ -34,13 +35,16 @@ import vinyldns.api.domain.zone._
import vinyldns.api.engine.TestMessageQueue
import vinyldns.mysql.TransactionProvider
import vinyldns.core.TestZoneData.testConnection
import vinyldns.core.domain.{Fqdn, HighValueDomainError}
import vinyldns.core.domain.{Encrypted, Fqdn, HighValueDomainError}
import vinyldns.core.domain.auth.AuthPrincipal
import vinyldns.core.domain.backend.{Backend, BackendResolver}
import vinyldns.core.domain.membership.{Group, GroupRepository, User, UserRepository}
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,19 +57,25 @@ 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
private var testRecordSetService: RecordSetServiceAlgebra = _
private val user = User("live-test-user", "key", "secret")
private val testUser = User("testuser", "key", "secret")
private val user2 = User("shared-record-test-user", "key-shared", "secret-shared")
private val user = User("live-test-user", "key", Encrypted("secret"))
private val testUser = User("testuser", "key", Encrypted("secret"))
private val user2 = User("shared-record-test-user", "key-shared", Encrypted("secret-shared"))
private val group = Group(s"test-group", "test@test.com", adminUserIds = Set(user.id))
private val dummyGroup = Group(s"dummy-group", "test@test.com", adminUserIds = Set(testUser.id))
private val group2 = Group(s"test-group", "test@test.com", adminUserIds = Set(user.id, user2.id))
@ -118,7 +128,10 @@ class RecordSetServiceIntegrationSpec
RecordSetStatus.Active,
Instant.now.truncatedTo(ChronoUnit.MILLIS),
None,
List(AAAAData("fd69:27cc:fe91::60"))
List(AAAAData("fd69:27cc:fe91::60")),
recordSetGroupChange =
Some(OwnerShipTransfer(ownerShipTransferStatus = OwnerShipTransferStatus.None,
requestedOwnerGroupId = None))
)
private val subTestRecordA = RecordSet(
zone.id,
@ -231,6 +244,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",
@ -284,7 +327,10 @@ class RecordSetServiceIntegrationSpec
// Seeding records in DB
val sharedRecords = List(
sharedTestRecord,
sharedTestRecordBadOwnerGroup
sharedTestRecordBadOwnerGroup,
sharedTestRecordPendingReviewOwnerShip,
sharedTestRecordCancelledOwnerShip
)
val conflictRecords = List(
subTestRecordNameConflict,
@ -323,7 +369,8 @@ class RecordSetServiceIntegrationSpec
vinyldnsConfig.highValueDomainConfig,
vinyldnsConfig.dottedHostsConfig,
vinyldnsConfig.serverConfig.approvedNameServers,
useRecordSetCache = true
useRecordSetCache = true,
mockNotifiers
)
}
@ -424,6 +471,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)
@ -668,25 +936,23 @@ class RecordSetServiceIntegrationSpec
}
"fail deleting for user not in record owner group in shared zone" in {
val result = leftResultOf(
val result =
testRecordSetService
.deleteRecordSet(sharedTestRecord.id, sharedTestRecord.zoneId, dummyAuth)
.value
)
.value.unsafeRunSync().swap.toOption.get
result shouldBe a[NotAuthorizedError]
}
"fail deleting for user in record owner group in non-shared zone" in {
val result = leftResultOf(
val result =
testRecordSetService
.deleteRecordSet(
testOwnerGroupRecordInNormalZone.id,
testOwnerGroupRecordInNormalZone.zoneId,
auth2
)
.value
)
.value.unsafeRunSync().swap.toOption.get
result shouldBe a[NotAuthorizedError]
}

View File

@ -18,6 +18,7 @@ package vinyldns.api.domain.zone
import cats.data.NonEmptyList
import cats.effect._
import java.time.Instant
import java.time.temporal.ChronoUnit
import org.mockito.Mockito.doReturn
@ -29,6 +30,7 @@ import org.scalatestplus.mockito.MockitoSugar
import org.scalatest.time.{Seconds, Span}
import scalikejdbc.DB
import vinyldns.api.domain.access.AccessValidations
import vinyldns.api.domain.membership.MembershipService
import vinyldns.api.domain.record.RecordSetChangeGenerator
import vinyldns.api.engine.TestMessageQueue
import vinyldns.mysql.TransactionProvider
@ -62,7 +64,7 @@ class ZoneServiceIntegrationSpec
private val recordSetRepo = recordSetRepository
private val zoneRepo: ZoneRepository = zoneRepository
private val mockMembershipService = mock[MembershipService]
private var testZoneService: ZoneServiceAlgebra = _
private val badAuth = AuthPrincipal(okUser, Seq())
@ -127,7 +129,8 @@ class ZoneServiceIntegrationSpec
new ZoneValidations(1000),
new AccessValidations(),
mockBackendResolver,
NoOpCrypto.instance
NoOpCrypto.instance,
mockMembershipService
)
}

View File

@ -19,8 +19,8 @@ package vinyldns.api.domain.zone
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpec
import org.xbill.DNS.ZoneTransferException
import vinyldns.api.backend.dns.DnsBackend
import vinyldns.api.config.VinylDNSConfig
import vinyldns.core.domain.Encrypted
import vinyldns.core.domain.backend.BackendResolver
import vinyldns.core.domain.zone.{Zone, ZoneConnection}
@ -50,15 +50,13 @@ class ZoneViewLoaderIntegrationSpec extends AnyWordSpec with Matchers {
ZoneConnection(
"vinyldns.",
"vinyldns.",
"nzisn+4G2ldMn0q1CV3vsg==",
Encrypted("nzisn+4G2ldMn0q1CV3vsg=="),
sys.env.getOrElse("DEFAULT_DNS_ADDRESS", "127.0.0.1:19001")
)
),
transferConnection =
Some(ZoneConnection("invalid-connection.", "bad-key", "invalid-key", "10.1.1.1"))
Some(ZoneConnection("invalid-connection.", "bad-key", Encrypted("invalid-key"), "10.1.1.1"))
)
val backend = backendResolver.resolve(zone).asInstanceOf[DnsBackend]
println(s"${backend.id}, ${backend.xfrInfo}, ${backend.resolver.getAddress}")
DnsZoneViewLoader(zone, backendResolver.resolve(zone), 10000)
.load()
.unsafeRunSync()
@ -83,7 +81,7 @@ class ZoneViewLoaderIntegrationSpec extends AnyWordSpec with Matchers {
ZoneConnection(
"vinyldns.",
"vinyldns.",
"nzisn+4G2ldMn0q1CV3vsg==",
Encrypted("nzisn+4G2ldMn0q1CV3vsg=="),
sys.env.getOrElse("DEFAULT_DNS_ADDRESS", "127.0.0.1:19001")
)
),
@ -91,7 +89,7 @@ class ZoneViewLoaderIntegrationSpec extends AnyWordSpec with Matchers {
ZoneConnection(
"vinyldns.",
"vinyldns.",
"nzisn+4G2ldMn0q1CV3vsg==",
Encrypted("nzisn+4G2ldMn0q1CV3vsg=="),
sys.env.getOrElse("DEFAULT_DNS_ADDRESS", "127.0.0.1:19001")
)
)

View File

@ -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] =

View File

@ -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 {

View File

@ -30,7 +30,7 @@ import vinyldns.api.engine.ZoneSyncHandler
import vinyldns.api.{MySqlApiIntegrationSpec, ResultHelpers}
import vinyldns.core.TestRecordSetData._
import vinyldns.core.domain.backend.{Backend, BackendResolver}
import vinyldns.core.domain.record.{NameSort, RecordType}
import vinyldns.core.domain.record.{NameSort, RecordType, RecordTypeSort}
import vinyldns.core.domain.zone.{Zone, ZoneChange, ZoneChangeType}
import vinyldns.core.health.HealthCheck.HealthCheck
import vinyldns.route53.backend.{Route53Backend, Route53BackendConfig}
@ -57,6 +57,8 @@ class Route53ApiIntegrationSpec
"test",
Some("access"),
Some("secret"),
None,
None,
sys.env.getOrElse("R53_SERVICE_ENDPOINT", "http://localhost:19003"),
"us-east-1"
)
@ -120,7 +122,7 @@ class Route53ApiIntegrationSpec
// We should have both the record we created above as well as at least one NS record
val results = recordSetRepository
.listRecordSets(Some(testZone.id), None, None, None, None, None, NameSort.ASC)
.listRecordSets(Some(testZone.id), None, None, None, None, None, NameSort.ASC, RecordTypeSort.ASC)
.unsafeRunSync()
results.recordSets.map(_.typ).distinct should contain theSameElementsAs List(
rsOk.typ,

View File

@ -27,10 +27,16 @@ vinyldns {
scheduled-changes-enabled = ${?SCHEDULED_CHANGES_ENABLED}
multi-record-batch-change-enabled = true
multi-record-batch-change-enabled = ${?MULTI_RECORD_BATCH_CHANGE_ENABLED}
# Server settings
use-recordset-cache = false
use-recordset-cache = ${?USE_RECORDSET_CACHE}
load-test-data = false
load-test-data = ${?LOAD_TEST_DATA}
# should be true while running locally or when we have only one api server/instance, for zone sync scheduler to work
is-zone-sync-schedule-allowed = true
# should be set to true only on a single server/instance else automated sync will be performed at every server/instance
is-zone-sync-schedule-allowed = ${?IS_ZONE_SYNC_SCHEDULE_ALLOWED}
# configured backend providers
backend {
@ -131,7 +137,10 @@ vinyldns {
from = ${?EMAIL_FROM}
}
}
valid-email-config{
email-domains = ["test.com","*dummy.com"]
number-of-dots= 2
}
sns {
class-name = "vinyldns.apadi.notifier.sns.SnsNotifierProvider"
class-name = ${?SNS_CLASS_NAME}
@ -155,6 +164,17 @@ vinyldns {
port=${?API_SERVICE_PORT}
}
api {
limits {
batchchange-routing-max-items-limit = 100
membership-routing-default-max-items = 100
membership-routing-max-items-limit = 1000
membership-routing-max-groups-list-limit = 3000
recordset-routing-default-max-items= 100
zone-routing-default-max-items = 100
zone-routing-max-items-limit = 100
}
}
approved-name-servers = [
"172.17.42.1.",
@ -195,9 +215,9 @@ vinyldns {
name = ${?DATABASE_NAME}
driver = "org.mariadb.jdbc.Driver"
driver = ${?JDBC_DRIVER}
migration-url = "jdbc:mariadb://localhost:19002/?user=root&password=pass"
migration-url = "jdbc:mariadb://localhost:19002/?user=root&password=pass&socketTimeout=20000"
migration-url = ${?JDBC_MIGRATION_URL}
url = "jdbc:mariadb://localhost:19002/vinyldns?user=root&password=pass"
url = "jdbc:mariadb://localhost:19002/vinyldns?user=root&password=pass&socketTimeout=20000"
url = ${?JDBC_URL}
user = "root"
user = ${?JDBC_USER}
@ -205,6 +225,12 @@ vinyldns {
password = ${?JDBC_PASSWORD}
flyway-out-of-order = false
flyway-out-of-order = ${?FLYWAY_OUT_OF_ORDER}
max-lifetime = 300000
connection-timeout-millis = 30000
idle-timeout = 150000
maximum-pool-size = 20
minimum-idle = 5
}
# TODO: Remove the need for these useless configuration blocks
@ -341,6 +367,10 @@ akka.http {
# Set to `infinite` to disable.
bind-timeout = 5s
# A default request timeout is applied globally to all routes and can be configured using the
# akka.http.server.request-timeout setting (which defaults to 20 seconds).
# request-timeout = 60s
# Show verbose error messages back to the client
verbose-error-messages = on
}

View File

@ -7,6 +7,10 @@
</encoder>
</appender>
<logger name="com.zaxxer.hikari" level="ERROR">
<appender-ref ref="CONSOLE"/>
</logger>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>

View File

@ -169,7 +169,10 @@ vinyldns {
from = "VinylDNS <do-not-reply@vinyldns.io>"
}
}
valid-email-config{
email-domains = ["test.com","*dummy.com"]
number-of-dots= 2
}
sns {
class-name = "vinyldns.api.notifier.sns.SnsNotifierProvider"
settings {
@ -232,4 +235,9 @@ vinyldns {
load-test-data = false
load-test-data = ${?LOAD_TEST_DATA}
# should be true while running locally or when we have only one api server/instance, for zone sync scheduler to work
is-zone-sync-schedule-allowed = true
# should be set to true only on a single server/instance else automated sync will be performed at every server/instance
is-zone-sync-schedule-allowed = ${?IS_ZONE_SYNC_SCHEDULE_ALLOWED}
}

View File

@ -20,6 +20,7 @@ import akka.actor.ActorSystem
import akka.http.scaladsl.Http
import akka.stream.{Materializer, ActorMaterializer}
import cats.effect.{Timer, IO, ContextShift}
import cats.data.NonEmptyList
import com.typesafe.config.ConfigFactory
import fs2.concurrent.SignallingRef
import io.prometheus.client.CollectorRegistry
@ -46,10 +47,15 @@ import scala.concurrent.{ExecutionContext, Future}
import scala.io.{Codec, Source}
import vinyldns.core.notifier.NotifierLoader
import vinyldns.core.repository.DataStoreLoader
import java.util.concurrent.{Executors, ScheduledExecutorService, TimeUnit}
object Boot extends App {
private val logger = LoggerFactory.getLogger("Boot")
// Create a ScheduledExecutorService with a new single thread
private val executor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor()
private implicit val ec: ExecutionContext = scala.concurrent.ExecutionContext.global
private implicit val cs: ContextShift[IO] = IO.contextShift(ec)
private implicit val timer: Timer[IO] = IO.timer(ec)
@ -90,9 +96,23 @@ 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
_ <- if (vinyldnsConfig.serverConfig.isZoneSyncScheduleAllowed){ IO(executor.scheduleAtFixedRate(() => {
val zoneChanges = for {
zoneChanges <- ZoneSyncScheduleHandler.zoneSyncScheduler(repositories.zoneRepository)
_ <- if (zoneChanges.nonEmpty) messageQueue.sendBatch(NonEmptyList.fromList(zoneChanges.toList).get) else IO.unit
} yield ()
zoneChanges.unsafeRunAsync {
case Right(_) =>
logger.debug("Zone sync scheduler ran successfully!")
case Left(error) =>
logger.error(s"An error occurred while performing the scheduled zone sync. Error: $error")
}
}, 0, 1, TimeUnit.SECONDS)) } else IO.unit
_ <- CommandHandler.run(
messageQueue,
msgsPerPoll,
@ -121,9 +141,10 @@ object Boot extends App {
vinyldnsConfig.highValueDomainConfig,
vinyldnsConfig.manualReviewConfig,
vinyldnsConfig.batchChangeConfig,
vinyldnsConfig.scheduledChangesConfig
vinyldnsConfig.scheduledChangesConfig,
vinyldnsConfig.serverConfig.approvedNameServers
)
val membershipService = MembershipService(repositories)
val membershipService = MembershipService(repositories,vinyldnsConfig.validEmailConfig)
val connectionValidator =
new ZoneConnectionValidator(
@ -141,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,
@ -150,7 +172,8 @@ object Boot extends App {
zoneValidations,
recordAccessValidations,
backendResolver,
vinyldnsConfig.crypto
vinyldnsConfig.crypto,
membershipService
)
//limits configured in reference.conf passing here
val limits = LimitsConfig(

View File

@ -39,6 +39,7 @@ import vinyldns.core.queue.{CommandMessage, MessageCount, MessageQueue}
import scala.concurrent.duration._
import vinyldns.core.notifier.AllNotifiers
import java.io.{PrintWriter, StringWriter}
object CommandHandler {
@ -94,7 +95,9 @@ object CommandHandler {
)
.parJoin(maxOpen)
.handleErrorWith { error =>
logger.error("Encountered unexpected error in main flow", error)
val errorMessage = new StringWriter
error.printStackTrace(new PrintWriter(errorMessage))
logger.error(s"Encountered unexpected error in main flow. Error: ${errorMessage.toString.replaceAll("\n",";").replaceAll("\t"," ")}")
// just continue, the flow should never stop unless explicitly told to do so
flow()
@ -123,7 +126,9 @@ object CommandHandler {
.handleErrorWith { error =>
// on error, we make sure we still continue; should only stop when the app stops
// or processing is disabled
logger.error("Encountered error polling message queue", error)
val errorMessage = new StringWriter
error.printStackTrace(new PrintWriter(errorMessage))
logger.error(s"Encountered error polling message queue. Error: ${errorMessage.toString.replaceAll("\n",";").replaceAll("\t"," ")}")
// just keep going on the stream
pollingStream()
@ -137,7 +142,7 @@ object CommandHandler {
_.evalMap[IO, Any] { message =>
message.command match {
case sync: ZoneChange
if sync.changeType == ZoneChangeType.Sync || sync.changeType == ZoneChangeType.Create =>
if sync.changeType == ZoneChangeType.Sync || sync.changeType == ZoneChangeType.AutomatedSync || sync.changeType == ZoneChangeType.Create =>
logger.info(s"Updating visibility timeout for zone change; changeId=${sync.id}")
mq.changeMessageTimeout(message, 1.hour)
@ -158,7 +163,7 @@ object CommandHandler {
_.evalMap[IO, MessageOutcome] { message =>
message.command match {
case sync: ZoneChange
if sync.changeType == ZoneChangeType.Sync || sync.changeType == ZoneChangeType.Create =>
if sync.changeType == ZoneChangeType.Sync || sync.changeType == ZoneChangeType.AutomatedSync || sync.changeType == ZoneChangeType.Create =>
outcomeOf(message)(zoneSyncProcessor(sync))
case zoneChange: ZoneChange =>
@ -182,7 +187,9 @@ object CommandHandler {
.attempt
.map {
case Left(e) =>
logger.warn(s"Failed processing message need to retry; $message", e)
val errorMessage = new StringWriter
e.printStackTrace(new PrintWriter(errorMessage))
logger.warn(s"Failed processing message need to retry; $message. Error: ${errorMessage.toString.replaceAll("\n",";").replaceAll("\t"," ")}")
RetryMessage(message)
case Right(ok) => ok
}

View File

@ -29,6 +29,7 @@ import vinyldns.core.domain.record.RecordType.RecordType
import vinyldns.core.domain.record.{RecordSet, RecordSetChange, RecordSetChangeType, RecordType}
import vinyldns.core.domain.zone.{Algorithm, Zone, ZoneConnection}
import java.io.{PrintWriter, StringWriter}
import scala.collection.JavaConverters._
object DnsProtocol {
@ -165,6 +166,7 @@ class DnsBackend(val id: String, val resolver: DNS.SimpleResolver, val xfrInfo:
val dnsName = recordDnsName(name, zoneName)
logger.info(s"Querying for dns dnsRecordName='${dnsName.toString}'; recordType='$typ'")
val lookup = new DNS.Lookup(dnsName, toDnsRecordType(typ))
lookup.setResolver(resolver)
lookup.setSearchPath(List(Name.empty).asJava)
lookup.setCache(null)
@ -213,8 +215,29 @@ class DnsBackend(val id: String, val resolver: DNS.SimpleResolver, val xfrInfo:
resp <- toDnsResponse(resp)
} yield resp
val message =
for {
str <- Either.catchNonFatal(s"DNS Resolver: ${resolver.toString}, " +
s"Resolver Address=${resolver.getAddress.getAddress}, Resolver Host=${resolver.getAddress.getHostName}, " +
s"Resolver Port=${resolver.getPort}, Timeout=${resolver.getTimeout.toString}"
)
} yield str
val resolver_debug_message = message match {
case Right(value) => value
case Left(_) => s"DNS Resolver: ${resolver.toString}"
}
val receivedResponse = result match {
case Right(value) => value.toString.replaceAll("\n",";").replaceAll("\t"," ")
case Left(e) =>
val errorMessage = new StringWriter
e.printStackTrace(new PrintWriter(errorMessage))
errorMessage.toString.replaceAll("\n",";").replaceAll("\t"," ")
}
logger.info(
s"DnsConnection.send - Sending DNS Message ${obscuredDnsMessage(msg).toString}\n...received response $result"
s"DnsConnection.send - Sending DNS Message ${obscuredDnsMessage(msg).toString.replaceAll("\n",";").replaceAll("\t"," ")}. Received response: $receivedResponse. DNS Resolver Info: $resolver_debug_message"
)
result
@ -234,10 +257,10 @@ class DnsBackend(val id: String, val resolver: DNS.SimpleResolver, val xfrInfo:
// so if we can parse the error into an rcode, then we need to handle it properly; otherwise, we can try again
// The DNS.Rcode.value function will return -1 if the error cannot be parsed into an integer
if (DNS.Rcode.value(query.error) >= 0) {
logger.info(s"Received TRY_AGAIN from DNS lookup; converting error: ${query.error}")
logger.warn(s"Received TRY_AGAIN from DNS lookup; converting error: ${query.error.replaceAll("\n",";")}")
fromDnsRcodeToError(DNS.Rcode.value(query.error), query.error)
} else {
logger.warn(s"Unparseable error code returned from DNS: ${query.error}")
logger.warn(s"Unparseable error code returned from DNS: ${query.error.replaceAll("\n",";")}")
Left(TryAgain(query.error))
}
@ -293,7 +316,7 @@ object DnsBackend {
new DNS.TSIG(
parseAlgorithm(conn.algorithm),
decryptedConnection.keyName,
decryptedConnection.key
decryptedConnection.key.value
)
}

View File

@ -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 =

View File

@ -31,6 +31,6 @@ object HighValueDomainConfig {
"ip-list"
) {
case (regexList, ipList) =>
HighValueDomainConfig(toCaseIgnoredRegexList(regexList), ipList.flatMap(IpAddress(_)))
HighValueDomainConfig(toCaseIgnoredRegexList(regexList), ipList.flatMap(IpAddress.fromString(_)))
}
}

View File

@ -41,7 +41,7 @@ object ManualReviewConfig {
ManualReviewConfig(
enabled,
toCaseIgnoredRegexList(domainsConfig.getStringList("domain-list").asScala.toList),
domainsConfig.getStringList("ip-list").asScala.toList.flatMap(IpAddress(_)),
domainsConfig.getStringList("ip-list").asScala.toList.flatMap(IpAddress.fromString(_)),
domainsConfig.getStringList("zone-name-list").asScala.toSet
)
}

View File

@ -34,13 +34,14 @@ final case class ServerConfig(
keyName: String,
processingDisabled: Boolean,
useRecordSetCache: Boolean,
loadTestData: Boolean
loadTestData: Boolean,
isZoneSyncScheduleAllowed: Boolean,
)
object ServerConfig {
import ZoneRecordValidations.toCaseIgnoredRegexList
implicit val configReader: ConfigReader[ServerConfig] = ConfigReader.forProduct12[
implicit val configReader: ConfigReader[ServerConfig] = ConfigReader.forProduct13[
ServerConfig,
Int,
Int,
@ -53,6 +54,7 @@ object ServerConfig {
Config,
Boolean,
Boolean,
Boolean,
Boolean
](
"health-check-timeout",
@ -66,7 +68,8 @@ object ServerConfig {
"defaultZoneConnection",
"processing-disabled",
"use-recordset-cache",
"load-test-data"
"load-test-data",
"is-zone-sync-schedule-allowed"
) {
case (
timeout,
@ -80,7 +83,8 @@ object ServerConfig {
zoneConnConfig,
processingDisabled,
useRecordSetCache,
loadTestData) =>
loadTestData,
isZoneSyncScheduleAllowed) =>
ServerConfig(
timeout,
ttl,
@ -93,7 +97,8 @@ object ServerConfig {
zoneConnConfig.getString("keyName"),
processingDisabled,
useRecordSetCache,
loadTestData
loadTestData,
isZoneSyncScheduleAllowed
)
}
}

View File

@ -0,0 +1,42 @@
/*
* Copyright 2018 Comcast Cable Communications Management, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package vinyldns.api.config
import pureconfig.ConfigReader
case class ValidEmailConfig(
valid_domains : List[String],
number_of_dots : Int)
object ValidEmailConfig {
implicit val configReader: ConfigReader[ValidEmailConfig] =
ConfigReader.forProduct2[ValidEmailConfig,List[String],Int](
"email-domains",
"number-of-dots"
)
{
case (
valid_domains,
number_of_dots,
) =>
ValidEmailConfig(
valid_domains,
number_of_dots,
)
}
}

View File

@ -38,6 +38,7 @@ import scala.reflect.ClassTag
final case class VinylDNSConfig(
serverConfig: ServerConfig,
limitsconfig: LimitsConfig,
validEmailConfig: ValidEmailConfig,
httpConfig: HttpConfig,
highValueDomainConfig: HighValueDomainConfig,
manualReviewConfig: ManualReviewConfig,
@ -83,6 +84,7 @@ object VinylDNSConfig {
for {
config <- IO.delay(ConfigFactory.load())
limitsconfig <- loadIO[LimitsConfig](config, "vinyldns.api.limits") //Added Limitsconfig to fetch data from the reference.config and pass to LimitsConfig.config
validEmailConfig <- loadIO[ValidEmailConfig](config, path="vinyldns.valid-email-config")
serverConfig <- loadIO[ServerConfig](config, "vinyldns")
batchChangeConfig <- loadIO[BatchChangeConfig](config, "vinyldns")
backendConfigs <- loadIO[BackendConfigs](config, "vinyldns.backend")
@ -103,6 +105,7 @@ object VinylDNSConfig {
} yield VinylDNSConfig(
serverConfig,
limitsconfig,
validEmailConfig,
httpConfig,
hvdConfig,
manualReviewConfig,

View File

@ -27,6 +27,7 @@ import scala.util.matching.Regex
Object to house common domain validations
*/
object DomainValidations {
val validReverseZoneFQDNRegex: Regex =
"""^(?:([0-9a-zA-Z\-\/_]{1,63}|[0-9a-zA-Z\-\/_]{1}[0-9a-zA-Z\-\/_]{0,61}[0-9a-zA-Z\-\/_]{1}|[*.]{2}[0-9a-zA-Z\-\/_]{0,60}[0-9a-zA-Z\-\/_]{1})\.)*$""".r
val validForwardZoneFQDNRegex: Regex =
@ -58,16 +59,23 @@ object DomainValidations {
val TTL_MIN_LENGTH: Int = 30
val TXT_TEXT_MIN_LENGTH: Int = 1
val TXT_TEXT_MAX_LENGTH: Int = 64764
val MX_PREFERENCE_MIN_VALUE: Int = 0
val MX_PREFERENCE_MAX_VALUE: Int = 65535
val INTEGER_MIN_VALUE: Int = 0
val INTEGER_MAX_VALUE: Int = 65535
// Cname check - Cname should not be IP address
def validateCname(name: Fqdn, isReverse: Boolean): ValidatedNel[DomainValidationError, Fqdn] =
validateIpv4Address(name.fqdn.dropRight(1)).isValid match {
case true => InvalidIPv4CName(name.toString).invalidNel
case false => validateIsReverseCname(name, isReverse)
}
def validateHostName(name: Fqdn): ValidatedNel[DomainValidationError, Fqdn] =
validateHostName(name.fqdn).map(_ => name)
def validateCname(name: Fqdn, isReverse: Boolean): ValidatedNel[DomainValidationError, Fqdn] =
validateCname(name.fqdn, isReverse).map(_ => name)
def validateIsReverseCname(name: Fqdn, isReverse: Boolean): ValidatedNel[DomainValidationError, Fqdn] =
validateIsReverseCname(name.fqdn, isReverse).map(_ => name)
def validateCname(name: String, isReverse: Boolean): ValidatedNel[DomainValidationError, String] = {
def validateIsReverseCname(name: String, isReverse: Boolean): ValidatedNel[DomainValidationError, String] = {
isReverse match {
case true =>
val checkRegex = validReverseZoneFQDNRegex
@ -152,7 +160,15 @@ object DomainValidations {
def validateTxtTextLength(value: String): ValidatedNel[DomainValidationError, String] =
validateStringLength(value, Some(TXT_TEXT_MIN_LENGTH), TXT_TEXT_MAX_LENGTH)
def validateMxPreference(pref: Int): ValidatedNel[DomainValidationError, Int] =
if (pref >= MX_PREFERENCE_MIN_VALUE && pref <= MX_PREFERENCE_MAX_VALUE) pref.validNel
else InvalidMxPreference(pref, MX_PREFERENCE_MIN_VALUE, MX_PREFERENCE_MAX_VALUE).invalidNel[Int]
def validateMX_NAPTR_SRVData(number: Int, recordDataType: String, recordType: String): ValidatedNel[DomainValidationError, Int] =
if (number >= INTEGER_MIN_VALUE && number <= INTEGER_MAX_VALUE) number.validNel
else InvalidMX_NAPTR_SRVData(number, INTEGER_MIN_VALUE, INTEGER_MAX_VALUE, recordDataType, recordType).invalidNel[Int]
def validateNaptrFlag(value: String): ValidatedNel[DomainValidationError, String] =
if (value == "U" || value == "S" || value == "A" || value == "P") value.validNel
else InvalidNaptrFlag(value).invalidNel[String]
def validateNaptrRegexp(value: String): ValidatedNel[DomainValidationError, String] =
if ((value.startsWith("!") && value.endsWith("!")) || value == "") value.validNel
else InvalidNaptrRegexp(value).invalidNel[String]
}

View File

@ -17,7 +17,7 @@
package vinyldns.api.domain
import cats.implicits._
import com.aaronbedra.orchard.CIDR
import com.comcast.ip4s.{Cidr, Ipv4Address, Ipv6Address}
import vinyldns.api.domain.zone.InvalidRequest
import vinyldns.core.domain.zone.Zone
import vinyldns.api.backend.dns.DnsConversions._
@ -30,8 +30,9 @@ object ReverseZoneHelpers {
if (zone.isIPv4) {
recordsetIsWithinCidrMaskIpv4(mask: String, zone: Zone, recordName: String)
} else {
val ipAddr = convertPTRtoIPv6(zone, recordName)
Try(CIDR.valueOf(mask).contains(ipAddr)).getOrElse(false)
val ipAddr = Ipv6Address.fromString(convertPTRtoIPv6(zone, recordName))
Try(Cidr(Cidr.fromString6(mask).get.address,Cidr.fromString6(mask).get.prefixBits).contains(ipAddr.get))
.getOrElse(false)
}
// NOTE: this will not work for zones with less than 3 octets
@ -86,11 +87,12 @@ object ReverseZoneHelpers {
zone: Zone,
recordName: String
): Boolean = {
val recordIpAddr = convertPTRtoIPv4(zone, recordName)
val recordIpAddr = Ipv4Address.fromString(convertPTRtoIPv4(zone, recordName))
Try {
// make sure mask contains 4 octets, expand if not
val ipMaskOctets = CIDR.parseBlock(mask).head.split('.').toList
val ipMaskOctets = Cidr.fromString4(mask).get.address.toString.split('.').toList
val fullIp = ipMaskOctets.length match {
case 1 => (ipMaskOctets ++ List("0", "0", "0")).mkString(".")
@ -99,9 +101,8 @@ object ReverseZoneHelpers {
case 4 => ipMaskOctets.mkString(".")
}
val updatedMask = fullIp + "/" + CIDR.valueOf(mask).getMask
CIDR.valueOf(updatedMask).contains(recordIpAddr)
val updatedMask = Cidr(recordIpAddr.get,Cidr.fromString4(mask).get.prefixBits)
updatedMask.contains(Ipv4Address.fromString(fullIp).get)
}.getOrElse(false)
}

View File

@ -38,6 +38,13 @@ class AccessValidations(
.isGroupMember(zone.adminGroupId) || userHasAclRules(auth, zone)
)
def canSeeZoneChange(auth: AuthPrincipal, zone: Zone): Either[Throwable, Unit] =
ensuring(
NotAuthorizedError(s"User ${auth.signedInUser.userName} cannot access zone '${zone.name}' changes")
)(
auth.isSystemAdmin || zone.shared || auth.isGroupMember(zone.adminGroupId)
)
def canChangeZone(
auth: AuthPrincipal,
zoneName: String,
@ -73,6 +80,7 @@ class AccessValidations(
recordType: RecordType,
zone: Zone,
recordOwnerGroupId: Option[String],
superUserCanUpdateOwnerGroup: Boolean = false,
newRecordData: List[RecordData] = List.empty
): Either[Throwable, Unit] = {
val accessLevel =
@ -82,7 +90,7 @@ class AccessValidations(
s"User ${auth.signedInUser.userName} does not have access to update " +
s"$recordName.${zone.name}"
)
)(accessLevel == AccessLevel.Delete || accessLevel == AccessLevel.Write)
)(accessLevel == AccessLevel.Delete || accessLevel == AccessLevel.Write || superUserCanUpdateOwnerGroup)
}
def canDeleteRecordSet(
@ -222,7 +230,9 @@ class AccessValidations(
AccessLevel.Delete
case support if support.isSystemAdmin =>
val aclAccess = getAccessFromAcl(auth, recordName, recordType, zone)
if (aclAccess == AccessLevel.NoAccess) AccessLevel.Read else aclAccess
if (aclAccess == AccessLevel.NoAccess)
AccessLevel.Read
else aclAccess
case globalAclUser
if globalAcls.isAuthorized(globalAclUser, recordName, recordType, zone, recordData) =>
AccessLevel.Delete

View File

@ -27,6 +27,8 @@ trait AccessValidationsAlgebra {
def canSeeZone(auth: AuthPrincipal, zone: Zone): Either[Throwable, Unit]
def canSeeZoneChange(auth: AuthPrincipal, zone: Zone): Either[Throwable, Unit]
def canChangeZone(
auth: AuthPrincipal,
zoneName: String,
@ -47,6 +49,7 @@ trait AccessValidationsAlgebra {
recordType: RecordType,
zone: Zone,
recordOwnerGroupId: Option[String],
superUserCanUpdateOwnerGroup: Boolean = false,
newRecordData: List[RecordData] = List.empty
): Either[Throwable, Unit]

View File

@ -34,8 +34,6 @@ import vinyldns.core.queue.MessageQueue
class BatchChangeConverter(batchChangeRepo: BatchChangeRepository, messageQueue: MessageQueue)
extends BatchChangeConverterAlgebra {
private val notExistCompletedMessage: String = "This record does not exist." +
"No further action is required."
private val failedMessage: String = "Error queueing RecordSetChange for processing"
private val logger = LoggerFactory.getLogger(classOf[BatchChangeConverter])
@ -125,7 +123,7 @@ class BatchChangeConverter(batchChangeRepo: BatchChangeRepository, messageQueue:
change match {
case _: SingleDeleteRRSetChange if change.recordSetId.isEmpty =>
// Mark as Complete since we don't want to throw it as an error
change.withDoesNotExistMessage(notExistCompletedMessage)
change.withDoesNotExistMessage
case _ =>
// Failure here means there was a message queue issue for this change
change.withFailureMessage(failedMessage)
@ -138,9 +136,10 @@ class BatchChangeConverter(batchChangeRepo: BatchChangeRepository, messageQueue:
def storeQueuingFailures(batchChange: BatchChange): BatchResult[Unit] = {
// Update if Single change is Failed or if a record that does not exist is deleted
val failedAndNotExistsChanges = batchChange.changes.collect {
case change if change.status == SingleChangeStatus.Failed || change.systemMessage.contains(notExistCompletedMessage) => change
case change if change.status == SingleChangeStatus.Failed => change
}
batchChangeRepo.updateSingleChanges(failedAndNotExistsChanges).as(())
val storeChanges = batchChangeRepo.updateSingleChanges(failedAndNotExistsChanges).as(())
storeChanges
}.toBatchResult
def createRecordSetChangesForBatch(
@ -215,7 +214,7 @@ class BatchChangeConverter(batchChangeRepo: BatchChangeRepository, messageQueue:
}
}
// New record set for add/update or single delete
// New record set for add/update/full deletes
lazy val newRecordSet = {
val firstAddChange = singleChangeNel.collect {
case sac: SingleAddChange => sac
@ -237,6 +236,33 @@ class BatchChangeConverter(batchChangeRepo: BatchChangeRepository, messageQueue:
Instant.now.truncatedTo(ChronoUnit.MILLIS),
None,
proposedRecordData.toList,
ownerGroupId = setOwnerGroupId,
recordSetGroupChange = Some(OwnerShipTransfer(ownerShipTransferStatus = OwnerShipTransferStatus.None))
)
}
}
// New record set for single delete which exists in dns backend but not in vinyl
lazy val newDeleteRecordSet = {
val firstDeleteChange = singleChangeNel.collect {
case sad: SingleDeleteRRSetChange => sad
}.headOption
val newTtlRecordNameTuple = firstDeleteChange
.map(del => del.recordName)
.orElse(existingRecordSet.map(rs => Some(rs.name)))
newTtlRecordNameTuple.collect{
case Some(recordName) =>
RecordSet(
zone.id,
recordName,
recordType,
7200L,
RecordSetStatus.Pending,
Instant.now.truncatedTo(ChronoUnit.MILLIS),
None,
proposedRecordData.toList,
ownerGroupId = setOwnerGroupId
)
}
@ -257,7 +283,12 @@ class BatchChangeConverter(batchChangeRepo: BatchChangeRepository, messageQueue:
existingRs <- existingRecordSet
newRs <- newRecordSet
} yield RecordSetChangeGenerator.forUpdate(existingRs, newRs, zone, userId, singleChangeIds)
case _ => None // This case should never happen
case OutOfSync =>
newDeleteRecordSet.map { newDelRs =>
RecordSetChangeGenerator.forOutOfSync(newDelRs, zone, userId, singleChangeIds)
}
case _ =>
None // This case should never happen
}
}
}

View File

@ -44,12 +44,14 @@ object BatchChangeInput {
sealed trait ChangeInput {
val inputName: String
val typ: RecordType
val systemMessage: Option[String]
def asNewStoredChange(errors: NonEmptyList[DomainValidationError], defaultTtl: Long): SingleChange
}
final case class AddChangeInput(
inputName: String,
typ: RecordType,
systemMessage: Option[String],
ttl: Option[Long],
record: RecordData
) extends ChangeInput {
@ -68,7 +70,7 @@ final case class AddChangeInput(
knownTtl,
record,
SingleChangeStatus.NeedsReview,
None,
systemMessage,
None,
None,
errors.toList.map(SingleChangeError(_))
@ -79,6 +81,7 @@ final case class AddChangeInput(
final case class DeleteRRSetChangeInput(
inputName: String,
typ: RecordType,
systemMessage: Option[String],
record: Option[RecordData]
) extends ChangeInput {
def asNewStoredChange(
@ -93,7 +96,7 @@ final case class DeleteRRSetChangeInput(
typ,
record,
SingleChangeStatus.NeedsReview,
None,
systemMessage,
None,
None,
errors.toList.map(SingleChangeError(_))
@ -104,6 +107,7 @@ object AddChangeInput {
def apply(
inputName: String,
typ: RecordType,
systemMessage: Option[String],
ttl: Option[Long],
record: RecordData
): AddChangeInput = {
@ -111,28 +115,29 @@ object AddChangeInput {
case PTR => inputName
case _ => ensureTrailingDot(inputName)
}
new AddChangeInput(transformName, typ, ttl, record)
new AddChangeInput(transformName, typ, systemMessage, ttl, record)
}
def apply(sc: SingleAddChange): AddChangeInput =
AddChangeInput(sc.inputName, sc.typ, Some(sc.ttl), sc.recordData)
AddChangeInput(sc.inputName, sc.typ, sc.systemMessage, Some(sc.ttl), sc.recordData)
}
object DeleteRRSetChangeInput {
def apply(
inputName: String,
typ: RecordType,
systemMessage: Option[String],
record: Option[RecordData] = None
): DeleteRRSetChangeInput = {
val transformName = typ match {
case PTR => inputName
case _ => ensureTrailingDot(inputName)
}
new DeleteRRSetChangeInput(transformName, typ, record)
new DeleteRRSetChangeInput(transformName, typ, systemMessage, record)
}
def apply(sc: SingleDeleteRRSetChange): DeleteRRSetChangeInput =
DeleteRRSetChangeInput(sc.inputName, sc.typ, sc.recordData)
DeleteRRSetChangeInput(sc.inputName, sc.typ, sc.systemMessage, sc.recordData)
}
object ChangeInputType extends Enumeration {

View File

@ -20,6 +20,7 @@ import cats.data.Validated.{Invalid, Valid}
import cats.data._
import cats.effect._
import cats.implicits._
import java.time.Instant
import java.time.temporal.ChronoUnit
import org.slf4j.{Logger, LoggerFactory}
@ -33,6 +34,7 @@ import vinyldns.core.domain.auth.AuthPrincipal
import vinyldns.core.domain.batch.BatchChangeApprovalStatus.BatchChangeApprovalStatus
import vinyldns.core.domain.batch._
import vinyldns.core.domain.batch.BatchChangeApprovalStatus._
import vinyldns.core.domain.batch.BatchChangeStatus.BatchChangeStatus
import vinyldns.core.domain.{CnameAtZoneApexError, SingleChangeError, UserIsNotAuthorizedError, ZoneDiscoveryError}
import vinyldns.core.domain.membership.{Group, GroupRepository, ListUsersResults, User, UserRepository}
import vinyldns.core.domain.record.RecordType._
@ -340,7 +342,7 @@ class BatchChangeService(
): ValidatedBatch[ChangeForValidation] =
changes.mapValid { change =>
change.typ match {
case A | AAAA | CNAME | MX | TXT => forwardZoneDiscovery(change, zoneMap)
case A | AAAA | CNAME | MX | TXT | NS | NAPTR | SRV => forwardZoneDiscovery(change, zoneMap)
case PTR if validateIpv4Address(change.inputName).isValid =>
ptrIpv4ZoneDiscovery(change, zoneMap)
case PTR if validateIpv6Address(change.inputName).isValid =>
@ -470,14 +472,15 @@ class BatchChangeService(
val hardErrorsPresent = allErrors.exists(_.isFatal)
val noErrors = allErrors.isEmpty
val isScheduled = batchChangeInput.scheduledTime.isDefined && this.scheduledChangesEnabled
val isNSRecordsPresent = batchChangeInput.changes.exists(_.typ == NS)
if (hardErrorsPresent) {
// Always error out
errorResponse
} else if (noErrors && !isScheduled) {
} else if (noErrors && !isScheduled && !isNSRecordsPresent) {
// There are no errors and this is not scheduled, so process immediately
processNowResponse
} else if (this.manualReviewEnabled && allowManualReview) {
} else if (this.manualReviewEnabled && allowManualReview || isNSRecordsPresent) {
if ((noErrors && isScheduled) || batchChangeInput.ownerGroupId.isDefined) {
// There are no errors and this is scheduled
// or we have soft errors and owner group is defined
@ -579,15 +582,22 @@ class BatchChangeService(
def listBatchChangeSummaries(
auth: AuthPrincipal,
userName: Option[String] = None,
dateTimeStartRange: Option[String] = None,
dateTimeEndRange: Option[String] = None,
startFrom: Option[Int] = None,
maxItems: Int = 100,
ignoreAccess: Boolean = false,
batchStatus: Option[BatchChangeStatus] = None,
approvalStatus: Option[BatchChangeApprovalStatus] = None
): BatchResult[BatchChangeSummaryList] = {
val userId = if (ignoreAccess && auth.isSystemAdmin) None else Some(auth.userId)
val submitterUserName = if(userName.isDefined && userName.get.isEmpty) None else userName
val startDateTime = if(dateTimeStartRange.isDefined && dateTimeStartRange.get.isEmpty) None else dateTimeStartRange
val endDateTime = if(dateTimeEndRange.isDefined && dateTimeEndRange.get.isEmpty) None else dateTimeEndRange
for {
listResults <- batchChangeRepo
.getBatchChangeSummaries(userId, startFrom, maxItems, approvalStatus)
.getBatchChangeSummaries(userId, submitterUserName, startDateTime, endDateTime, startFrom, maxItems, batchStatus, approvalStatus)
.toBatchResult
rsOwnerGroupIds = listResults.batchChanges.flatMap(_.ownerGroupId).toSet
rsOwnerGroups <- groupRepository.getGroups(rsOwnerGroupIds).toBatchResult
@ -604,7 +614,10 @@ class BatchChangeService(
listWithGroupNames = listResults.copy(
batchChanges = summariesWithReviewerUserNames,
ignoreAccess = ignoreAccess,
approvalStatus = approvalStatus
approvalStatus = approvalStatus,
userName = userName,
dateTimeStartRange = dateTimeStartRange,
dateTimeEndRange = dateTimeEndRange
)
} yield listWithGroupNames
}

View File

@ -19,6 +19,7 @@ package vinyldns.api.domain.batch
import vinyldns.api.domain.batch.BatchChangeInterfaces.BatchResult
import vinyldns.core.domain.auth.AuthPrincipal
import vinyldns.core.domain.batch.BatchChangeApprovalStatus.BatchChangeApprovalStatus
import vinyldns.core.domain.batch.BatchChangeStatus.BatchChangeStatus
import vinyldns.core.domain.batch.{BatchChange, BatchChangeInfo, BatchChangeSummaryList}
// $COVERAGE-OFF$
@ -33,9 +34,13 @@ trait BatchChangeServiceAlgebra {
def listBatchChangeSummaries(
auth: AuthPrincipal,
userName: Option[String] = None,
dateTimeStartRange: Option[String] = None,
dateTimeEndRange: Option[String] = None,
startFrom: Option[Int],
maxItems: Int,
ignoreAccess: Boolean,
batchStatus: Option[BatchChangeStatus],
approvalStatus: Option[BatchChangeApprovalStatus]
): BatchResult[BatchChangeSummaryList]

View File

@ -16,7 +16,6 @@
package vinyldns.api.domain.batch
import java.net.InetAddress
import java.time.Instant
import java.time.temporal.ChronoUnit
import cats.data._
@ -27,11 +26,16 @@ import vinyldns.api.domain.access.AccessValidationsAlgebra
import vinyldns.core.domain.auth.AuthPrincipal
import vinyldns.api.domain.batch.BatchChangeInterfaces._
import vinyldns.api.domain.batch.BatchTransformations._
import vinyldns.api.domain.zone.ZoneRecordValidations.isStringInRegexList
import vinyldns.api.domain.zone.ZoneRecordValidations
import vinyldns.core.Messages.{nonExistentRecordDataDeleteMessage, nonExistentRecordDeleteMessage}
import vinyldns.core.domain.DomainHelpers.omitTrailingDot
import vinyldns.core.domain.record._
import vinyldns.core.domain._
import vinyldns.core.domain.batch.{BatchChange, BatchChangeApprovalStatus, OwnerType, RecordKey, RecordKeyData}
import vinyldns.core.domain.membership.Group
import vinyldns.core.domain.zone.Zone
import scala.util.matching.Regex
trait BatchChangeValidationsAlgebra {
@ -81,7 +85,8 @@ class BatchChangeValidations(
highValueDomainConfig: HighValueDomainConfig,
manualReviewConfig: ManualReviewConfig,
batchChangeConfig: BatchChangeConfig,
scheduledChangesConfig: ScheduledChangesConfig
scheduledChangesConfig: ScheduledChangesConfig,
approvedNameServers: List[Regex]
) extends BatchChangeValidationsAlgebra {
import RecordType._
@ -214,8 +219,8 @@ class BatchChangeValidations(
}
def validateDeleteRRSetChangeInput(
deleteRRSetChangeInput: DeleteRRSetChangeInput,
isApproved: Boolean
deleteRRSetChangeInput: DeleteRRSetChangeInput,
isApproved: Boolean
): SingleValidation[Unit] = {
val validRecord = deleteRRSetChangeInput.record match {
case Some(recordData) => validateRecordData(recordData, deleteRRSetChangeInput)
@ -241,14 +246,17 @@ class BatchChangeValidations(
case ptr: PTRData => validateHostName(ptr.ptrdname).asUnit
case txt: TXTData => validateTxtTextLength(txt.text).asUnit
case mx: MXData =>
validateMxPreference(mx.preference).asUnit |+| validateHostName(mx.exchange).asUnit
validateMX_NAPTR_SRVData(mx.preference, "preference", "MX").asUnit |+| validateHostName(mx.exchange).asUnit
case ns: NSData => validateHostName(ns.nsdname).asUnit
case naptr: NAPTRData => validateMX_NAPTR_SRVData(naptr.preference, "preference", "NAPTR").asUnit |+| validateMX_NAPTR_SRVData(naptr.order, "order", "NAPTR").asUnit |+| validateHostName(naptr.replacement).asUnit |+| validateNaptrFlag(naptr.flags).asUnit |+| validateNaptrRegexp(naptr.regexp).asUnit
case srv: SRVData => validateMX_NAPTR_SRVData(srv.priority, "priority", "SRV").asUnit |+| validateMX_NAPTR_SRVData(srv.port, "port", "SRV").asUnit |+| validateMX_NAPTR_SRVData(srv.weight, "weight", "SRV").asUnit |+| validateHostName(srv.target).asUnit
case other =>
InvalidBatchRecordType(other.toString, SupportedBatchChangeRecordTypes.get).invalidNel[Unit]
}
def validateInputName(change: ChangeInput, isApproved: Boolean): SingleValidation[Unit] = {
val typedChecks = change.typ match {
case A | AAAA | MX =>
case A | AAAA | MX | NS | NAPTR | SRV =>
validateHostName(change.inputName).asUnit |+| notInReverseZone(change)
case CNAME | TXT =>
validateHostName(change.inputName).asUnit
@ -307,35 +315,34 @@ class BatchChangeValidations(
else
().validNel
def matchRecordData(existingRecordSetData: List[RecordData], recordData: RecordData): Boolean =
existingRecordSetData.exists { rd =>
(rd, recordData) match {
case (AAAAData(rdAddress), AAAAData(proposedAddress)) =>
InetAddress.getByName(proposedAddress).getHostName == InetAddress
.getByName(rdAddress)
.getHostName
case _ => rd == recordData
}
def matchRecordData(existingRecordSetData: List[RecordData], recordData: RecordData): Boolean = {
existingRecordSetData.par.exists { rd =>
rd == recordData
}
}
def ensureRecordExists(
change: ChangeForValidation,
groupedChanges: ChangeForValidationMap
): SingleValidation[Unit] =
change: ChangeForValidation,
groupedChanges: ChangeForValidationMap
): Boolean = {
change match {
// For DeleteRecord inputs, need to verify that the record data actually exists
case DeleteRRSetChangeForValidation(
_,
_,
DeleteRRSetChangeInput(inputName, _, Some(recordData))
)
if !groupedChanges
.getExistingRecordSet(change.recordKey)
.exists(rs => matchRecordData(rs.records, recordData)) =>
DeleteRecordDataDoesNotExist(inputName, recordData).invalidNel
case DeleteRRSetChangeForValidation(_, _, DeleteRRSetChangeInput(_, _, _, Some(recordData)))
if !groupedChanges
.getExistingRecordSet(change.recordKey)
.exists(rs => matchRecordData(rs.records, recordData)) =>
false
case _ =>
().validNel
true
}
}
def updateSystemMessage(changeInput: ChangeInput, systemMessage: String): ChangeInput = {
changeInput match {
case dci: DeleteRRSetChangeInput => dci.copy(systemMessage = Some(systemMessage))
case _ => changeInput
}
}
def validateDeleteWithContext(
change: ChangeForValidation,
@ -344,15 +351,34 @@ class BatchChangeValidations(
isApproved: Boolean
): SingleValidation[ChangeForValidation] = {
val validations =
groupedChanges.getExistingRecordSet(change.recordKey) match {
case Some(rs) =>
userCanDeleteRecordSet(change, auth, rs.ownerGroupId, rs.records) |+|
zoneDoesNotRequireManualReview(change, isApproved) |+|
ensureRecordExists(change, groupedChanges)
case None => RecordDoesNotExist(change.inputChange.inputName).validNel
}
validations.map(_ => change)
val recordData = change match {
case AddChangeForValidation(_, _, inputChange, _, _) => inputChange.record.toString
case DeleteRRSetChangeForValidation(_, _, inputChange) => inputChange.record.map(_.toString).getOrElse("")
}
val addInBatch = groupedChanges.getProposedAdds(change.recordKey)
val isSameRecordUpdateInBatch = recordData.nonEmpty && addInBatch.contains(RecordData.fromString(recordData, change.inputChange.typ).get)
// Perform the system message update based on the condition
val updatedChange = if (groupedChanges.getExistingRecordSet(change.recordKey).isEmpty && !isSameRecordUpdateInBatch) {
val updatedChangeInput = updateSystemMessage(change.inputChange, nonExistentRecordDeleteMessage)
change.withUpdatedInputChange(updatedChangeInput)
} else if (!ensureRecordExists(change, groupedChanges)) {
val updatedChangeInput = updateSystemMessage(change.inputChange, nonExistentRecordDataDeleteMessage)
change.withUpdatedInputChange(updatedChangeInput)
} else {
change
}
val validations = groupedChanges.getExistingRecordSet(updatedChange.recordKey) match {
case Some(rs) =>
userCanDeleteRecordSet(updatedChange, auth, rs.ownerGroupId, rs.records) |+|
zoneDoesNotRequireManualReview(updatedChange, isApproved)
case None =>
if (isSameRecordUpdateInBatch) InvalidUpdateRequest(updatedChange.inputChange.inputName).invalidNel else ().validNel
}
validations.map(_ => updatedChange)
}
def validateAddUpdateWithContext(
@ -381,7 +407,7 @@ class BatchChangeValidations(
) |+|
zoneDoesNotRequireManualReview(change, isApproved)
case None =>
RecordDoesNotExist(change.inputChange.inputName).invalidNel
InvalidUpdateRequest(change.inputChange.inputName).invalidNel
}
}
@ -396,18 +422,38 @@ class BatchChangeValidations(
auth: AuthPrincipal,
isApproved: Boolean
): SingleValidation[ChangeForValidation] = {
// To handle add and delete for the record with same record data is present in the batch
val recordData = change match {
case AddChangeForValidation(_, _, inputChange, _, _) => inputChange.record.toString
case DeleteRRSetChangeForValidation(_, _, inputChange) => inputChange.record.map(_.toString).getOrElse("")
}
val addInBatch = groupedChanges.getProposedAdds(change.recordKey)
val isSameRecordUpdateInBatch = recordData.nonEmpty && addInBatch.contains(RecordData.fromString(recordData, change.inputChange.typ).get)
// Perform the system message update based on the condition
val updatedChange = if (groupedChanges.getExistingRecordSet(change.recordKey).isEmpty && !isSameRecordUpdateInBatch) {
val updatedChangeInput = updateSystemMessage(change.inputChange, nonExistentRecordDeleteMessage)
change.withUpdatedInputChange(updatedChangeInput)
} else if (!ensureRecordExists(change, groupedChanges)) {
val updatedChangeInput = updateSystemMessage(change.inputChange, nonExistentRecordDataDeleteMessage)
change.withUpdatedInputChange(updatedChangeInput)
} else {
change
}
val validations =
groupedChanges.getExistingRecordSet(change.recordKey) match {
groupedChanges.getExistingRecordSet(updatedChange.recordKey) match {
case Some(rs) =>
val adds = groupedChanges.getProposedAdds(change.recordKey).toList
userCanUpdateRecordSet(change, auth, rs.ownerGroupId, adds) |+|
zoneDoesNotRequireManualReview(change, isApproved) |+|
ensureRecordExists(change, groupedChanges)
val adds = groupedChanges.getProposedAdds(updatedChange.recordKey).toList
userCanUpdateRecordSet(updatedChange, auth, rs.ownerGroupId, adds) |+|
zoneDoesNotRequireManualReview(updatedChange, isApproved)
case None =>
RecordDoesNotExist(change.inputChange.inputName).validNel
if(isSameRecordUpdateInBatch) InvalidUpdateRequest(updatedChange.inputChange.inputName).invalidNel else ().validNel
}
validations.map(_ => change)
validations.map(_ => updatedChange)
}
def validateAddWithContext(
@ -418,8 +464,10 @@ class BatchChangeValidations(
ownerGroupId: Option[String]
): SingleValidation[ChangeForValidation] = {
val typedValidations = change.inputChange.typ match {
case A | AAAA | MX =>
case A | AAAA | MX | SRV | NAPTR =>
newRecordSetIsNotDotted(change)
case NS =>
newRecordSetIsNotDotted(change) |+| nsValidations(change.inputChange.record, change.recordName, change.zone, approvedNameServers)
case CNAME =>
cnameHasUniqueNameInBatch(change, groupedChanges) |+|
newRecordSetIsNotDotted(change)
@ -429,8 +477,29 @@ class BatchChangeValidations(
InvalidBatchRecordType(other.toString, SupportedBatchChangeRecordTypes.get).invalidNel
}
// To handle add and delete for the record with same record data is present in the batch
val recordData = change match {
case AddChangeForValidation(_, _, inputChange, _, _) => inputChange.record.toString
}
val deletes = groupedChanges.getProposedDeletes(change.recordKey)
val isDeleteExists = deletes.nonEmpty
val isSameRecordUpdateInBatch = if(recordData.nonEmpty){
if(deletes.contains(RecordData.fromString(recordData, change.inputChange.typ).get)) true else false
} else false
val commonValidations: SingleValidation[Unit] = {
groupedChanges.getExistingRecordSet(change.recordKey) match {
case Some(_) =>
().validNel
case None =>
if(isSameRecordUpdateInBatch) InvalidUpdateRequest(change.inputChange.inputName).invalidNel else ().validNel
}
}
val validations =
typedValidations |+|
commonValidations |+|
noIncompatibleRecordExists(change, groupedChanges) |+|
userCanAddRecordSet(change, auth) |+|
recordDoesNotExist(
@ -440,7 +509,7 @@ class BatchChangeValidations(
change.inputChange.typ,
change.inputChange.record,
groupedChanges,
isApproved
isDeleteExists
) |+|
ownerGroupProvidedIfNeeded(change, None, ownerGroupId) |+|
zoneDoesNotRequireManualReview(change, isApproved)
@ -483,13 +552,14 @@ class BatchChangeValidations(
typ: RecordType,
recordData: RecordData,
groupedChanges: ChangeForValidationMap,
isApproved: Boolean
isDeleteExist: Boolean
): SingleValidation[Unit] = {
val record = groupedChanges.getExistingRecordSetData(RecordKeyData(zoneId, recordName, typ, recordData))
if(record.isDefined) {
record.get.records.contains(recordData) match {
case true => ().validNel
case false => RecordAlreadyExists(inputName, recordData, isApproved).invalidNel}
case false => if(isDeleteExist) ().validNel else RecordAlreadyExists(inputName).invalidNel
}
} else ().validNel
}
@ -561,6 +631,7 @@ class BatchChangeValidations(
input.inputChange.typ,
input.zone,
ownerGroupId,
false,
addRecords
)
result
@ -697,4 +768,46 @@ class BatchChangeValidations(
change.inputChange.inputName
)
}
def nsValidations(
newRecordSetData: RecordData,
newRecordSetName: String,
zone: Zone,
approvedNameServers: List[Regex]
): SingleValidation[Unit] = {
isNotOrigin(
newRecordSetName,
zone,
s"Record with name $newRecordSetName is an NS record at apex and cannot be added"
)
containsApprovedNameServers(newRecordSetData, approvedNameServers)
}
def isNotOrigin(recordSet: String, zone: Zone, err: String): SingleValidation[Unit] =
if(!isOriginRecord(recordSet, omitTrailingDot(zone.name))) ().validNel else InvalidBatchRequest(err).invalidNel
def isOriginRecord(recordSetName: String, zoneName: String): Boolean =
recordSetName == "@" || omitTrailingDot(recordSetName) == omitTrailingDot(zoneName)
def containsApprovedNameServers(
nsRecordSet: RecordData,
approvedNameServers: List[Regex]
): SingleValidation[Unit] = {
val nsData = nsRecordSet match {
case ns: NSData => ns
case _ => ??? // this would never be the case
}
isApprovedNameServer(approvedNameServers, nsData)
}
def isApprovedNameServer(
approvedServerList: List[Regex],
nsData: NSData
): SingleValidation[Unit] =
if (isStringInRegexList(approvedServerList, nsData.nsdname.fqdn)) {
().validNel
} else {
NotApprovedNSError(nsData.nsdname.fqdn).invalidNel
}
}

View File

@ -16,7 +16,6 @@
package vinyldns.api.domain.batch
import java.net.InetAddress
import java.util.UUID
import vinyldns.api.domain.ReverseZoneHelpers
@ -24,13 +23,13 @@ import vinyldns.api.domain.batch.BatchChangeInterfaces.ValidatedBatch
import vinyldns.api.domain.batch.BatchTransformations.LogicalChangeType.LogicalChangeType
import vinyldns.api.backend.dns.DnsConversions.getIPv6FullReverseName
import vinyldns.core.domain.batch._
import vinyldns.core.domain.record.{AAAAData, RecordData, RecordSet, RecordSetChange}
import vinyldns.core.domain.record.{RecordData, RecordSet, RecordSetChange}
import vinyldns.core.domain.record.RecordType._
import vinyldns.core.domain.zone.Zone
import vinyldns.core.domain.record.RecordType.RecordType
object SupportedBatchChangeRecordTypes {
val supportedTypes = Set(A, AAAA, CNAME, PTR, TXT, MX)
val supportedTypes = Set(A, AAAA, CNAME, PTR, TXT, MX, NS, SRV, NAPTR)
def get: Set[RecordType] = supportedTypes
}
@ -82,6 +81,7 @@ object BatchTransformations {
val recordKey = RecordKey(zone.id, recordName, inputChange.typ)
def asStoredChange(changeId: Option[String] = None): SingleChange
def isAddChangeForValidation: Boolean
def withUpdatedInputChange(inputChange: ChangeInput): ChangeForValidation
}
object ChangeForValidation {
@ -118,7 +118,7 @@ object BatchTransformations {
ttl,
inputChange.record,
SingleChangeStatus.Pending,
None,
inputChange.systemMessage,
None,
None,
List.empty,
@ -127,6 +127,10 @@ object BatchTransformations {
}
def isAddChangeForValidation: Boolean = true
def withUpdatedInputChange(inputChange: ChangeInput): ChangeForValidation = {
this.copy(inputChange = inputChange.asInstanceOf[AddChangeInput])
}
}
final case class DeleteRRSetChangeForValidation(
@ -143,7 +147,7 @@ object BatchTransformations {
inputChange.typ,
inputChange.record,
SingleChangeStatus.Pending,
None,
inputChange.systemMessage,
None,
None,
List.empty,
@ -151,6 +155,10 @@ object BatchTransformations {
)
def isAddChangeForValidation: Boolean = false
def withUpdatedInputChange(inputChange: ChangeInput): ChangeForValidation = {
this.copy(inputChange = inputChange.asInstanceOf[DeleteRRSetChangeInput])
}
}
final case class BatchConversionOutput(
@ -180,6 +188,9 @@ object BatchTransformations {
def getProposedAdds(recordKey: RecordKey): Set[RecordData] =
innerMap.get(recordKey).map(_.proposedAdds).toSet.flatten
def getProposedDeletes(recordKey: RecordKey): Set[RecordData] =
innerMap.get(recordKey).map(_.proposedDeletes).toSet.flatten
// The new, net record data factoring in existing records, deletes and adds
// If record is not edited in batch, will fallback to look up record in existing
// records
@ -194,13 +205,6 @@ object BatchTransformations {
}
object ValidationChanges {
def matchRecordData(existingRecord: RecordData, recordData: String): Boolean =
existingRecord match {
case AAAAData(address) =>
InetAddress.getByName(address).getHostName ==
InetAddress.getByName(recordData).getHostName
case _ => false
}
def apply(
changes: List[ChangeForValidation],
@ -220,16 +224,11 @@ object BatchTransformations {
case DeleteRRSetChangeForValidation(
_,
_,
DeleteRRSetChangeInput(_, AAAA, Some(AAAAData(address)))
) =>
existingRecords.filter(r => matchRecordData(r, address))
case DeleteRRSetChangeForValidation(
_,
_,
DeleteRRSetChangeInput(_, _, Some(recordData))
DeleteRRSetChangeInput(_, _, _, Some(recordData))
) =>
Set(recordData)
case _: DeleteRRSetChangeForValidation => existingRecords
case _: DeleteRRSetChangeForValidation =>
existingRecords
}
.toSet
.flatten
@ -237,26 +236,45 @@ object BatchTransformations {
// New proposed record data (assuming all validations pass)
val proposedRecordData = existingRecords -- deleteChangeSet ++ addChangeRecordDataSet
// Note: "Update" where an Add and DeleteRecordSet is provided for a DNS record that does not exist will be
// treated as a logical Add since the delete validation will fail (on record does not exist)
val logicalChangeType = (addChangeRecordDataSet.nonEmpty, deleteChangeSet.nonEmpty) match {
case (true, true) => LogicalChangeType.Update
case (true, false) => LogicalChangeType.Add
case (false, true) =>
if ((existingRecords -- deleteChangeSet).isEmpty) {
LogicalChangeType.FullDelete
case (true, true) =>
if (existingRecords.isEmpty) {
// Note: "Add" where an Add and DeleteRecordSet is provided for a DNS record that does not exist.
// Adds the record if it doesn't exist and ignores the delete.
LogicalChangeType.Add
} else {
// Note: "Update" where an Add and DeleteRecordSet is provided for a DNS record that exist, but record data for DeleteRecordSet does not exist.
// Updates the record and ignores the delete.
LogicalChangeType.Update
}
case (false, false) => LogicalChangeType.NotEditedInBatch
case (true, false) => LogicalChangeType.Add
case (false, true) =>
if (existingRecords == deleteChangeSet) {
LogicalChangeType.FullDelete
} else if (existingRecords.nonEmpty) {
LogicalChangeType.Update
} else {
LogicalChangeType.OutOfSync
}
case (false, false) =>
if(changes.exists {
case _: DeleteRRSetChangeForValidation => true
case _ => false
}
){
LogicalChangeType.OutOfSync
} else {
LogicalChangeType.NotEditedInBatch
}
}
new ValidationChanges(addChangeRecordDataSet, proposedRecordData, logicalChangeType)
new ValidationChanges(addChangeRecordDataSet, deleteChangeSet, proposedRecordData, logicalChangeType)
}
}
final case class ValidationChanges(
proposedAdds: Set[RecordData],
proposedDeletes: Set[RecordData],
proposedRecordData: Set[RecordData],
logicalChangeType: LogicalChangeType
)
@ -269,6 +287,6 @@ object BatchTransformations {
object LogicalChangeType extends Enumeration {
type LogicalChangeType = Value
val Add, FullDelete, Update, NotEditedInBatch = Value
val Add, FullDelete, Update, NotEditedInBatch, OutOfSync = Value
}
}

View File

@ -104,14 +104,16 @@ object UserInfo {
case class UserResponseInfo(
id: String,
userName: Option[String] = None
userName: Option[String] = None,
groupId: Set[String] = Set.empty
)
object UserResponseInfo {
def apply(user: User): UserResponseInfo =
def apply(user: User , group: Group): UserResponseInfo =
UserResponseInfo(
id = user.id,
userName = Some(user.userName)
userName = Some(user.userName),
groupId = Set(group.id)
)
}
@ -158,8 +160,8 @@ final case class ListAdminsResponse(admins: Seq[UserInfo])
final case class ListGroupChangesResponse(
changes: Seq[GroupChangeInfo],
startFrom: Option[String] = None,
nextId: Option[String] = None,
startFrom: Option[Int] = None,
nextId: Option[Int] = None,
maxItems: Int
)
@ -178,6 +180,8 @@ final case class GroupAlreadyExistsError(msg: String) extends Throwable(msg)
final case class GroupValidationError(msg: String) extends Throwable(msg)
final case class EmailValidationError(msg: String) extends Throwable(msg)
final case class UserNotFoundError(msg: String) extends Throwable(msg)
final case class InvalidGroupError(msg: String) extends Throwable(msg)

View File

@ -20,6 +20,7 @@ import cats.effect.IO
import cats.implicits._
import scalikejdbc.DB
import vinyldns.api.Interfaces._
import vinyldns.api.config.ValidEmailConfig
import vinyldns.api.repository.ApiDataAccessor
import vinyldns.core.domain.auth.AuthPrincipal
import vinyldns.core.domain.membership.LockStatus.LockStatus
@ -30,14 +31,15 @@ import vinyldns.core.Messages._
import vinyldns.mysql.TransactionProvider
object MembershipService {
def apply(dataAccessor: ApiDataAccessor): MembershipService =
def apply(dataAccessor: ApiDataAccessor,emailConfig:ValidEmailConfig): MembershipService =
new MembershipService(
dataAccessor.groupRepository,
dataAccessor.userRepository,
dataAccessor.membershipRepository,
dataAccessor.zoneRepository,
dataAccessor.groupChangeRepository,
dataAccessor.recordSetRepository
dataAccessor.recordSetRepository,
emailConfig
)
}
@ -47,7 +49,8 @@ class MembershipService(
membershipRepo: MembershipRepository,
zoneRepo: ZoneRepository,
groupChangeRepo: GroupChangeRepository,
recordSetRepo: RecordSetRepository
recordSetRepo: RecordSetRepository,
validDomains: ValidEmailConfig
) extends MembershipServiceAlgebra with TransactionProvider {
import MembershipValidations._
@ -58,6 +61,7 @@ class MembershipService(
val nonAdminMembers = inputGroup.memberIds.diff(adminMembers)
for {
_ <- groupValidation(newGroup)
_ <- emailValidation(newGroup.email)
_ <- hasMembersAndAdmins(newGroup).toResult
_ <- groupWithSameNameDoesNotExist(newGroup.name)
_ <- usersExist(newGroup.memberIds)
@ -65,6 +69,11 @@ class MembershipService(
} yield newGroup
}
def listEmailDomains(authPrincipal: AuthPrincipal): Result[List[String]] = {
val validEmailDomains = validDomains.valid_domains
IO(validEmailDomains).toResult
}
def updateGroup(
groupId: String,
name: String,
@ -78,6 +87,7 @@ class MembershipService(
existingGroup <- getExistingGroup(groupId)
newGroup = existingGroup.withUpdates(name, email, description, memberIds, adminUserIds)
_ <- groupValidation(newGroup)
_ <- emailValidation(newGroup.email)
_ <- canEditGroup(existingGroup, authPrincipal).toResult
addedAdmins = newGroup.adminUserIds.diff(existingGroup.adminUserIds)
// new non-admin members ++ admins converted to non-admins
@ -244,8 +254,10 @@ class MembershipService(
.getGroupChange(groupChangeId)
.toResult[Option[GroupChange]]
_ <- isGroupChangePresent(result).toResult
_ <- canSeeGroup(result.get.newGroup.id, authPrincipal).toResult
groupChangeMessage <- determineGroupDifference(Seq(result.get))
_ <- canSeeGroupChange(result.get.newGroup.id, authPrincipal).toResult
allUserIds = getGroupUserIds(Seq(result.get))
allUserMap <- getUsers(allUserIds).map(_.users.map(x => x.id -> x.userName).toMap.withDefaultValue("unknown user"))
groupChangeMessage <- determineGroupDifference(Seq(result.get), allUserMap)
groupChanges = (groupChangeMessage, Seq(result.get)).zipped.map{ (a, b) => b.copy(groupChangeMessage = Some(a)) }
userIds = Seq(result.get).map(_.userId).toSet
users <- getUsers(userIds).map(_.users)
@ -254,16 +266,18 @@ class MembershipService(
def getGroupActivity(
groupId: String,
startFrom: Option[String],
startFrom: Option[Int],
maxItems: Int,
authPrincipal: AuthPrincipal
): Result[ListGroupChangesResponse] =
for {
_ <- canSeeGroup(groupId, authPrincipal).toResult
_ <- canSeeGroupChange(groupId, authPrincipal).toResult
result <- groupChangeRepo
.getGroupChanges(groupId, startFrom, maxItems)
.toResult[ListGroupChangesResults]
groupChangeMessage <- determineGroupDifference(result.changes)
allUserIds = getGroupUserIds(result.changes)
allUserMap <- getUsers(allUserIds).map(_.users.map(x => x.id -> x.userName).toMap.withDefaultValue("unknown user"))
groupChangeMessage <- determineGroupDifference(result.changes, allUserMap)
groupChanges = (groupChangeMessage, result.changes).zipped.map{ (a, b) => b.copy(groupChangeMessage = Some(a)) }
userIds = result.changes.map(_.userId).toSet
users <- getUsers(userIds).map(_.users)
@ -271,11 +285,25 @@ class MembershipService(
} yield ListGroupChangesResponse(
groupChanges.map(change => GroupChangeInfo.apply(change.copy(userName = userMap.get(change.userId)))),
startFrom,
result.lastEvaluatedTimeStamp,
result.nextId,
maxItems
)
def determineGroupDifference(groupChange: Seq[GroupChange]): Result[Seq[String]] = {
def getGroupUserIds(groupChange: Seq[GroupChange]): Set[String] = {
var userIds: Set[String] = Set.empty[String]
for (change <- groupChange) {
if (change.oldGroup.isDefined) {
val adminAddDifference = change.newGroup.adminUserIds.diff(change.oldGroup.get.adminUserIds)
val adminRemoveDifference = change.oldGroup.get.adminUserIds.diff(change.newGroup.adminUserIds)
val memberAddDifference = change.newGroup.memberIds.diff(change.oldGroup.get.memberIds)
val memberRemoveDifference = change.oldGroup.get.memberIds.diff(change.newGroup.memberIds)
userIds = userIds ++ adminAddDifference ++ adminRemoveDifference ++ memberAddDifference ++ memberRemoveDifference
}
}
userIds
}
def determineGroupDifference(groupChange: Seq[GroupChange], allUserMap: Map[String, String]): Result[Seq[String]] = {
var groupChangeMessage: Seq[String] = Seq.empty[String]
for (change <- groupChange) {
@ -288,23 +316,24 @@ class MembershipService(
sb.append(s"Group email changed to '${change.newGroup.email}'. ")
}
if (change.oldGroup.get.description != change.newGroup.description) {
sb.append(s"Group description changed to '${change.newGroup.description.get}'. ")
val description = if(change.newGroup.description.isEmpty) "" else change.newGroup.description.get
sb.append(s"Group description changed to '$description'. ")
}
val adminAddDifference = change.newGroup.adminUserIds.diff(change.oldGroup.get.adminUserIds)
if (adminAddDifference.nonEmpty) {
sb.append(s"Group admin/s with userId/s (${adminAddDifference.mkString(",")}) added. ")
sb.append(s"Group admin/s with user name/s '${adminAddDifference.map(x => allUserMap(x)).mkString("','")}' added. ")
}
val adminRemoveDifference = change.oldGroup.get.adminUserIds.diff(change.newGroup.adminUserIds)
if (adminRemoveDifference.nonEmpty) {
sb.append(s"Group admin/s with userId/s (${adminRemoveDifference.mkString(",")}) removed. ")
sb.append(s"Group admin/s with user name/s '${adminRemoveDifference.map(x => allUserMap(x)).mkString("','")}' removed. ")
}
val memberAddDifference = change.newGroup.memberIds.diff(change.oldGroup.get.memberIds)
if (memberAddDifference.nonEmpty) {
sb.append(s"Group member/s with userId/s (${memberAddDifference.mkString(",")}) added. ")
sb.append(s"Group member/s with user name/s '${memberAddDifference.map(x => allUserMap(x)).mkString("','")}' added. ")
}
val memberRemoveDifference = change.oldGroup.get.memberIds.diff(change.newGroup.memberIds)
if (memberRemoveDifference.nonEmpty) {
sb.append(s"Group member/s with userId/s (${memberRemoveDifference.mkString(",")}) removed. ")
sb.append(s"Group member/s with user name/s '${memberRemoveDifference.map(x => allUserMap(x)).mkString("','")}' removed. ")
}
groupChangeMessage = groupChangeMessage :+ sb.toString().trim
}
@ -333,6 +362,14 @@ class MembershipService(
.orFail(UserNotFoundError(s"User $userIdentifier was not found"))
.toResult[User]
def getUserDetails(userIdentifier: String, authPrincipal: AuthPrincipal): Result[UserResponseInfo] =
for{
user <- getUser(userIdentifier,authPrincipal)
group <- membershipRepo.getGroupsForUser(user.id).toResult[Set[String]]
} yield UserResponseInfo(user.id, Some(user.userName), group)
def getUsers(
userIds: Set[String],
startFrom: Option[String] = None,
@ -363,6 +400,37 @@ class MembershipService(
().asRight
}
}.toResult
// Validate email details.Email domains details are fetched from the config file.
def emailValidation(email: String): Result[Unit] = {
val emailDomains = validDomains.valid_domains
val numberOfDots= validDomains.number_of_dots
val splitEmailDomains = emailDomains.mkString(",")
val emailRegex ="""^(?!\.)(?!.*\.$)(?!.*\.\.)[a-zA-Z0-9._+!&-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$""".r
val index = email.indexOf('@');
val emailSplit = if(index != -1){
email.substring(index+1,email.length)}
val wildcardEmailDomains=if(splitEmailDomains.contains("*")){
emailDomains.map(x=>x.replaceAllLiterally("*",""))}
else emailDomains
Option(email) match {
case Some(value) if (emailRegex.findFirstIn(value) != None && emailSplit.toString.count(_ == '.')>0)=>
if ((emailDomains.contains(emailSplit) || emailDomains.isEmpty || wildcardEmailDomains.exists(x => emailSplit.toString.endsWith(x)))&&
emailSplit.toString.count(_ == '.')<=numberOfDots)
().asRight
else {
if(emailSplit.toString.count(_ == '.')>numberOfDots){
EmailValidationError(DotsValidationErrorMsg + " " + numberOfDots).asLeft
}
else {
EmailValidationError(EmailValidationErrorMsg + " " + wildcardEmailDomains.mkString(",")).asLeft
}
}
case _ =>
EmailValidationError(InvalidEmailValidationErrorMsg).asLeft
}}.toResult
def groupWithSameNameDoesNotExist(name: String): Result[Unit] =
groupRepo

View File

@ -25,6 +25,8 @@ trait MembershipServiceAlgebra {
def createGroup(inputGroup: Group, authPrincipal: AuthPrincipal): Result[Group]
def listEmailDomains(authPrincipal: AuthPrincipal):Result[List[String]]
def updateGroup(
groupId: String,
name: String,
@ -61,7 +63,7 @@ trait MembershipServiceAlgebra {
def getGroupActivity(
groupId: String,
startFrom: Option[String],
startFrom: Option[Int],
maxItems: Int,
authPrincipal: AuthPrincipal
): Result[ListGroupChangesResponse]
@ -76,4 +78,9 @@ trait MembershipServiceAlgebra {
userIdentifier: String,
authPrincipal: AuthPrincipal
): Result[User]
def getUserDetails(
userIdentifier: String,
authPrincipal: AuthPrincipal
): Result[UserResponseInfo]
}

View File

@ -45,6 +45,11 @@ object MembershipValidations {
authPrincipal.isGroupMember(groupId) || authPrincipal.isSystemAdmin || canViewGroupDetails
}
def canSeeGroupChange(groupId: String, authPrincipal: AuthPrincipal): Either[Throwable, Unit] =
ensuring(NotAuthorizedError("Not authorized")) {
authPrincipal.isGroupMember(groupId) || authPrincipal.isSystemAdmin
}
def isGroupChangePresent(groupChange: Option[GroupChange]): Either[Throwable, Unit] =
ensuring(InvalidGroupRequestError("Invalid Group Change ID")) {
groupChange.isDefined

View File

@ -17,13 +17,13 @@
package vinyldns.api.domain.record
import vinyldns.api.domain.zone.RecordSetChangeInfo
import vinyldns.core.domain.record.ListRecordSetChangesResults
import vinyldns.core.domain.record.{ListFailedRecordSetChangesResults, ListRecordSetChangesResults, RecordSetChange}
case class ListRecordSetChangesResponse(
zoneId: String,
recordSetChanges: List[RecordSetChangeInfo] = Nil,
nextId: Option[String],
startFrom: Option[String],
nextId: Option[Int],
startFrom: Option[Int],
maxItems: Int
)
@ -41,3 +41,43 @@ object ListRecordSetChangesResponse {
listResults.maxItems
)
}
case class ListRecordSetHistoryResponse(
zoneId: Option[String],
recordSetChanges: List[RecordSetChangeInfo] = Nil,
nextId: Option[Int],
startFrom: Option[Int],
maxItems: Int
)
object ListRecordSetHistoryResponse {
def apply(
zoneId: Option[String],
listResults: ListRecordSetChangesResults,
info: List[RecordSetChangeInfo]
): ListRecordSetHistoryResponse =
ListRecordSetHistoryResponse(
zoneId,
info,
listResults.nextId,
listResults.startFrom,
listResults.maxItems
)
}
case class ListFailedRecordSetChangesResponse(
failedRecordSetChanges: List[RecordSetChange] = Nil,
nextId: Int,
startFrom: Int,
maxItems: Int
)
object ListFailedRecordSetChangesResponse {
def apply(
ListFailedRecordSetChanges: ListFailedRecordSetChangesResults
): ListFailedRecordSetChangesResponse =
ListFailedRecordSetChangesResponse(
ListFailedRecordSetChanges.items,
ListFailedRecordSetChanges.nextId,
ListFailedRecordSetChanges.startFrom,
ListFailedRecordSetChanges.maxItems)}

View File

@ -19,7 +19,7 @@ package vinyldns.api.domain.record
import cats.effect.IO
import org.slf4j.LoggerFactory
import scalikejdbc.DB
import vinyldns.core.domain.record.{NameSort, ListRecordSetResults, RecordSetCacheRepository, RecordSetRepository}
import vinyldns.core.domain.record.{ListRecordSetResults, NameSort, RecordSetCacheRepository, RecordSetRepository, RecordTypeSort}
import vinyldns.mysql.TransactionProvider
@ -30,7 +30,7 @@ class RecordSetCacheService(recordSetRepository: RecordSetRepository,
final def populateRecordSetCache(nextId: Option[String] = None): IO[ListRecordSetResults] = {
logger.info(s"Populating recordset data. Starting at $nextId")
for {
result <- recordSetRepository.listRecordSets(None, nextId, Some(1000), None, None, None, NameSort.ASC)
result <- recordSetRepository.listRecordSets(None, nextId, Some(1000), None, None, None, NameSort.ASC, RecordTypeSort.ASC)
_ <- executeWithinTransaction { db: DB =>
IO {

View File

@ -113,6 +113,25 @@ object RecordSetChangeGenerator extends DnsConversions {
singleBatchChangeIds = singleBatchChangeIds
)
def forOutOfSync(
recordSet: RecordSet,
zone: Zone,
userId: String,
singleBatchChangeIds: List[String]
): RecordSetChange =
RecordSetChange(
zone = zone,
recordSet = recordSet.copy(
name = relativize(recordSet.name, zone.name),
status = RecordSetStatus.PendingDelete,
updated = Some(Instant.now.truncatedTo(ChronoUnit.MILLIS))
),
userId = userId,
changeType = RecordSetChangeType.Sync,
updates = Some(recordSet),
singleBatchChangeIds = singleBatchChangeIds
)
def forDelete(
recordSet: RecordSet,
zone: Zone,

View File

@ -27,6 +27,7 @@ import vinyldns.core.domain.zone.{Zone, ZoneCommandResult, ZoneRepository}
import vinyldns.core.queue.MessageQueue
import cats.data._
import cats.effect.IO
import org.slf4j.{Logger, LoggerFactory}
import org.xbill.DNS.ReverseMap
import vinyldns.api.config.{ZoneAuthConfigs, DottedHostsConfig, HighValueDomainConfig}
import vinyldns.api.domain.DomainValidations.{validateIpv4Address, validateIpv6Address}
@ -35,6 +36,8 @@ import vinyldns.core.domain.record.NameSort.NameSort
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
@ -48,7 +51,8 @@ object RecordSetService {
highValueDomainConfig: HighValueDomainConfig,
dottedHostsConfig: DottedHostsConfig,
approvedNameServers: List[Regex],
useRecordSetCache: Boolean
useRecordSetCache: Boolean,
notifiers: AllNotifiers
): RecordSetService =
new RecordSetService(
dataAccessor.zoneRepository,
@ -64,7 +68,9 @@ object RecordSetService {
highValueDomainConfig,
dottedHostsConfig,
approvedNameServers,
useRecordSetCache
useRecordSetCache,
notifiers
)
}
@ -82,12 +88,18 @@ class RecordSetService(
highValueDomainConfig: HighValueDomainConfig,
dottedHostsConfig: DottedHostsConfig,
approvedNameServers: List[Regex],
useRecordSetCache: Boolean
useRecordSetCache: Boolean,
notifiers: AllNotifiers
) extends RecordSetServiceAlgebra {
import RecordSetValidations._
import accessValidation._
val logger: Logger = LoggerFactory.getLogger(classOf[RecordSetService])
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)
@ -142,13 +154,31 @@ class RecordSetService(
_ <- unchangedRecordName(existing, recordSet, zone).toResult
_ <- unchangedRecordType(existing, recordSet).toResult
_ <- unchangedZoneId(existing, recordSet).toResult
_ <- if(requestorOwnerShipTransferStatus.contains(recordSet.recordSetGroupChange.map(_.ownerShipTransferStatus).getOrElse("<none>"))
&& !auth.isSuper && !auth.isGroupMember(existing.ownerGroupId.getOrElse("None")))
unchangedRecordSet(existing, recordSet).toResult else ().toResult
_ <- if(existing.recordSetGroupChange.map(_.ownerShipTransferStatus).getOrElse("<none>") == OwnerShipTransferStatus.Cancelled
&& !auth.isSuper) {
recordSetOwnerShipApproveStatus(recordSet).toResult
} else ().toResult
_ = logger.info(s"updated recordsetgroupchange: ${recordSet.recordSetGroupChange}")
_ = logger.info(s"existing recordsetgroupchange: ${existing.recordSetGroupChange}")
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).toResult
_ <- if(requestorOwnerShipTransferStatus.contains(recordSet.recordSetGroupChange.map(_.ownerShipTransferStatus).getOrElse("<none>"))
&& !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("<none>"))
&& !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("<none>"))
&& !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)
@ -183,6 +213,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("<none>") != OwnerShipTransferStatus.None &&
recordSet.recordSetGroupChange.map(_.ownerShipTransferStatus).getOrElse("<none>") != OwnerShipTransferStatus.AutoApproved)
notifiers.notify(Notification(change)).toResult
else ().toResult
} yield change
def deleteRecordSet(
@ -201,6 +236,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
@ -382,6 +476,14 @@ class RecordSetService(
groupName <- getGroupName(recordSet.ownerGroupId)
} yield RecordSetInfo(recordSet, groupName)
def getRecordSetCount(zoneId: String, authPrincipal: AuthPrincipal): Result[RecordSetCount] = {
for {
zone <- getZone(zoneId)
_ <- canSeeZone(authPrincipal, zone).toResult
count <- recordSetRepository.getRecordSetCount(zoneId).toResult
} yield RecordSetCount(count)
}
def getRecordSetByZone(
recordSetId: String,
zoneId: String,
@ -407,7 +509,8 @@ class RecordSetService(
recordTypeFilter: Option[Set[RecordType]],
recordOwnerGroupFilter: Option[String],
nameSort: NameSort,
authPrincipal: AuthPrincipal
authPrincipal: AuthPrincipal,
recordTypeSort: RecordTypeSort
): Result[ListGlobalRecordSetsResponse] =
for {
_ <- validRecordNameFilterLength(recordNameFilter).toResult
@ -420,7 +523,8 @@ class RecordSetService(
Some(formattedRecordNameFilter),
recordTypeFilter,
recordOwnerGroupFilter,
nameSort
nameSort,
recordTypeSort
)
.toResult[ListRecordSetResults]
rsOwnerGroupIds = recordSetResults.recordSets.flatMap(_.ownerGroupId).toSet
@ -458,7 +562,8 @@ class RecordSetService(
recordTypeFilter: Option[Set[RecordType]],
recordOwnerGroupFilter: Option[String],
nameSort: NameSort,
authPrincipal: AuthPrincipal
authPrincipal: AuthPrincipal,
recordTypeSort: RecordTypeSort
): Result[ListGlobalRecordSetsResponse] = {
for {
_ <- validRecordNameFilterLength(recordNameFilter).toResult
@ -483,7 +588,8 @@ class RecordSetService(
Some(formattedRecordNameFilter),
recordTypeFilter,
recordOwnerGroupFilter,
nameSort
nameSort,
recordTypeSort
).toResult[ListRecordSetResults]
}
rsOwnerGroupIds = recordSetResults.recordSets.flatMap(_.ownerGroupId).toSet
@ -511,7 +617,8 @@ class RecordSetService(
recordTypeFilter: Option[Set[RecordType]],
recordOwnerGroupFilter: Option[String],
nameSort: NameSort,
authPrincipal: AuthPrincipal
authPrincipal: AuthPrincipal,
recordTypeSort: RecordTypeSort
): Result[ListRecordSetsByZoneResponse] =
for {
zone <- getZone(zoneId)
@ -524,7 +631,8 @@ class RecordSetService(
recordNameFilter,
recordTypeFilter,
recordOwnerGroupFilter,
nameSort
nameSort,
recordTypeSort
)
.toResult[ListRecordSetResults]
rsOwnerGroupIds = recordSetResults.recordSets.flatMap(_.ownerGroupId).toSet
@ -539,7 +647,8 @@ class RecordSetService(
recordSetResults.recordNameFilter,
recordSetResults.recordTypeFilter,
recordSetResults.recordOwnerGroupFilter,
recordSetResults.nameSort
recordSetResults.nameSort,
recordSetResults.recordTypeSort
)
def getRecordSetChange(
@ -568,18 +677,61 @@ class RecordSetService(
def listRecordSetChanges(
zoneId: String,
startFrom: Option[String] = None,
startFrom: Option[Int] = None,
maxItems: Int = 100,
authPrincipal: AuthPrincipal
): Result[ListRecordSetChangesResponse] =
for {
zone <- getZone(zoneId)
_ <- canSeeZone(authPrincipal, zone).toResult
recordSetChangesResults <- recordChangeRepository
.listRecordSetChanges(Some(zone.id), startFrom, maxItems, None, None)
.toResult[ListRecordSetChangesResults]
recordSetChangesInfo <- buildRecordSetChangeInfo(recordSetChangesResults.items)
} yield ListRecordSetChangesResponse(zoneId, recordSetChangesResults, recordSetChangesInfo)
def listRecordSetChangeHistory(
zoneId: Option[String] = None,
startFrom: Option[Int] = None,
maxItems: Int = 100,
fqdn: Option[String] = None,
recordType: Option[RecordType] = None,
authPrincipal: AuthPrincipal
): Result[ListRecordSetHistoryResponse] =
for {
zone <- getZone(zoneId)
zone <- getZone(zoneId.get)
_ <- canSeeZone(authPrincipal, zone).toResult
recordSetChangesResults <- recordChangeRepository
.listRecordSetChanges(zone.id, startFrom, maxItems)
.listRecordSetChanges(zoneId, startFrom, maxItems, fqdn, recordType)
.toResult[ListRecordSetChangesResults]
recordSetChangesInfo <- buildRecordSetChangeInfo(recordSetChangesResults.items)
} yield ListRecordSetChangesResponse(zoneId, recordSetChangesResults, recordSetChangesInfo)
} yield ListRecordSetHistoryResponse(zoneId, recordSetChangesResults, recordSetChangesInfo)
def listFailedRecordSetChanges(
authPrincipal: AuthPrincipal,
zoneId: Option[String] = None,
startFrom: Int= 0,
maxItems: Int = 100
): Result[ListFailedRecordSetChangesResponse] =
for {
recordSetChangesFailedResults <- recordChangeRepository
.listFailedRecordSetChanges(zoneId, maxItems, startFrom)
.toResult[ListFailedRecordSetChangesResults]
_ <- zoneAccess(recordSetChangesFailedResults.items, authPrincipal).toResult
} yield
ListFailedRecordSetChangesResponse(
recordSetChangesFailedResults.items,
recordSetChangesFailedResults.nextId,
startFrom,
maxItems)
def zoneAccess(
RecordSetCh: List[RecordSetChange],
auth: AuthPrincipal
): List[Result[Unit]] =
RecordSetCh.map { zn =>
canSeeZone(auth, zn.zone).toResult
}
def getZone(zoneId: String): Result[Zone] =
zoneRepository

View File

@ -17,12 +17,13 @@
package vinyldns.api.domain.record
import vinyldns.api.Interfaces.Result
import vinyldns.api.domain.zone.RecordSetInfo
import vinyldns.api.domain.zone.{RecordSetCount, RecordSetInfo}
import vinyldns.core.domain.auth.AuthPrincipal
import vinyldns.core.domain.zone.ZoneCommandResult
import vinyldns.api.route.{ListGlobalRecordSetsResponse, ListRecordSetsByZoneResponse}
import vinyldns.core.domain.record.NameSort.NameSort
import vinyldns.core.domain.record.RecordType.RecordType
import vinyldns.core.domain.record.RecordTypeSort.RecordTypeSort
import vinyldns.core.domain.record.{RecordSet, RecordSetChange}
trait RecordSetServiceAlgebra {
@ -54,7 +55,8 @@ trait RecordSetServiceAlgebra {
recordTypeFilter: Option[Set[RecordType]],
recordOwnerGroupId: Option[String],
nameSort: NameSort,
authPrincipal: AuthPrincipal
authPrincipal: AuthPrincipal,
recordTypeSort: RecordTypeSort
): Result[ListGlobalRecordSetsResponse]
/**
@ -76,7 +78,8 @@ trait RecordSetServiceAlgebra {
recordTypeFilter: Option[Set[RecordType]],
recordOwnerGroupId: Option[String],
nameSort: NameSort,
authPrincipal: AuthPrincipal
authPrincipal: AuthPrincipal,
recordTypeSort: RecordTypeSort
): Result[ListGlobalRecordSetsResponse]
def listRecordSetsByZone(
@ -87,7 +90,8 @@ trait RecordSetServiceAlgebra {
recordTypeFilter: Option[Set[RecordType]],
recordOwnerGroupId: Option[String],
nameSort: NameSort,
authPrincipal: AuthPrincipal
authPrincipal: AuthPrincipal,
recordTypeSort: RecordTypeSort
): Result[ListRecordSetsByZoneResponse]
def getRecordSetChange(
@ -98,9 +102,27 @@ trait RecordSetServiceAlgebra {
def listRecordSetChanges(
zoneId: String,
startFrom: Option[String],
startFrom: Option[Int],
maxItems: Int,
authPrincipal: AuthPrincipal
): Result[ListRecordSetChangesResponse]
def listRecordSetChangeHistory(
zoneId: Option[String],
startFrom: Option[Int],
maxItems: Int,
fqdn: Option[String],
recordType: Option[RecordType],
authPrincipal: AuthPrincipal
): Result[ListRecordSetHistoryResponse]
def listFailedRecordSetChanges(
authPrincipal: AuthPrincipal,
zoneId: Option[String],
startFrom: Int,
maxItems: Int
): Result[ListFailedRecordSetChangesResponse]
def getRecordSetCount(zoneId: String, authPrincipal: AuthPrincipal): Result[RecordSetCount]
}

View File

@ -20,13 +20,14 @@ import cats.syntax.either._
import vinyldns.api.Interfaces._
import vinyldns.api.backend.dns.DnsConversions
import vinyldns.api.config.HighValueDomainConfig
import vinyldns.api.domain.DomainValidations.validateIpv4Address
import vinyldns.api.domain._
import vinyldns.core.domain.DomainHelpers._
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._
@ -236,6 +237,16 @@ object RecordSetValidations {
)
}
val isNotIPv4inCname = {
ensuring(
RecordSetValidation(
s"""Invalid CNAME: ${newRecordSet.records.head.toString.dropRight(1)}, valid CNAME record data cannot be an IP address."""
)
)(
validateIpv4Address(newRecordSet.records.head.toString.dropRight(1)).isInvalid
)
}
for {
_ <- isNotOrigin(
newRecordSet,
@ -243,6 +254,7 @@ object RecordSetValidations {
"CNAME RecordSet cannot have name '@' because it points to zone origin"
)
_ <- noRecordWithName
_ <- isNotIPv4inCname
_ <- RDataWithConsecutiveDots
_ <- checkForDot(newRecordSet, zone, existingRecordSet, recordFqdnDoesNotExist, dottedHostZoneConfig, isRecordTypeAndUserAllowed, allowedDotsLimit)
} yield ()
@ -425,10 +437,66 @@ object RecordSetValidations {
InvalidRequest("Cannot update RecordSet's zone ID.")
)
/**
* Checks of the user is a superuser, the zone is shared, and the only record attribute being changed
* is the record owner group.
*/
def canSuperUserUpdateOwnerGroup(
existing: RecordSet,
updates: RecordSet,
zone: Zone,
auth: AuthPrincipal
): Boolean =
(updates.ownerGroupId != existing.ownerGroupId
&& updates.zoneId == existing.zoneId
&& updates.name == existing.name
&& updates.typ == existing.typ
&& updates.ttl == existing.ttl
&& updates.records == existing.records
&& zone.shared
&& auth.isSuper)
def validRecordNameFilterLength(recordNameFilter: String): Either[Throwable, Unit] =
ensuring(onError = InvalidRequest(RecordNameFilterError)) {
val searchRegex = "[a-zA-Z0-9].*[a-zA-Z0-9]+".r
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("<none>") != OwnerShipTransferStatus.ManuallyApproved &&
updates.recordSetGroupChange.map(_.ownerShipTransferStatus).getOrElse("<none>") != OwnerShipTransferStatus.AutoApproved &&
updates.recordSetGroupChange.map(_.ownerShipTransferStatus).getOrElse("<none>") != 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 || existing.recordSetGroupChange.isEmpty,
(),
InvalidRequest("Cannot update RecordSet OwnerShip Status when zone is not shared.")
)
}

View File

@ -16,7 +16,7 @@
package vinyldns.api.domain.zone
import com.aaronbedra.orchard.CIDR
import com.comcast.ip4s.Cidr
import vinyldns.core.domain.record.RecordType
import vinyldns.core.domain.zone.ACLRule
@ -61,7 +61,7 @@ object ACLRuleOrdering extends ACLRuleOrdering {
object PTRACLRuleOrdering extends ACLRuleOrdering {
def sortableRecordMaskValue(rule: ACLRule): Int = {
val slash = rule.recordMask match {
case Some(cidrRule) => CIDR.valueOf(cidrRule).getMask
case Some(cidrRule) => Cidr.fromString(cidrRule).get.prefixBits
case None => 0
}
128 - slash

View File

@ -17,7 +17,7 @@
package vinyldns.api.domain.zone
import vinyldns.api.domain.zone
import vinyldns.core.domain.zone.{ListZoneChangesResults, ZoneChange}
import vinyldns.core.domain.zone.{ListFailedZoneChangesResults, ListZoneChangesResults, ZoneChange}
case class ListZoneChangesResponse(
zoneId: String,
@ -37,3 +37,29 @@ object ListZoneChangesResponse {
listResults.maxItems
)
}
case class ListDeletedZoneChangesResponse(
zonesDeletedInfo: List[ZoneChangeDeletedInfo],
zoneChangeFilter: Option[String] = None,
nextId: Option[String] = None,
startFrom: Option[String] = None,
maxItems: Int = 100,
ignoreAccess: Boolean = false
)
case class ListFailedZoneChangesResponse(
failedZoneChanges: List[ZoneChange] = Nil,
nextId: Int,
startFrom: Int,
maxItems: Int
)
object ListFailedZoneChangesResponse {
def apply(listResults: ListFailedZoneChangesResults): ListFailedZoneChangesResponse =
zone.ListFailedZoneChangesResponse(
listResults.items,
listResults.nextId,
listResults.startFrom,
listResults.maxItems
)
}

View File

@ -61,6 +61,14 @@ object ZoneChangeGenerator {
ZoneChangeStatus.Pending
)
def forSyncs(zone: Zone): ZoneChange =
ZoneChange(
zone.copy(updated = Some(Instant.now.truncatedTo(ChronoUnit.MILLIS)), status = ZoneStatus.Syncing),
zone.scheduleRequestor.get,
ZoneChangeType.AutomatedSync,
ZoneChangeStatus.Pending
)
def forDelete(zone: Zone, authPrincipal: AuthPrincipal): ZoneChange =
ZoneChange(
zone.copy(updated = Some(Instant.now.truncatedTo(ChronoUnit.MILLIS)), status = ZoneStatus.Deleted),

View File

@ -21,8 +21,8 @@ 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.zone.{ACLRuleInfo, AccessLevel, Zone, ZoneACL, ZoneConnection}
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
@ -44,6 +44,8 @@ case class ZoneInfo(
adminGroupName: String,
latestSync: Option[Instant],
backendId: Option[String],
recurrenceSchedule: Option[String],
scheduleRequestor: Option[String],
accessLevel: AccessLevel
)
@ -70,10 +72,34 @@ object ZoneInfo {
adminGroupName = groupName,
latestSync = zone.latestSync,
backendId = zone.backendId,
recurrenceSchedule = zone.recurrenceSchedule,
scheduleRequestor = zone.scheduleRequestor,
accessLevel = accessLevel
)
}
case class ZoneDetails(
name: String,
email: String,
status: ZoneStatus,
adminGroupId: String,
adminGroupName: String,
)
object ZoneDetails {
def apply(
zone: Zone,
groupName: String,
): ZoneDetails =
ZoneDetails(
name = zone.name,
email = zone.email,
status = zone.status,
adminGroupId = zone.adminGroupId,
adminGroupName = groupName,
)
}
case class ZoneSummaryInfo(
name: String,
email: String,
@ -90,6 +116,8 @@ case class ZoneSummaryInfo(
adminGroupName: String,
latestSync: Option[Instant],
backendId: Option[String],
recurrenceSchedule: Option[String],
scheduleRequestor: Option[String],
accessLevel: AccessLevel
)
@ -111,6 +139,29 @@ object ZoneSummaryInfo {
adminGroupName = groupName,
latestSync = zone.latestSync,
zone.backendId,
recurrenceSchedule = zone.recurrenceSchedule,
scheduleRequestor = zone.scheduleRequestor,
accessLevel = accessLevel
)
}
case class ZoneChangeDeletedInfo(
zoneChange: ZoneChange,
adminGroupName: String,
userName: String,
accessLevel: AccessLevel
)
object ZoneChangeDeletedInfo {
def apply(zoneChange: List[ZoneChange],
groupName: String,
userName: String,
accessLevel: AccessLevel)
: ZoneChangeDeletedInfo =
ZoneChangeDeletedInfo(
zoneChange= zoneChange,
groupName = groupName,
userName = userName,
accessLevel = accessLevel
)
}
@ -129,6 +180,7 @@ case class RecordSetListInfo(
accessLevel: AccessLevel,
ownerGroupId: Option[String],
ownerGroupName: Option[String],
recordSetGroupChange: Option[OwnerShipTransfer],
fqdn: Option[String]
)
@ -148,6 +200,7 @@ object RecordSetListInfo {
accessLevel = accessLevel,
ownerGroupId = recordSet.ownerGroupId,
ownerGroupName = recordSet.ownerGroupName,
recordSetGroupChange = recordSet.recordSetGroupChange,
fqdn = recordSet.fqdn
)
}
@ -165,6 +218,7 @@ case class RecordSetInfo(
account: String,
ownerGroupId: Option[String],
ownerGroupName: Option[String],
recordSetGroupChange: Option[OwnerShipTransfer],
fqdn: Option[String]
)
@ -183,6 +237,7 @@ object RecordSetInfo {
account = recordSet.account,
ownerGroupId = recordSet.ownerGroupId,
ownerGroupName = groupName,
recordSetGroupChange = recordSet.recordSetGroupChange,
fqdn = recordSet.fqdn
)
}
@ -200,6 +255,7 @@ case class RecordSetGlobalInfo(
account: String,
ownerGroupId: Option[String],
ownerGroupName: Option[String],
recordSetGroupChange: Option[OwnerShipTransfer],
fqdn: Option[String],
zoneName: String,
zoneShared: Boolean
@ -225,6 +281,7 @@ object RecordSetGlobalInfo {
account = recordSet.account,
ownerGroupId = recordSet.ownerGroupId,
ownerGroupName = groupName,
recordSetGroupChange = recordSet.recordSetGroupChange,
fqdn = recordSet.fqdn,
zoneName = zoneName,
zoneShared = zoneShared
@ -266,9 +323,12 @@ case class ListZonesResponse(
startFrom: Option[String] = None,
nextId: Option[String] = None,
maxItems: Int = 100,
ignoreAccess: Boolean = false
ignoreAccess: Boolean = false,
includeReverse: Boolean = true
)
case class RecordSetCount( count: Int = 0 )
// Errors
case class InvalidRequest(msg: String) extends Throwable(msg)

View File

@ -19,13 +19,7 @@ package vinyldns.api.domain.zone
import cats.implicits._
import cats.data._
import com.comcast.ip4s.IpAddress
import com.comcast.ip4s.interop.cats.implicits._
import vinyldns.core.domain.{
DomainHelpers,
DomainValidationError,
HighValueDomainError,
RecordRequiresManualReview
}
import vinyldns.core.domain.{DomainHelpers, DomainValidationError, HighValueDomainError, RecordRequiresManualReview}
import vinyldns.core.domain.record.{NSData, RecordSet}
import scala.util.matching.Regex
@ -41,7 +35,7 @@ object ZoneRecordValidations {
/* Checks to see if an ip address is part of the ip address list */
def isIpInIpList(ipList: List[IpAddress], ipToTest: String): Boolean =
IpAddress(ipToTest).exists(ip => ipList.exists(_ === ip))
IpAddress.fromString(ipToTest).exists(ip => ipList.exists(_ === ip))
/* Checks to see if an individual ns data is part of the approved server list */
def isApprovedNameServer(

View File

@ -23,11 +23,16 @@ import vinyldns.api.Interfaces
import vinyldns.core.domain.auth.AuthPrincipal
import vinyldns.api.repository.ApiDataAccessor
import vinyldns.core.crypto.CryptoAlgebra
import vinyldns.core.domain.membership.{Group, GroupRepository, User, UserRepository}
import vinyldns.core.domain.membership.{Group, GroupRepository, ListUsersResults, User, UserRepository}
import vinyldns.core.domain.zone._
import vinyldns.core.queue.MessageQueue
import vinyldns.core.domain.DomainHelpers.ensureTrailingDot
import vinyldns.core.domain.backend.BackendResolver
import com.cronutils.model.definition.CronDefinition
import com.cronutils.model.definition.CronDefinitionBuilder
import com.cronutils.parser.CronParser
import com.cronutils.model.CronType
import vinyldns.api.domain.membership.MembershipService
object ZoneService {
def apply(
@ -37,7 +42,8 @@ object ZoneService {
zoneValidations: ZoneValidations,
accessValidation: AccessValidationsAlgebra,
backendResolver: BackendResolver,
crypto: CryptoAlgebra
crypto: CryptoAlgebra,
membershipService:MembershipService
): ZoneService =
new ZoneService(
dataAccessor.zoneRepository,
@ -49,7 +55,8 @@ object ZoneService {
zoneValidations,
accessValidation,
backendResolver,
crypto
crypto,
membershipService
)
}
@ -63,7 +70,8 @@ class ZoneService(
zoneValidations: ZoneValidations,
accessValidation: AccessValidationsAlgebra,
backendResolver: BackendResolver,
crypto: CryptoAlgebra
crypto: CryptoAlgebra,
membershipService:MembershipService
) extends ZoneServiceAlgebra {
import accessValidation._
@ -76,12 +84,17 @@ class ZoneService(
): Result[ZoneCommandResult] =
for {
_ <- isValidZoneAcl(createZoneInput.acl).toResult
_ <- membershipService.emailValidation(createZoneInput.email)
_ <- connectionValidator.isValidBackendId(createZoneInput.backendId).toResult
_ <- validateSharedZoneAuthorized(createZoneInput.shared, auth.signedInUser).toResult
_ <- zoneDoesNotExist(createZoneInput.name)
_ <- adminGroupExists(createZoneInput.adminGroupId)
_ <- if(createZoneInput.recurrenceSchedule.isDefined) canScheduleZoneSync(auth).toResult else IO.unit.toResult
isCronStringValid = if(createZoneInput.recurrenceSchedule.isDefined) isValidCronString(createZoneInput.recurrenceSchedule.get) else true
_ <- validateCronString(isCronStringValid).toResult
_ <- canChangeZone(auth, createZoneInput.name, createZoneInput.adminGroupId).toResult
zoneToCreate = Zone(createZoneInput, auth.isTestUser)
createdZoneInput = if(createZoneInput.recurrenceSchedule.isDefined) createZoneInput.copy(scheduleRequestor = Some(auth.signedInUser.userName)) else createZoneInput
zoneToCreate = Zone(createdZoneInput, auth.isTestUser)
_ <- connectionValidator.validateZoneConnections(zoneToCreate)
createZoneChange <- ZoneChangeGenerator.forAdd(zoneToCreate, auth).toResult
_ <- messageQueue.send(createZoneChange).toResult[Unit]
@ -90,6 +103,7 @@ class ZoneService(
def updateZone(updateZoneInput: UpdateZoneInput, auth: AuthPrincipal): Result[ZoneCommandResult] =
for {
_ <- isValidZoneAcl(updateZoneInput.acl).toResult
_ <- membershipService.emailValidation(updateZoneInput.email)
_ <- connectionValidator.isValidBackendId(updateZoneInput.backendId).toResult
existingZone <- getZoneOrFail(updateZoneInput.id)
_ <- validateSharedZoneAuthorized(
@ -98,10 +112,14 @@ class ZoneService(
auth.signedInUser
).toResult
_ <- canChangeZone(auth, existingZone.name, existingZone.adminGroupId).toResult
_ <- if(updateZoneInput.recurrenceSchedule.isDefined) canScheduleZoneSync(auth).toResult else IO.unit.toResult
isCronStringValid = if(updateZoneInput.recurrenceSchedule.isDefined) isValidCronString(updateZoneInput.recurrenceSchedule.get) else true
_ <- validateCronString(isCronStringValid).toResult
_ <- adminGroupExists(updateZoneInput.adminGroupId)
// if admin group changes, this confirms user has access to new group
_ <- canChangeZone(auth, updateZoneInput.name, updateZoneInput.adminGroupId).toResult
zoneWithUpdates = Zone(updateZoneInput, existingZone)
updatedZoneInput = if(updateZoneInput.recurrenceSchedule.isDefined) updateZoneInput.copy(scheduleRequestor = Some(auth.signedInUser.userName)) else updateZoneInput
zoneWithUpdates = Zone(updatedZoneInput, existingZone)
_ <- validateZoneConnectionIfChanged(zoneWithUpdates, existingZone)
updateZoneChange <- ZoneChangeGenerator
.forUpdate(zoneWithUpdates, existingZone, auth, crypto)
@ -135,6 +153,12 @@ class ZoneService(
accessLevel = getZoneAccess(auth, zone)
} yield ZoneInfo(zone, aclInfo, groupName, accessLevel)
def getCommonZoneDetails(zoneId: String, auth: AuthPrincipal): Result[ZoneDetails] =
for {
zone <- getZoneOrFail(zoneId)
groupName <- getGroupName(zone.adminGroupId)
} yield ZoneDetails(zone, groupName)
def getZoneByName(zoneName: String, auth: AuthPrincipal): Result[ZoneInfo] =
for {
zone <- getZoneByNameOrFail(ensureTrailingDot(zoneName))
@ -150,7 +174,8 @@ class ZoneService(
startFrom: Option[String] = None,
maxItems: Int = 100,
searchByAdminGroup: Boolean = false,
ignoreAccess: Boolean = false
ignoreAccess: Boolean = false,
includeReverse: Boolean = true
): Result[ListZonesResponse] = {
if(!searchByAdminGroup || nameFilter.isEmpty){
for {
@ -159,21 +184,22 @@ class ZoneService(
nameFilter,
startFrom,
maxItems,
ignoreAccess
)
zones = listZonesResult.zones
groupIds = zones.map(_.adminGroupId).toSet
groups <- groupRepository.getGroups(groupIds)
zoneSummaryInfos = zoneSummaryInfoMapping(zones, authPrincipal, groups)
} yield ListZonesResponse(
zoneSummaryInfos,
listZonesResult.zonesFilter,
listZonesResult.startFrom,
listZonesResult.nextId,
listZonesResult.maxItems,
listZonesResult.ignoreAccess
ignoreAccess,
includeReverse
)
}
zones = listZonesResult.zones
groupIds = zones.map(_.adminGroupId).toSet
groups <- groupRepository.getGroups(groupIds)
zoneSummaryInfos = zoneSummaryInfoMapping(zones, authPrincipal, groups)
} yield ListZonesResponse(
zoneSummaryInfos,
listZonesResult.zonesFilter,
listZonesResult.startFrom,
listZonesResult.nextId,
listZonesResult.maxItems,
listZonesResult.ignoreAccess,
listZonesResult.includeReverse
)}
else {
for {
groupIds <- getGroupsIdsByName(nameFilter.get)
@ -182,7 +208,8 @@ class ZoneService(
startFrom,
maxItems,
groupIds,
ignoreAccess
ignoreAccess,
includeReverse
)
zones = listZonesResult.zones
groups <- groupRepository.getGroups(groupIds)
@ -193,11 +220,64 @@ class ZoneService(
listZonesResult.startFrom,
listZonesResult.nextId,
listZonesResult.maxItems,
listZonesResult.ignoreAccess
listZonesResult.ignoreAccess,
listZonesResult.includeReverse
)
}
}.toResult
def listDeletedZones(
authPrincipal: AuthPrincipal,
nameFilter: Option[String] = None,
startFrom: Option[String] = None,
maxItems: Int = 100,
ignoreAccess: Boolean = false
): Result[ListDeletedZoneChangesResponse] = {
for {
listZonesChangeResult <- zoneChangeRepository.listDeletedZones(
authPrincipal,
nameFilter,
startFrom,
maxItems,
ignoreAccess
)
zoneChanges = listZonesChangeResult.zoneDeleted
groupIds = zoneChanges.map(_.zone.adminGroupId).toSet
groups <- groupRepository.getGroups(groupIds)
userId = zoneChanges.map(_.userId).toSet
users <- userRepository.getUsers(userId,None,None)
zoneDeleteSummaryInfos = ZoneChangeDeletedInfoMapping(zoneChanges, authPrincipal, groups, users)
} yield {
ListDeletedZoneChangesResponse(
zoneDeleteSummaryInfos,
listZonesChangeResult.zoneChangeFilter,
listZonesChangeResult.nextId,
listZonesChangeResult.startFrom,
listZonesChangeResult.maxItems,
listZonesChangeResult.ignoreAccess
)
}
}.toResult
private def ZoneChangeDeletedInfoMapping(
zoneChange: List[ZoneChange],
auth: AuthPrincipal,
groups: Set[Group],
users: ListUsersResults
): List[ZoneChangeDeletedInfo] =
zoneChange.map { zc =>
val groupName = groups.find(_.id == zc.zone.adminGroupId) match {
case Some(group) => group.name
case None => "Unknown group name"
}
val userName = users.users.find(_.id == zc.userId) match {
case Some(user) => user.userName
case None => "Unknown user name"
}
val zoneAccess = getZoneAccess(auth, zc.zone)
ZoneChangeDeletedInfo(zc, groupName,userName, zoneAccess)
}
def zoneSummaryInfoMapping(
zones: List[Zone],
auth: AuthPrincipal,
@ -220,12 +300,38 @@ class ZoneService(
): Result[ListZoneChangesResponse] =
for {
zone <- getZoneOrFail(zoneId)
_ <- canSeeZone(authPrincipal, zone).toResult
_ <- canSeeZoneChange(authPrincipal, zone).toResult
zoneChangesResults <- zoneChangeRepository
.listZoneChanges(zone.id, startFrom, maxItems)
.toResult[ListZoneChangesResults]
} yield ListZoneChangesResponse(zone.id, zoneChangesResults)
def listFailedZoneChanges(
authPrincipal: AuthPrincipal,
startFrom: Int= 0,
maxItems: Int = 100
): Result[ListFailedZoneChangesResponse] =
for {
zoneChangesFailedResults <- zoneChangeRepository
.listFailedZoneChanges(maxItems, startFrom)
.toResult[ListFailedZoneChangesResults]
_ <- zoneAccess(zoneChangesFailedResults.items, authPrincipal).toResult
} yield
ListFailedZoneChangesResponse(
zoneChangesFailedResults.items,
zoneChangesFailedResults.nextId,
startFrom,
maxItems
)
def zoneAccess(
zoneCh: List[ZoneChange],
auth: AuthPrincipal
): List[Result[Unit]] =
zoneCh.map { zn =>
canSeeZone(auth, zn.zone).toResult
}
def addACLRule(
zoneId: String,
aclRuleInfo: ACLRuleInfo,
@ -276,6 +382,28 @@ class ZoneService(
def getBackendIds(): Result[List[String]] =
backendResolver.ids.toList.toResult
def isValidCronString(maybeString: String): Boolean = {
val isValid = try {
val cronDefinition: CronDefinition = CronDefinitionBuilder.instanceDefinitionFor(CronType.QUARTZ)
val parser: CronParser = new CronParser(cronDefinition)
val quartzCron = parser.parse(maybeString)
quartzCron.validate
true
}
catch {
case _: Exception =>
false
}
isValid
}
def validateCronString(isValid: Boolean): Either[Throwable, Unit] =
ensuring(
InvalidRequest("Invalid cron expression. Please enter a valid cron expression in 'recurrenceSchedule'.")
)(
isValid
)
def zoneDoesNotExist(zoneName: String): Result[Unit] =
zoneRepository
.getZoneByName(zoneName)
@ -289,6 +417,13 @@ class ZoneService(
}
.toResult
def canScheduleZoneSync(auth: AuthPrincipal): Either[Throwable, Unit] =
ensuring(
NotAuthorizedError(s"User '${auth.signedInUser.userName}' is not authorized to schedule zone sync in this zone.")
)(
auth.isSystemAdmin
)
def adminGroupExists(groupId: String): Result[Unit] =
groupRepository
.getGroup(groupId)

View File

@ -35,6 +35,8 @@ trait ZoneServiceAlgebra {
def getZone(zoneId: String, auth: AuthPrincipal): Result[ZoneInfo]
def getCommonZoneDetails(zoneId: String, auth: AuthPrincipal): Result[ZoneDetails]
def getZoneByName(zoneName: String, auth: AuthPrincipal): Result[ZoneInfo]
def listZones(
@ -43,9 +45,18 @@ trait ZoneServiceAlgebra {
startFrom: Option[String],
maxItems: Int,
searchByAdminGroup: Boolean,
ignoreAccess: Boolean
ignoreAccess: Boolean,
includeReverse: Boolean
): Result[ListZonesResponse]
def listDeletedZones(
authPrincipal: AuthPrincipal,
nameFilter: Option[String],
startFrom: Option[String],
maxItems: Int,
ignoreAccess: Boolean
): Result[ListDeletedZoneChangesResponse]
def listZoneChanges(
zoneId: String,
authPrincipal: AuthPrincipal,
@ -67,4 +78,9 @@ trait ZoneServiceAlgebra {
def getBackendIds(): Result[List[String]]
def listFailedZoneChanges(
authPrincipal: AuthPrincipal,
startFrom: Int,
maxItems: Int
): Result[ListFailedZoneChangesResponse]
}

View File

@ -0,0 +1,74 @@
/*
* Copyright 2018 Comcast Cable Communications Management, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package vinyldns.api.domain.zone
import cats.effect.IO
import com.cronutils.model.CronType
import com.cronutils.model.definition.{CronDefinition, CronDefinitionBuilder}
import com.cronutils.model.time.ExecutionTime
import com.cronutils.parser.CronParser
import org.slf4j.LoggerFactory
import vinyldns.core.domain.zone.{Zone, ZoneChange, ZoneRepository}
import java.time.{Instant, ZoneId}
import java.time.temporal.ChronoUnit
object ZoneSyncScheduleHandler {
private val logger = LoggerFactory.getLogger("ZoneSyncScheduleHandler")
def zoneSyncScheduler(zoneRepository: ZoneRepository): IO[Set[ZoneChange]] = {
for {
zones <- zoneRepository.getAllZonesWithSyncSchedule
zoneScheduleIds = getZonesWithSchedule(zones.toList)
zoneChanges <- getZoneChanges(zoneRepository, zoneScheduleIds)
} yield zoneChanges
}
def getZoneChanges(zoneRepository: ZoneRepository, zoneScheduleIds: List[String]): IO[Set[ZoneChange]] = {
if(zoneScheduleIds.nonEmpty) {
for{
getZones <- zoneRepository.getZones(zoneScheduleIds.toSet)
syncZoneChange = getZones.map(zone => ZoneChangeGenerator.forSyncs(zone))
} yield syncZoneChange
} else {
IO(Set.empty)
}
}
def getZonesWithSchedule(zone: List[Zone]): List[String] = {
var zonesWithSchedule: List[String] = List.empty
for(z <- zone) {
if (z.recurrenceSchedule.isDefined) {
val now = Instant.now().atZone(ZoneId.of("UTC"))
val cronDefinition: CronDefinition = CronDefinitionBuilder.instanceDefinitionFor(CronType.QUARTZ)
val parser: CronParser = new CronParser(cronDefinition)
val executionTime: ExecutionTime = ExecutionTime.forCron(parser.parse(z.recurrenceSchedule.get))
val nextExecution = executionTime.nextExecution(now).get()
val diff = ChronoUnit.SECONDS.between(now, nextExecution)
if (diff == 1) {
zonesWithSchedule = zonesWithSchedule :+ z.id
logger.info("Zones with sync schedule: " + zonesWithSchedule)
} else {
List.empty
}
} else {
List.empty
}
}
zonesWithSchedule
}
}

View File

@ -17,7 +17,7 @@
package vinyldns.api.domain.zone
import cats.syntax.either._
import com.aaronbedra.orchard.CIDR
import com.comcast.ip4s.Cidr
import java.time.Instant
import java.time.temporal.ChronoUnit
import vinyldns.api.Interfaces.ensuring
@ -57,10 +57,10 @@ class ZoneValidations(syncDelayMillis: Int) {
def aclRuleMaskIsValid(rule: ACLRule): Either[Throwable, Unit] =
rule.recordMask match {
case Some(mask) if rule.recordTypes == Set(RecordType.PTR) =>
Try(CIDR.valueOf(mask)) match {
Try(Cidr.fromString(mask).get) match {
case Success(_) => Right(())
case Failure(e) =>
InvalidRequest(s"PTR types must have no mask or a valid CIDR mask: ${e.getMessage}").asLeft
case Failure(_) =>
InvalidRequest(s"PTR types must have no mask or a valid CIDR mask: Invalid CIDR block").asLeft
}
case Some(_) if rule.recordTypes.contains(RecordType.PTR) =>
InvalidRequest("Multiple record types including PTR must have no mask").asLeft

View File

@ -20,7 +20,7 @@ import cats.effect._
import org.slf4j.LoggerFactory
import vinyldns.api.backend.dns.DnsConversions
import vinyldns.core.domain.backend.Backend
import vinyldns.core.domain.record.{NameSort, RecordSetCacheRepository, RecordSetRepository}
import vinyldns.core.domain.record.{NameSort, RecordSetCacheRepository, RecordSetRepository, RecordTypeSort}
import vinyldns.core.domain.zone.Zone
import vinyldns.core.route.Monitored
@ -69,7 +69,8 @@ case class VinylDNSZoneViewLoader(
recordNameFilter = None,
recordTypeFilter = None,
recordOwnerGroupFilter = None,
nameSort = NameSort.ASC
nameSort = NameSort.ASC,
recordTypeSort = RecordTypeSort.ASC
)
.map { result =>
VinylDNSZoneViewLoader.logger.info(

View File

@ -23,6 +23,7 @@ import scalikejdbc.DB
import vinyldns.api.backend.dns.DnsProtocol.TryAgain
import vinyldns.api.domain.record.RecordSetChangeGenerator
import vinyldns.api.domain.record.RecordSetHelpers._
import vinyldns.core.Messages.{nonExistentRecordDataDeleteMessage, nonExistentRecordDeleteMessage}
import vinyldns.core.domain.backend.{Backend, BackendResponse}
import vinyldns.core.domain.batch.{BatchChangeRepository, SingleChange}
import vinyldns.core.domain.record._
@ -35,6 +36,11 @@ object RecordSetChangeHandler extends TransactionProvider {
private implicit val cs: ContextShift[IO] =
IO.contextShift(scala.concurrent.ExecutionContext.global)
private val outOfSyncFailureMessage: String = "This record set is out of sync with the DNS backend; sync this zone before attempting to update this record set."
private val incompatibleRecordFailureMessage: String = "Incompatible record in DNS."
private val syncZoneMessage: String = "This record set is out of sync with the DNS backend. Sync this zone before attempting to update this record set."
private val recordConflictMessage: String = "Conflict due to the record having the same name as an NS record in the same zone. Please create the record using the DNS service the NS record has been delegated to (ex. AWS r53), or use a different record name."
final case class Requeue(change: RecordSetChange) extends Throwable
def apply(
@ -87,15 +93,31 @@ object RecordSetChangeHandler extends TransactionProvider {
): IO[Unit] =
executeWithinTransaction { db: DB =>
for {
_ <- recordSetRepository.apply(db, changeSet)
_ <- recordChangeRepository.save(db, changeSet)
_ <- recordSetCacheRepository.save(db, changeSet)
// Update single changes within this transaction to rollback the changes made to recordset and record change repo
// when exception occurs while updating single changes
singleBatchChanges <- batchChangeRepository.getSingleChanges(
recordSetChange.singleBatchChangeIds
)
singleChangeStatusUpdates = updateBatchStatuses(singleBatchChanges, completedState.change)
updatedChangeSet = if (singleChangeStatusUpdates.size == 1) {
// Filter out RecordSetChange from changeSet if systemMessage matches
val filteredChangeSetChanges = changeSet.changes.filterNot { recordSetChange =>
// Find the corresponding singleChangeStatusUpdate by recordChangeId
singleChangeStatusUpdates.exists { singleChange =>
singleChange.recordChangeId.contains(recordSetChange.id) &&
singleChange.systemMessage.exists(msg =>
msg == nonExistentRecordDeleteMessage || msg == nonExistentRecordDataDeleteMessage
)
}
}
// Create a new ChangeSet with filtered changes
changeSet.copy(changes = filteredChangeSetChanges)
} else {
changeSet
}
_ <- recordSetRepository.apply(db, updatedChangeSet)
_ <- recordChangeRepository.save(db, updatedChangeSet)
_ <- recordSetCacheRepository.save(db, updatedChangeSet)
_ <- batchChangeRepository.updateSingleChanges(singleChangeStatusUpdates)
} yield ()
}
@ -106,7 +128,7 @@ object RecordSetChangeHandler extends TransactionProvider {
): List[SingleChange] =
recordSetChange.status match {
case RecordSetChangeStatus.Complete =>
singleChanges.map(_.complete(recordSetChange.systemMessage, recordSetChange.id, recordSetChange.recordSet.id))
singleChanges.map(_.complete(recordSetChange.id, recordSetChange.recordSet.id))
case RecordSetChangeStatus.Failed =>
singleChanges.map(_.withProcessingError(recordSetChange.systemMessage, recordSetChange.id))
case _ => singleChanges
@ -157,15 +179,6 @@ object RecordSetChangeHandler extends TransactionProvider {
def isDnsMatch(dnsResult: List[RecordSet], recordSet: RecordSet, zoneName: String): Boolean =
dnsResult.exists(matches(_, recordSet, zoneName))
def isRecordExist(existingRecords: List[RecordSet], change: RecordSetChange): Boolean = {
var isExists : Boolean = false
existingRecords.foreach(recordData=>
for (record<-change.recordSet.records)
if (recordData.records.contains(record)) isExists= true
else isExists= false )
isExists
}
// Determine processing status by comparing request against disposition of DNS backend
def getProcessingStatus(
change: RecordSetChange,
@ -174,9 +187,9 @@ object RecordSetChangeHandler extends TransactionProvider {
change.changeType match {
case RecordSetChangeType.Create =>
if (existingRecords.isEmpty) ReadyToApply(change)
else if (isDnsMatch(existingRecords, change.recordSet, change.zone.name) || isRecordExist(existingRecords,change))
AlreadyApplied(change) //Record exists in DNS
else Failure(change, "Incompatible record in DNS.")
else if (isDnsMatch(existingRecords, change.recordSet, change.zone.name))
AlreadyApplied(change)
else Failure(change, incompatibleRecordFailureMessage)
case RecordSetChangeType.Update =>
if (isDnsMatch(existingRecords, change.recordSet, change.zone.name))
@ -190,14 +203,23 @@ object RecordSetChangeHandler extends TransactionProvider {
else
Failure(
change,
"This record set is out of sync with the DNS backend; " +
"sync this zone before attempting to update this record set."
outOfSyncFailureMessage
)
}
case RecordSetChangeType.Delete =>
if (existingRecords.nonEmpty) ReadyToApply(change) // we have a record set, move forward
else AlreadyApplied(change) // we did not find the record set, so already applied
case RecordSetChangeType.Sync =>
if (existingRecords.nonEmpty) {
Failure(
change,
outOfSyncFailureMessage
)
} else {
AlreadyApplied(change)
}
}
}
@ -397,11 +419,25 @@ object RecordSetChangeHandler extends TransactionProvider {
case AlreadyApplied(_) => Completed(change.successful)
case ReadyToApply(_) => Validated(change)
case Failure(_, message) =>
Completed(
change.failed(
s"""Failed validating update to DNS for change "${change.id}": "${change.recordSet.name}": """ + message
if(message == outOfSyncFailureMessage || message == incompatibleRecordFailureMessage){
Completed(
change.failed(
syncZoneMessage
)
)
)
} else if (message == "referral") {
Completed(
change.failed(
recordConflictMessage
)
)
} else {
Completed(
change.failed(
s"""Failed validating update to DNS for change "${change.id}": "${change.recordSet.name}": """ + message
)
)
}
case Retry(_) => Retrying(change)
}

View File

@ -17,12 +17,16 @@
package vinyldns.api.engine
import cats.effect.IO
import org.slf4j.{Logger, LoggerFactory}
import scalikejdbc.DB
import vinyldns.api.engine.ZoneSyncHandler.executeWithinTransaction
import vinyldns.core.domain.record.{RecordSetCacheRepository, RecordSetRepository}
import vinyldns.core.domain.zone._
object ZoneChangeHandler {
private implicit val logger: Logger = LoggerFactory.getLogger("vinyldns.engine.ZoneChangeHandler")
def apply(
zoneRepository: ZoneRepository,
zoneChangeRepository: ZoneChangeRepository,
@ -54,6 +58,7 @@ object ZoneChangeHandler {
zoneChangeRepository.save(zoneChange.copy(status = ZoneChangeStatus.Synced))
}
case Right(_) =>
logger.info(s"Saving zone change with id: '${zoneChange.id}', zone name: '${zoneChange.zone.name}'")
zoneChangeRepository.save(zoneChange.copy(status = ZoneChangeStatus.Synced))
}
}

View File

@ -29,6 +29,7 @@ import vinyldns.core.domain.record._
import vinyldns.core.domain.zone._
import vinyldns.core.route.Monitored
import vinyldns.mysql.TransactionProvider
import java.io.{PrintWriter, StringWriter}
object ZoneSyncHandler extends DnsConversions with Monitored with TransactionProvider {
@ -78,6 +79,7 @@ object ZoneSyncHandler extends DnsConversions with Monitored with TransactionPro
)
)
case Right(_) =>
logger.info(s"Saving zone sync details for zone change with id: '${zoneChange.id}', zone name: '${zoneChange.zone.name}'")
zoneChangeRepository.save(zoneChange)
}
@ -169,9 +171,10 @@ object ZoneSyncHandler extends DnsConversions with Monitored with TransactionPro
}
}.attempt.map {
case Left(e: Throwable) =>
val errorMessage = new StringWriter
e.printStackTrace(new PrintWriter(errorMessage))
logger.error(
s"Encountered error syncing ; zoneName='${zoneChange.zone.name}'; zoneChange='${zoneChange.id}'",
e
s"Encountered error syncing ; zoneName='${zoneChange.zone.name}'; zoneChange='${zoneChange.id}'. Error: ${errorMessage.toString.replaceAll("\n",";").replaceAll("\t"," ")}"
)
// We want to just move back to an active status, do not update latest sync
zoneChange.copy(

View File

@ -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("<none>")}"
)
}
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("<none>"))
ccGroup <- groupRepository.getGroup(rsc.recordSet.recordSetGroupChange.map(_.requestedOwnerGroupId.getOrElse("<none>")).getOrElse("<none>"))
_ <- 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("<none>")
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("<none>")}"
)
}
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"<b>Description:</b> $comments</br>").getOrElse("")}
| <b>Created:</b> ${DateTimeFormatter.ofLocalizedDateTime(FormatStyle.FULL).withZone(ZoneId.systemDefault()).format(bc.createdTimestamp)} <br/>
| <b>Id:</b> ${bc.id}<br/>
| <b>Status:</b> ${formatStatus(bc.approvalStatus, bc.status)}<br/>""".stripMargin)
| <b>Status:</b> ${formatBatchStatus(bc.approvalStatus, bc.status)}<br/>""".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"""<h1>RecordSet Ownership Transfer</h1>
| <b>Submitter:</b> ${ userRepository.getUser(rsc.userId).map(_.get.userName)}
| <b>Id:</b> ${rsc.id}<br/>
| <b>Submitted time:</b> ${DateTimeFormatter.ofLocalizedDateTime(FormatStyle.FULL).withZone(ZoneId.systemDefault()).format(rsc.created)} <br/>
| <b>OwnerShip Current Group:</b> ${rsc.recordSet.ownerGroupId.getOrElse("none")} <br/>
| <b>OwnerShip Transfer Group:</b> ${rsc.recordSet.recordSetGroupChange.map(_.requestedOwnerGroupId.getOrElse("none")).getOrElse("none")} <br/>
| <b>OwnerShip Transfer Status:</b> ${formatOwnerShipStatus(rsc.recordSet.recordSetGroupChange.map(_.ownerShipTransferStatus).get)}<br/>
""".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(
_,

View File

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

View File

@ -25,6 +25,7 @@ import org.slf4j.LoggerFactory
import vinyldns.api.route.VinylDNSJsonProtocol
import vinyldns.core.domain.batch.BatchChange
import vinyldns.core.notifier.{Notification, Notifier}
import java.io.{PrintWriter, StringWriter}
class SnsNotifier(config: SnsNotifierConfig, sns: AmazonSNS)
extends Notifier
@ -52,6 +53,8 @@ class SnsNotifier(config: SnsNotifierConfig, sns: AmazonSNS)
sns.publish(request)
logger.info(s"Sending batch change success; batchChange='${bc.id}'")
}.handleErrorWith { e =>
IO(logger.error(s"Failed sending batch change; batchChange='${bc.id}'", e))
val errorMessage = new StringWriter
e.printStackTrace(new PrintWriter(errorMessage))
IO(logger.error(s"Failed sending batch change; batchChange='${bc.id}'. Error: ${errorMessage.toString.replaceAll("\n",";").replaceAll("\t"," ")}"))
}.void
}

View File

@ -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](_)

View File

@ -22,6 +22,7 @@ import java.time.Instant
import java.time.temporal.ChronoUnit
import org.slf4j.{Logger, LoggerFactory}
import scalikejdbc.DB
import vinyldns.core.domain.Encrypted
import vinyldns.core.domain.membership._
import vinyldns.core.domain.zone._
import vinyldns.mysql.TransactionProvider
@ -39,7 +40,7 @@ object TestDataLoader extends TransactionProvider {
id = "testuser",
created = Instant.now.truncatedTo(ChronoUnit.SECONDS),
accessKey = "testUserAccessKey",
secretKey = "testUserSecretKey",
secretKey = Encrypted("testUserSecretKey"),
firstName = Some("Test"),
lastName = Some("User"),
email = Some("test@test.com"),
@ -50,7 +51,7 @@ object TestDataLoader extends TransactionProvider {
id = "ok",
created = Instant.now.truncatedTo(ChronoUnit.SECONDS),
accessKey = "okAccessKey",
secretKey = "okSecretKey",
secretKey = Encrypted("okSecretKey"),
firstName = Some("ok"),
lastName = Some("ok"),
email = Some("test@test.com"),
@ -61,7 +62,7 @@ object TestDataLoader extends TransactionProvider {
id = "dummy",
created = Instant.now.truncatedTo(ChronoUnit.SECONDS),
accessKey = "dummyAccessKey",
secretKey = "dummySecretKey",
secretKey = Encrypted("dummySecretKey"),
isTest = true
)
final val sharedZoneUser = User(
@ -69,7 +70,7 @@ object TestDataLoader extends TransactionProvider {
id = "sharedZoneUser",
created = Instant.now.truncatedTo(ChronoUnit.SECONDS),
accessKey = "sharedZoneUserAccessKey",
secretKey = "sharedZoneUserSecretKey",
secretKey = Encrypted("sharedZoneUserSecretKey"),
firstName = Some("sharedZoneUser"),
lastName = Some("sharedZoneUser"),
email = Some("test@test.com"),
@ -80,7 +81,7 @@ object TestDataLoader extends TransactionProvider {
id = "locked",
created = Instant.now.truncatedTo(ChronoUnit.SECONDS),
accessKey = "lockedAccessKey",
secretKey = "lockedSecretKey",
secretKey = Encrypted("lockedSecretKey"),
firstName = Some("Locked"),
lastName = Some("User"),
email = Some("testlocked@test.com"),
@ -93,7 +94,7 @@ object TestDataLoader extends TransactionProvider {
id = "dummy%03d".format(runner),
created = Instant.now.truncatedTo(ChronoUnit.SECONDS),
accessKey = "dummy",
secretKey = "dummy",
secretKey = Encrypted("dummy"),
isTest = true
)
}
@ -102,7 +103,7 @@ object TestDataLoader extends TransactionProvider {
id = "list-group-user",
created = Instant.now.truncatedTo(ChronoUnit.SECONDS),
accessKey = "listGroupAccessKey",
secretKey = "listGroupSecretKey",
secretKey = Encrypted("listGroupSecretKey"),
firstName = Some("list-group"),
lastName = Some("list-group"),
email = Some("test@test.com"),
@ -114,7 +115,7 @@ object TestDataLoader extends TransactionProvider {
id = "list-zones-user",
created = Instant.now.truncatedTo(ChronoUnit.SECONDS),
accessKey = "listZonesAccessKey",
secretKey = "listZonesSecretKey",
secretKey = Encrypted("listZonesSecretKey"),
firstName = Some("list-zones"),
lastName = Some("list-zones"),
email = Some("test@test.com"),
@ -126,7 +127,7 @@ object TestDataLoader extends TransactionProvider {
id = "history-id",
created = Instant.now.truncatedTo(ChronoUnit.SECONDS),
accessKey = "history-key",
secretKey = "history-secret",
secretKey = Encrypted("history-secret"),
firstName = Some("history-first"),
lastName = Some("history-last"),
email = Some("history@history.com"),
@ -138,7 +139,7 @@ object TestDataLoader extends TransactionProvider {
id = "list-records-user",
created = Instant.now.truncatedTo(ChronoUnit.SECONDS),
accessKey = "listRecordsAccessKey",
secretKey = "listRecordsSecretKey",
secretKey = Encrypted("listRecordsSecretKey"),
firstName = Some("list-records"),
lastName = Some("list-records"),
email = Some("test@test.com"),
@ -150,7 +151,7 @@ object TestDataLoader extends TransactionProvider {
id = "list-batch-summaries-id",
created = Instant.now.truncatedTo(ChronoUnit.SECONDS),
accessKey = "listBatchSummariesAccessKey",
secretKey = "listBatchSummariesSecretKey",
secretKey = Encrypted("listBatchSummariesSecretKey"),
firstName = Some("list-batch-summaries"),
lastName = Some("list-batch-summaries"),
email = Some("test@test.com"),
@ -170,7 +171,7 @@ object TestDataLoader extends TransactionProvider {
id = "list-zero-summaries-id",
created = Instant.now.truncatedTo(ChronoUnit.SECONDS),
accessKey = "listZeroSummariesAccessKey",
secretKey = "listZeroSummariesSecretKey",
secretKey = Encrypted("listZeroSummariesSecretKey"),
firstName = Some("list-zero-summaries"),
lastName = Some("list-zero-summaries"),
email = Some("test@test.com"),
@ -182,7 +183,7 @@ object TestDataLoader extends TransactionProvider {
id = "support-user-id",
created = Instant.now.truncatedTo(ChronoUnit.SECONDS),
accessKey = "supportUserAccessKey",
secretKey = "supportUserSecretKey",
secretKey = Encrypted("supportUserSecretKey"),
firstName = Some("support-user"),
lastName = Some("support-user"),
email = Some("test@test.com"),
@ -190,6 +191,19 @@ object TestDataLoader extends TransactionProvider {
isTest = true
)
final val superUser = User(
userName = "super-user",
id = "super-user-id",
created = Instant.now.truncatedTo(ChronoUnit.SECONDS),
accessKey = "superUserAccessKey",
secretKey = Encrypted("superUserSecretKey"),
firstName = Some("super-user"),
lastName = Some("super-user"),
email = Some("test@test.com"),
isSuper = true,
isTest = true
)
final val sharedZoneGroup = Group(
name = "testSharedZoneGroup",
id = "shared-zone-group",
@ -276,7 +290,7 @@ object TestDataLoader extends TransactionProvider {
for {
_ <- (testUser :: okUser :: dummyUser :: sharedZoneUser :: lockedUser :: listGroupUser :: listZonesUser ::
listBatchChangeSummariesUser :: listZeroBatchChangeSummariesUser :: zoneHistoryUser :: supportUser ::
listRecordsUser :: listOfDummyUsers).map { user =>
superUser :: listRecordsUser :: listOfDummyUsers).map { user =>
userRepo.save(user)
}.parSequence
// if the test shared zones exist already, clean them out

View File

@ -88,6 +88,7 @@ trait BatchChangeJsonProtocol extends JsonValidation {
(
(js \ "inputName").required[String]("Missing BatchChangeInput.changes.inputName"),
recordType,
(js \ "systemMessage").optional[String],
(js \ "ttl").optional[Long],
recordType.andThen(extractRecord(_, js \ "record"))
).mapN(AddChangeInput.apply)
@ -114,6 +115,7 @@ trait BatchChangeJsonProtocol extends JsonValidation {
(
(js \ "inputName").required[String]("Missing BatchChangeInput.changes.inputName"),
recordType,
(js \ "systemMessage").optional[String],
recordData
).mapN(DeleteRRSetChangeInput.apply)
}
@ -249,8 +251,23 @@ trait BatchChangeJsonProtocol extends JsonValidation {
js.required[MXData](
"Missing BatchChangeInput.changes.record.preference and BatchChangeInput.changes.record.exchange"
)
case NS => js.required[NSData]("Missing BatchChangeInput.changes.record.nsdname")
case SRV => js.required[SRVData](
"Missing BatchChangeInput.changes.record.priority and " +
"Missing BatchChangeInput.changes.record.weight and " +
"Missing BatchChangeInput.changes.record.port and " +
"Missing BatchChangeInput.changes.record.target"
)
case NAPTR => js.required[NAPTRData](
"Missing BatchChangeInput.changes.record.order and " +
"Missing BatchChangeInput.changes.record.preference and " +
"Missing BatchChangeInput.changes.record.flags and " +
"Missing BatchChangeInput.changes.record.service and " +
"Missing BatchChangeInput.changes.record.regexp and " +
"Missing BatchChangeInput.changes.record.replacement"
)
case _ =>
s"Unsupported type $typ, valid types include: A, AAAA, CNAME, PTR, TXT, and MX".invalidNel
s"Unsupported type $typ, valid types include: A, AAAA, CNAME, PTR, TXT, MX, NS, SRV and NAPTR".invalidNel
}
}
}

View File

@ -16,13 +16,12 @@
package vinyldns.api.route
import akka.http.scaladsl.model.StatusCodes
import akka.http.scaladsl.model._
import akka.http.scaladsl.server.{RejectionHandler, Route, ValidationRejection}
import vinyldns.api.config.LimitsConfig
import org.slf4j.{Logger, LoggerFactory}
import vinyldns.api.config.ManualReviewConfig
import vinyldns.core.domain.batch._
import vinyldns.api.config.{LimitsConfig, ManualReviewConfig}
import vinyldns.api.domain.batch._
import vinyldns.core.domain.batch._
class BatchChangeRoute(
batchChangeService: BatchChangeServiceAlgebra,
@ -71,22 +70,28 @@ class BatchChangeRoute(
}
}
} ~
(get & monitor("Endpoint.listBatchChangeSummaries")) {
parameters(
"startFrom".as[Int].?,
"maxItems".as[Int].?(MAX_ITEMS_LIMIT),
"ignoreAccess".as[Boolean].?(false),
"approvalStatus".as[String].?
) {
(
startFrom: Option[Int],
maxItems: Int,
ignoreAccess: Boolean,
approvalStatus: Option[String]
) =>
{
val convertApprovalStatus = approvalStatus.flatMap(BatchChangeApprovalStatus.find)
(get & monitor("Endpoint.listBatchChangeSummaries")) {
parameters(
"userName".as[String].?,
"dateTimeRangeStart".as[String].?,
"dateTimeRangeEnd".as[String].?,
"startFrom".as[Int].?,
"maxItems".as[Int].?(MAX_ITEMS_LIMIT),
"ignoreAccess".as[Boolean].?(false),
"approvalStatus".as[String].?
) {
(
userName: Option[String],
dateTimeRangeStart: Option[String],
dateTimeRangeEnd: Option[String],
startFrom: Option[Int],
maxItems: Int,
ignoreAccess: Boolean,
approvalStatus: Option[String]
) =>
{
val convertApprovalStatus = approvalStatus.flatMap(BatchChangeApprovalStatus.find)
handleRejections(invalidQueryHandler) {
validate(
0 < maxItems && maxItems <= MAX_ITEMS_LIMIT,
@ -95,9 +100,14 @@ class BatchChangeRoute(
authenticateAndExecute(
batchChangeService.listBatchChangeSummaries(
_,
userName,
dateTimeRangeStart,
dateTimeRangeEnd,
startFrom,
maxItems,
ignoreAccess,
// TODO: Update batch status from None to its actual value when the feature is ready for release
None,
convertApprovalStatus
)
) { summaries =>
@ -106,9 +116,9 @@ class BatchChangeRoute(
}
}
}
}
}
}
} ~
} ~
path("zones" / "batchrecordchanges" / Segment) { id =>
(get & monitor("Endpoint.getBatchChange")) {
authenticateAndExecute(batchChangeService.getBatchChange(id, _)) { chg =>

View File

@ -27,10 +27,12 @@ import scodec.bits.{Bases, ByteVector}
import vinyldns.api.domain.zone.{RecordSetGlobalInfo, RecordSetInfo, RecordSetListInfo}
import vinyldns.core.domain.DomainHelpers.ensureTrailingDot
import vinyldns.core.domain.DomainHelpers.removeWhitespace
import vinyldns.core.domain.Fqdn
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._
@ -40,12 +42,15 @@ trait DnsJsonProtocol extends JsonValidation {
UpdateZoneInputSerializer,
ZoneConnectionSerializer,
AlgorithmSerializer,
EncryptedSerializer,
RecordSetSerializer,
ownerShipTransferSerializer,
RecordSetListInfoSerializer,
RecordSetGlobalInfoSerializer,
RecordSetInfoSerializer,
RecordSetChangeSerializer,
JsonEnumV(ZoneStatus),
JsonEnumV(OwnerShipTransferStatus),
JsonEnumV(ZoneChangeStatus),
JsonEnumV(RecordSetStatus),
JsonEnumV(RecordSetChangeStatus),
@ -53,6 +58,7 @@ trait DnsJsonProtocol extends JsonValidation {
JsonEnumV(ZoneChangeType),
JsonEnumV(RecordSetChangeType),
JsonEnumV(NameSort),
JsonEnumV(RecordTypeSort),
ASerializer,
AAAASerializer,
CNAMESerializer,
@ -84,6 +90,18 @@ trait DnsJsonProtocol extends JsonValidation {
(js \ "id").default[String](UUID.randomUUID.toString),
(js \ "singleBatchChangeIds").default[List[String]](List())
).mapN(RecordSetChange.apply)
override def toJson(rs: RecordSetChange): JValue =
("zone" -> Extraction.decompose(rs.zone)) ~
("recordSet" -> Extraction.decompose(rs.recordSet)) ~
("userId" -> rs.userId) ~
("changeType" -> Extraction.decompose(rs.changeType)) ~
("status" -> Extraction.decompose(rs.status)) ~
("created" -> Extraction.decompose(rs.created)) ~
("systemMessage" -> rs.systemMessage) ~
("updates" -> Extraction.decompose(rs.updates)) ~
("id" -> rs.id) ~
("singleBatchChangeIds" -> Extraction.decompose(rs.singleBatchChangeIds))
}
case object CreateZoneInputSerializer extends ValidationSerializer[CreateZoneInput] {
@ -99,7 +117,9 @@ trait DnsJsonProtocol extends JsonValidation {
(js \ "shared").default[Boolean](false),
(js \ "acl").default[ZoneACL](ZoneACL()),
(js \ "adminGroupId").required[String]("Missing Zone.adminGroupId"),
(js \ "backendId").optional[String]
(js \ "backendId").optional[String],
(js \ "recurrenceSchedule").optional[String],
(js \ "scheduleRequestor").optional[String],
).mapN(CreateZoneInput.apply)
}
@ -116,7 +136,9 @@ trait DnsJsonProtocol extends JsonValidation {
(js \ "shared").default[Boolean](false),
(js \ "acl").default[ZoneACL](ZoneACL()),
(js \ "adminGroupId").required[String]("Missing Zone.adminGroupId"),
(js \ "backendId").optional[String]
(js \ "recurrenceSchedule").optional[String],
(js \ "scheduleRequestor").optional[String],
(js \ "backendId").optional[String],
).mapN(UpdateZoneInput.apply)
}
@ -130,18 +152,30 @@ trait DnsJsonProtocol extends JsonValidation {
override def toJson(a: Algorithm): JValue = JString(a.name)
}
case object EncryptedSerializer extends ValidationSerializer[Encrypted] {
override def fromJson(js: JValue): ValidatedNel[String, Encrypted] =
js match {
case JString(value) => EncryptFromJson.fromString(value).toValidatedNel
case _ => "Unsupported type for zone connection key, must be a string".invalidNel
}
override def toJson(a: Encrypted): JValue = JString(a.value)
}
case object ZoneConnectionSerializer extends ValidationSerializer[ZoneConnection] {
override def fromJson(js: JValue): ValidatedNel[String, ZoneConnection] =
(
(js \ "name").required[String]("Missing ZoneConnection.name"),
(js \ "keyName").required[String]("Missing ZoneConnection.keyName"),
(js \ "key").required[String]("Missing ZoneConnection.key"),
(js \ "key").required[Encrypted]("Missing ZoneConnection.key"),
(js \ "primaryServer").required[String]("Missing ZoneConnection.primaryServer"),
(js \ "algorithm").default[Algorithm](Algorithm.HMAC_MD5)
).mapN(ZoneConnection.apply)
}
def checkDomainNameLen(s: String): Boolean = s.length <= 255
def validateNaptrFlag(flag: String): Boolean = flag == "U" || flag == "S" || flag == "A" || flag == "P"
def validateNaptrRegexp(regexp: String): Boolean = regexp.startsWith("!") && regexp.endsWith("!") || regexp == ""
def nameContainsDots(s: String): Boolean = s.contains(".")
def nameDoesNotContainSpaces(s: String): Boolean = !s.contains(" ")
@ -202,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)
@ -226,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] =
(
@ -250,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)
}
@ -271,6 +321,7 @@ trait DnsJsonProtocol extends JsonValidation {
("account" -> rs.account) ~
("ownerGroupId" -> rs.ownerGroupId) ~
("ownerGroupName" -> rs.ownerGroupName) ~
("recordSetGroupChange" -> Extraction.decompose(rs.recordSetGroupChange)) ~
("fqdn" -> rs.fqdn)
}
@ -296,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)
@ -487,7 +539,7 @@ trait DnsJsonProtocol extends JsonValidation {
(js \ "flags")
.required[String]("Missing NAPTR.flags")
.check(
"NAPTR.flags must be less than 2 characters" -> (_.length < 2)
"Invalid NAPTR.flag. Valid NAPTR flag value must be U, S, A or P" -> validateNaptrFlag
),
(js \ "service")
.required[String]("Missing NAPTR.service")
@ -497,7 +549,7 @@ trait DnsJsonProtocol extends JsonValidation {
(js \ "regexp")
.required[String]("Missing NAPTR.regexp")
.check(
"NAPTR.regexp must be less than 255 characters" -> checkDomainNameLen
"Invalid NAPTR.regexp. Valid NAPTR regexp value must start and end with '!' or can be empty" -> validateNaptrRegexp
),
(js \ "replacement")

View File

@ -49,6 +49,7 @@ class MembershipRoute(
case InvalidGroupError(msg) => complete(StatusCodes.BadRequest, msg)
case UserNotFoundError(msg) => complete(StatusCodes.NotFound, msg)
case InvalidGroupRequestError(msg) => complete(StatusCodes.BadRequest, msg)
case EmailValidationError(msg) => complete(StatusCodes.BadRequest, msg)
}
val membershipRoute: Route = path("groups" / Segment) { groupId =>
@ -161,8 +162,8 @@ class MembershipRoute(
} ~
path("groups" / Segment / "activity") { groupId =>
(get & monitor("Endpoint.groupActivity")) {
parameters("startFrom".?, "maxItems".as[Int].?(DEFAULT_MAX_ITEMS)) {
(startFrom: Option[String], maxItems: Int) =>
parameters("startFrom".as[Int].?, "maxItems".as[Int].?(DEFAULT_MAX_ITEMS)) {
(startFrom: Option[Int], maxItems: Int) =>
handleRejections(invalidQueryHandler) {
validate(
0 < maxItems && maxItems <= MAX_ITEMS_LIMIT,
@ -186,6 +187,13 @@ class MembershipRoute(
}
}
} ~
path("groups" / "valid" / "domains") {
(get & monitor("Endpoint.validdomains")) {
authenticateAndExecute(membershipService.listEmailDomains) { emailDomains =>
complete(StatusCodes.OK, emailDomains)
}
}
} ~
path("users" / Segment / "lock") { id =>
(put & monitor("Endpoint.lockUser")) {
authenticateAndExecute(membershipService.updateUserLockStatus(id, LockStatus.Locked, _)) {
@ -204,9 +212,9 @@ class MembershipRoute(
} ~
path("users" / Segment) { id =>
(get & monitor("Endpoint.getUser")) {
authenticateAndExecute(membershipService.getUser(id, _)) {
authenticateAndExecute(membershipService.getUserDetails(id, _)) {
user =>
complete(StatusCodes.OK, UserResponseInfo(user))
complete(StatusCodes.OK, user)
}
}
}

View File

@ -26,7 +26,8 @@ import vinyldns.api.config.LimitsConfig
import vinyldns.api.domain.zone._
import vinyldns.core.domain.record.NameSort.NameSort
import vinyldns.core.domain.record.RecordType.RecordType
import vinyldns.core.domain.record.{NameSort, RecordSet, RecordType}
import vinyldns.core.domain.record.RecordTypeSort.RecordTypeSort
import vinyldns.core.domain.record.{NameSort, RecordSet, RecordType, RecordTypeSort}
import vinyldns.core.domain.zone.ZoneCommandResult
import scala.concurrent.duration._
@ -52,7 +53,8 @@ case class ListRecordSetsByZoneResponse(
recordNameFilter: Option[String] = None,
recordTypeFilter: Option[Set[RecordType]] = None,
recordOwnerGroupFilter: Option[String] = None,
nameSort: NameSort
nameSort: NameSort,
recordTypeSort: RecordTypeSort
)
class RecordSetRoute(
@ -100,7 +102,8 @@ class RecordSetRoute(
"recordNameFilter".?,
"recordTypeFilter".?,
"recordOwnerGroupFilter".?,
"nameSort".as[String].?("ASC")
"nameSort".as[String].?("ASC"),
"recordTypeSort".as[String].?("None")
) {
(
startFrom: Option[String],
@ -108,7 +111,8 @@ class RecordSetRoute(
recordNameFilter: Option[String],
recordTypeFilter: Option[String],
recordOwnerGroupFilter: Option[String],
nameSort: String
nameSort: String,
recordTypeSort: String
) =>
val convertedRecordTypeFilter = convertRecordTypeFilter(recordTypeFilter)
handleRejections(invalidQueryHandler) {
@ -126,8 +130,9 @@ class RecordSetRoute(
convertedRecordTypeFilter,
recordOwnerGroupFilter,
NameSort.find(nameSort),
_
)
_,
RecordTypeSort.find(recordTypeSort),
)
) { rsResponse =>
complete(StatusCodes.OK, rsResponse)
}
@ -144,7 +149,8 @@ class RecordSetRoute(
"recordNameFilter".as[String],
"recordTypeFilter".?,
"recordOwnerGroupFilter".?,
"nameSort".as[String].?("ASC")
"nameSort".as[String].?("ASC"),
"recordTypeSort".as[String].?("NONE")
) {
(
startFrom: Option[String],
@ -152,7 +158,8 @@ class RecordSetRoute(
recordNameFilter: String,
recordTypeFilter: Option[String],
recordOwnerGroupFilter: Option[String],
nameSort: String
nameSort: String,
recordTypeSort: String
) =>
val convertedRecordTypeFilter = convertRecordTypeFilter(recordTypeFilter)
handleRejections(invalidQueryHandler) {
@ -169,7 +176,8 @@ class RecordSetRoute(
convertedRecordTypeFilter,
recordOwnerGroupFilter,
NameSort.find(nameSort),
_
_,
RecordTypeSort.find(recordTypeSort)
)
) { rsResponse =>
complete(StatusCodes.OK, rsResponse)
@ -179,6 +187,13 @@ class RecordSetRoute(
}
}
} ~
path("zones" / Segment / "recordsetcount") { zoneId =>
(get & monitor("Endpoint.getRecordSetCount")) {
authenticateAndExecute(recordSetService.getRecordSetCount(zoneId, _)) { count =>
complete(StatusCodes.OK, count)
}
}
} ~
path("zones" / Segment / "recordsets" / Segment) { (zoneId, rsId) =>
(get & monitor("Endpoint.getRecordSetByZone")) {
authenticateAndExecute(recordSetService.getRecordSetByZone(rsId, zoneId, _)) { rs =>
@ -215,8 +230,8 @@ class RecordSetRoute(
} ~
path("zones" / Segment / "recordsetchanges") { zoneId =>
(get & monitor("Endpoint.listRecordSetChanges")) {
parameters("startFrom".?, "maxItems".as[Int].?(DEFAULT_MAX_ITEMS)) {
(startFrom: Option[String], maxItems: Int) =>
parameters("startFrom".as[Int].?, "maxItems".as[Int].?(DEFAULT_MAX_ITEMS)) {
(startFrom: Option[Int], maxItems: Int) =>
handleRejections(invalidQueryHandler) {
validate(
check = 0 < maxItems && maxItems <= DEFAULT_MAX_ITEMS,
@ -233,6 +248,52 @@ class RecordSetRoute(
}
}
}
} ~
path("recordsetchange" / "history") {
(get & monitor("Endpoint.listRecordSetChangeHistory")) {
parameters("zoneId".as[String].?, "startFrom".as[Int].?, "maxItems".as[Int].?(DEFAULT_MAX_ITEMS), "fqdn".as[String].?, "recordType".as[String].?) {
(zoneId: Option[String], startFrom: Option[Int], maxItems: Int, fqdn: Option[String], recordType: Option[String]) =>
handleRejections(invalidQueryHandler) {
val errorMessage = if(fqdn.isEmpty || recordType.isEmpty || zoneId.isEmpty) {
"recordType, fqdn and zoneId cannot be empty"
} else {
s"maxItems was $maxItems, maxItems must be between 0 exclusive " +
s"and $DEFAULT_MAX_ITEMS inclusive"
}
val isValid = (0 < maxItems && maxItems <= DEFAULT_MAX_ITEMS) && (fqdn.nonEmpty && recordType.nonEmpty && zoneId.nonEmpty)
validate(
check = isValid,
errorMsg = errorMessage
){
authenticateAndExecute(
recordSetService
.listRecordSetChangeHistory(zoneId, startFrom, maxItems, fqdn, RecordType.find(recordType.get), _)
) { changes =>
complete(StatusCodes.OK, changes)
}
}
}
}
}
} ~
path("metrics" / "health" / "zones" / Segment / "recordsetchangesfailure") {zoneId =>
(get & monitor("Endpoint.listFailedRecordSetChanges")) {
parameters("startFrom".as[Int].?(0), "maxItems".as[Int].?(DEFAULT_MAX_ITEMS)) {
(startFrom: Int, maxItems: Int) =>
handleRejections(invalidQueryHandler) {
validate(
check = 0 < maxItems && maxItems <= DEFAULT_MAX_ITEMS,
errorMsg = s"maxItems was $maxItems, maxItems must be between 0 exclusive " +
s"and $DEFAULT_MAX_ITEMS inclusive"
){
authenticateAndExecute(recordSetService.listFailedRecordSetChanges(_, Some(zoneId), startFrom, maxItems)) {
changes =>
complete(StatusCodes.OK, changes)
}
}
}
}
}
}
private val invalidQueryHandler = RejectionHandler

View File

@ -21,6 +21,7 @@ import akka.http.scaladsl.server._
import akka.util.Timeout
import org.slf4j.{Logger, LoggerFactory}
import vinyldns.api.config.LimitsConfig
import vinyldns.api.domain.membership.EmailValidationError
import vinyldns.api.domain.zone._
import vinyldns.core.crypto.CryptoAlgebra
import vinyldns.core.domain.zone._
@ -28,6 +29,7 @@ import vinyldns.core.domain.zone._
import scala.concurrent.duration._
case class GetZoneResponse(zone: ZoneInfo)
case class GetZoneDetailsResponse(zone: ZoneDetails)
case class ZoneRejected(zone: Zone, errors: List[String])
class ZoneRoute(
@ -62,6 +64,7 @@ class ZoneRoute(
case RecentSyncError(msg) => complete(StatusCodes.Forbidden, msg)
case ZoneInactiveError(msg) => complete(StatusCodes.BadRequest, msg)
case InvalidRequest(msg) => complete(StatusCodes.BadRequest, msg)
case EmailValidationError(msg) => complete(StatusCodes.BadRequest, msg)
}
val zoneRoute: Route = path("zones") {
@ -79,14 +82,16 @@ class ZoneRoute(
"startFrom".as[String].?,
"maxItems".as[Int].?(DEFAULT_MAX_ITEMS),
"searchByAdminGroup".as[Boolean].?(false),
"ignoreAccess".as[Boolean].?(false)
"ignoreAccess".as[Boolean].?(false),
"includeReverse".as[Boolean].?(true)
) {
(
nameFilter: Option[String],
startFrom: Option[String],
maxItems: Int,
searchByAdminGroup: Boolean,
ignoreAccess: Boolean
ignoreAccess: Boolean,
includeReverse: Boolean
) =>
{
handleRejections(invalidQueryHandler) {
@ -96,7 +101,7 @@ class ZoneRoute(
) {
authenticateAndExecute(
zoneService
.listZones(_, nameFilter, startFrom, maxItems, searchByAdminGroup, ignoreAccess)
.listZones(_, nameFilter, startFrom, maxItems, searchByAdminGroup, ignoreAccess, includeReverse)
) { result =>
complete(StatusCodes.OK, result)
}
@ -106,6 +111,38 @@ class ZoneRoute(
}
}
} ~
path("zones" / "deleted" / "changes") {
(get & monitor("Endpoint.listDeletedZones")) {
parameters(
"nameFilter".?,
"startFrom".as[String].?,
"maxItems".as[Int].?(DEFAULT_MAX_ITEMS),
"ignoreAccess".as[Boolean].?(false)
) {
(
nameFilter: Option[String],
startFrom: Option[String],
maxItems: Int,
ignoreAccess: Boolean
) =>
{
handleRejections(invalidQueryHandler) {
validate(
0 < maxItems && maxItems <= MAX_ITEMS_LIMIT,
s"maxItems was $maxItems, maxItems must be between 0 and $MAX_ITEMS_LIMIT"
) {
authenticateAndExecute(
zoneService
.listDeletedZones(_, nameFilter, startFrom, maxItems, ignoreAccess)
) { result =>
complete(StatusCodes.OK, result)
}
}
}
}
}
}
} ~
path("zones" / "backendids") {
(get & monitor("Endpoint.getBackendIds")) {
authenticateAndExecute(_ => zoneService.getBackendIds()) { ids =>
@ -138,6 +175,13 @@ class ZoneRoute(
}
}
} ~
path("zones" / Segment / "details") { id =>
(get & monitor("Endpoint.getCommonZoneDetails")) {
authenticateAndExecute(zoneService.getCommonZoneDetails(id, _)) { zone =>
complete(StatusCodes.OK, GetZoneDetailsResponse(zone))
}
}
} ~
path("zones" / Segment / "sync") { id =>
(post & monitor("Endpoint.syncZone")) {
authenticateAndExecute(zoneService.syncZone(id, _)) { chg =>
@ -163,6 +207,24 @@ class ZoneRoute(
}
}
} ~
path("metrics" / "health" / "zonechangesfailure") {
(get & monitor("Endpoint.listFailedZoneChanges")) {
parameters("startFrom".as[Int].?(0), "maxItems".as[Int].?(DEFAULT_MAX_ITEMS)) {
(startFrom: Int, maxItems: Int) =>
handleRejections(invalidQueryHandler) {
validate(
0 < maxItems && maxItems <= DEFAULT_MAX_ITEMS,
s"maxItems was $maxItems, maxItems must be between 0 exclusive and $DEFAULT_MAX_ITEMS inclusive"
) {
authenticateAndExecute(zoneService.listFailedZoneChanges(_, startFrom, maxItems)) {
changes =>
complete(StatusCodes.OK, changes)
}
}
}
}
}
} ~
path("zones" / Segment / "acl" / "rules") { id =>
(put & monitor("Endpoint.addZoneACLRule")) {
authenticateAndExecuteWithEntity[ZoneCommandResult, ACLRuleInfo](

View File

@ -2,11 +2,11 @@ pyhamcrest==2.0.2
pytz>=2014
pytest==6.2.5
mock==4.0.3
dnspython==2.1.0
dnspython==2.6.1
boto3==1.18.51
botocore==1.21.51
requests==2.26.0
requests==2.32.3
pytest-xdist==2.4.0
python-dateutil==2.8.2
filelock==3.2.0
pytest-custom_exit_code==0.3.0
pytest-custom_exit_code==0.3.0

View File

@ -14,7 +14,7 @@ def validate_change_error_response_basics(input_json, change_type, input_name, r
assert_that(input_json["changeType"], is_(change_type))
assert_that(input_json["inputName"], is_(input_name))
assert_that(input_json["type"], is_(record_type))
assert_that(record_type, is_in(["A", "AAAA", "CNAME", "PTR", "TXT", "MX"]))
assert_that(record_type, is_in(["A", "AAAA", "CNAME", "PTR", "TXT", "MX", "NS", "NAPTR", "SRV"]))
if change_type == "Add":
assert_that(input_json["ttl"], is_(ttl))
if record_type in ["A", "AAAA"]:
@ -28,6 +28,20 @@ def validate_change_error_response_basics(input_json, change_type, input_name, r
elif record_type == "MX":
assert_that(input_json["record"]["preference"], is_(record_data["preference"]))
assert_that(input_json["record"]["exchange"], is_(record_data["exchange"]))
elif record_type == "NS" and change_type == "Add":
assert_that(input_json["record"]["nsdname"], is_(record_data))
elif record_type == "NAPTR" and change_type == "Add":
assert_that(input_json["record"]["order"], is_(record_data["order"]))
assert_that(input_json["record"]["preference"], is_(record_data["preference"]))
assert_that(input_json["record"]["flags"], is_(record_data["flags"]))
assert_that(input_json["record"]["service"], is_(record_data["service"]))
assert_that(input_json["record"]["regexp"], is_(record_data["regexp"]))
assert_that(input_json["record"]["replacement"], is_(record_data["replacement"]))
elif record_type == "SRV" and change_type == "Add":
assert_that(input_json["record"]["priority"], is_(record_data["priority"]))
assert_that(input_json["record"]["weight"], is_(record_data["weight"]))
assert_that(input_json["record"]["port"], is_(record_data["port"]))
assert_that(input_json["record"]["target"], is_(record_data["target"]))
return
@ -56,7 +70,7 @@ def assert_change_success(changes_json, zone, index, record_name, input_name, re
assert_that(changes_json[index]["type"], is_(record_type))
assert_that(changes_json[index]["id"], is_not(none()))
assert_that(changes_json[index]["changeType"], is_(change_type))
assert_that(record_type, is_in(["A", "AAAA", "CNAME", "PTR", "TXT", "MX"]))
assert_that(record_type, is_in(["A", "AAAA", "CNAME", "PTR", "TXT", "MX", "NS", "NAPTR", "SRV"]))
if record_type in ["A", "AAAA"] and change_type == "Add":
assert_that(changes_json[index]["record"]["address"], is_(record_data))
elif record_type == "CNAME" and change_type == "Add":
@ -68,6 +82,20 @@ def assert_change_success(changes_json, zone, index, record_name, input_name, re
elif record_type == "MX" and change_type == "Add":
assert_that(changes_json[index]["record"]["preference"], is_(record_data["preference"]))
assert_that(changes_json[index]["record"]["exchange"], is_(record_data["exchange"]))
elif record_type == "NS" and change_type == "Add":
assert_that(changes_json[index]["record"]["nsdname"], is_(record_data))
elif record_type == "NAPTR" and change_type == "Add":
assert_that(changes_json[index]["record"]["order"], is_(record_data["order"]))
assert_that(changes_json[index]["record"]["preference"], is_(record_data["preference"]))
assert_that(changes_json[index]["record"]["flags"], is_(record_data["flags"]))
assert_that(changes_json[index]["record"]["service"], is_(record_data["service"]))
assert_that(changes_json[index]["record"]["regexp"], is_(record_data["regexp"]))
assert_that(changes_json[index]["record"]["replacement"], is_(record_data["replacement"]))
elif record_type == "SRV" and change_type == "Add":
assert_that(changes_json[index]["record"]["priority"], is_(record_data["priority"]))
assert_that(changes_json[index]["record"]["weight"], is_(record_data["weight"]))
assert_that(changes_json[index]["record"]["port"], is_(record_data["port"]))
assert_that(changes_json[index]["record"]["target"], is_(record_data["target"]))
return
@ -113,7 +141,9 @@ def test_create_batch_change_with_adds_success(shared_zone_test_context):
get_change_TXT_json(f"txt-unique-characters.{ok_zone_name}", text='a\\\\`=` =\\"Cat\\"\nattr=val'),
get_change_TXT_json(f"txt.{ip4_zone_name}"),
get_change_MX_json(f"mx.{ok_zone_name}", preference=0),
get_change_MX_json(f"{ok_zone_name}", preference=1000, exchange="bar.foo.")
get_change_MX_json(f"{ok_zone_name}", preference=1000, exchange="bar.foo."),
get_change_NAPTR_json(f"naptr.{ok_zone_name}", order=1, preference=1000, flags="U", service="E2U+sip", regexp="!.*!test.!", replacement="target.vinyldns."),
get_change_SRV_json(f"srv.{ok_zone_name}", priority=1000, weight=5, port=20, target="bar.foo.")
]
}
@ -161,6 +191,10 @@ def test_create_batch_change_with_adds_success(shared_zone_test_context):
record_name="mx", input_name=f"mx.{ok_zone_name}", record_data={"preference": 0, "exchange": "foo.bar."}, record_type="MX")
assert_change_success(result["changes"], zone=ok_zone, index=14,
record_name=f"{ok_zone_name}", input_name=f"{ok_zone_name}", record_data={"preference": 1000, "exchange": "bar.foo."}, record_type="MX")
assert_change_success(result["changes"], zone=ok_zone, index=15,
record_name=f"naptr", input_name=f"naptr.{ok_zone_name}", record_data={"order": 1, "preference": 1000, "flags": "U", "service": "E2U+sip", "regexp": "!.*!test.!", "replacement": "target.vinyldns."}, record_type="NAPTR")
assert_change_success(result["changes"], zone=ok_zone, index=16,
record_name=f"srv", input_name=f"srv.{ok_zone_name}", record_data={"priority": 1000, "weight": 5, "port": 20, "target": "bar.foo."}, record_type="SRV")
completed_status = [change["status"] == "Complete" for change in completed_batch["changes"]]
assert_that(all(completed_status), is_(True))
@ -285,6 +319,22 @@ def test_create_batch_change_with_adds_success(shared_zone_test_context):
"ttl": 200,
"records": [{"preference": 1000, "exchange": "bar.foo."}]}
verify_recordset(rs16, expected16)
rs17 = client.get_recordset(record_set_list[15][0], record_set_list[15][1])["recordSet"]
expected17 = {"name": f"naptr",
"zoneId": ok_zone["id"],
"type": "NAPTR",
"ttl": 200,
"records": [{"order": 1, "preference": 1000, "flags": "U", "service": "E2U+sip", "regexp": "!.*!test.!", "replacement": "target.vinyldns."}]}
verify_recordset(rs17, expected17)
rs18 = client.get_recordset(record_set_list[16][0], record_set_list[16][1])["recordSet"]
expected18 = {"name": f"srv",
"zoneId": ok_zone["id"],
"type": "SRV",
"ttl": 200,
"records": [{"priority": 1000, "weight": 5, "port": 20, "target": "bar.foo."}]}
verify_recordset(rs18, expected18)
finally:
clear_zoneid_rsid_tuple_list(to_delete, client)
@ -316,6 +366,30 @@ def test_create_batch_change_with_scheduled_time_and_owner_group_succeeds(shared
rejecter.reject_batch_change(result["id"], status=200)
@pytest.mark.manual_batch_review
def test_create_batch_change_with_ns_record_goes_to_review(shared_zone_test_context):
"""
Test creating a batch change with ns record goes to review
"""
client = shared_zone_test_context.ok_vinyldns_client
ok_zone_name = shared_zone_test_context.ok_zone["name"]
batch_change_input = {
"comments": "this is optional",
"changes": [
get_change_NS_json(f"ns.{ok_zone_name}", nsdname="ns1.parent.com."),
],
"ownerGroupId": shared_zone_test_context.ok_group["id"]
}
result = None
try:
result = client.create_batch_change(batch_change_input, status=202)
assert_that(result["status"], "Pending Review")
finally:
if result:
rejecter = shared_zone_test_context.support_user_client
rejecter.reject_batch_change(result["id"], status=200)
@pytest.mark.manual_batch_review
def test_create_scheduled_batch_change_with_zone_discovery_error_without_owner_group_fails(shared_zone_test_context):
"""
@ -890,7 +964,7 @@ def test_create_batch_change_with_unsupported_record_type_fails(shared_zone_test
errors = client.create_batch_change(batch_change_input, status=400)
assert_error(errors,
error_messages=["Unsupported type UNKNOWN, valid types include: A, AAAA, CNAME, PTR, TXT, and MX"])
error_messages=["Unsupported type UNKNOWN, valid types include: A, AAAA, CNAME, PTR, TXT, MX, NS, SRV and NAPTR"])
def test_create_batch_change_with_high_value_domain_fails(shared_zone_test_context):
@ -1481,8 +1555,8 @@ def test_a_recordtype_add_checks(shared_zone_test_context):
# context validations: conflicting recordsets, unauthorized error
assert_failed_change_in_error_response(response[7], input_name=existing_a_fqdn, record_data="1.2.3.4",
error_messages=[f"RecordName \"{existing_a_fqdn}\" already exists. Your request will be manually reviewed. "
f"If you intended to update this record, you can avoid manual review by adding a DeleteRecordSet entry followed by an Add."])
error_messages=[f"RecordName \"{existing_a_fqdn}\" already exists. "
f"If you intended to update this record, submit a DeleteRecordSet entry followed by an Add."])
assert_failed_change_in_error_response(response[8], input_name=existing_cname_fqdn,
record_data="1.2.3.4",
error_messages=[f'CNAME Conflict: CNAME record names must be unique. '
@ -1938,7 +2012,8 @@ def test_cname_recordtype_add_checks(shared_zone_test_context):
get_change_CNAME_json(existing_forward_fqdn),
get_change_CNAME_json(existing_cname_fqdn),
get_change_CNAME_json(f"0.{ip4_zone_name}", cname="duplicate.in.db."),
get_change_CNAME_json(f"user-add-unauthorized.{dummy_zone_name}")
get_change_CNAME_json(f"user-add-unauthorized.{dummy_zone_name}"),
get_change_CNAME_json(f"invalid-ipv4-{parent_zone_name}", cname="1.2.3.4")
]
}
@ -2003,8 +2078,8 @@ def test_cname_recordtype_add_checks(shared_zone_test_context):
f"Existing record with name \"{existing_forward_fqdn}\" and type \"A\" conflicts with this record."])
assert_failed_change_in_error_response(response[14], input_name=existing_cname_fqdn,
record_type="CNAME", record_data="test.com.",
error_messages=[f"RecordName \"{existing_cname_fqdn}\" already exists. Your request will be manually reviewed. "
f"If you intended to update this record, you can avoid manual review by adding a DeleteRecordSet entry followed by an Add.",
error_messages=[f"RecordName \"{existing_cname_fqdn}\" already exists. "
f"If you intended to update this record, submit a DeleteRecordSet entry followed by an Add.",
f"CNAME Conflict: CNAME record names must be unique. "
f"Existing record with name \"{existing_cname_fqdn}\" and type \"CNAME\" conflicts with this record."])
assert_failed_change_in_error_response(response[15], input_name=existing_reverse_fqdn, record_type="CNAME",
@ -2014,6 +2089,9 @@ def test_cname_recordtype_add_checks(shared_zone_test_context):
assert_failed_change_in_error_response(response[16], input_name=f"user-add-unauthorized.{dummy_zone_name}",
record_type="CNAME", record_data="test.com.",
error_messages=[f"User \"ok\" is not authorized. Contact zone owner group: {dummy_group_name} at test@test.com to make DNS changes."])
assert_failed_change_in_error_response(response[17], input_name=f"invalid-ipv4-{parent_zone_name}", record_type="CNAME", record_data="1.2.3.4.",
error_messages=[f'Invalid Cname: "Fqdn(1.2.3.4.)", Valid CNAME record data should not be an IP address'])
finally:
clear_recordset_list(to_delete, client)
@ -2291,9 +2369,9 @@ def test_ipv4_ptr_recordtype_add_checks(shared_zone_test_context):
# delegated and non-delegated PTR duplicate name checks
assert_successful_change_in_error_response(response[4], input_name=f"{ip4_prefix}.196", record_type="PTR", record_data="test.com.")
assert_successful_change_in_error_response(response[5], input_name=f"196.{ip4_zone_name}", record_type="CNAME", record_data="test.com.")
assert_failed_change_in_error_response(response[6], input_name=f"196.192/30.{ip4_zone_name}", record_type="CNAME", record_data="test.com.",
error_messages=[f'Record Name "196.192/30.{ip4_zone_name}" Not Unique In Batch Change: cannot have multiple "CNAME" records with the same name.'])
assert_failed_change_in_error_response(response[5], input_name=f"196.{ip4_zone_name}", record_type="CNAME", record_data="test.com.",
error_messages=[f'Record Name "196.{ip4_zone_name}" Not Unique In Batch Change: cannot have multiple "CNAME" records with the same name.'])
assert_successful_change_in_error_response(response[6], input_name=f"196.192/30.{ip4_zone_name}", record_type="CNAME", record_data="test.com.")
assert_successful_change_in_error_response(response[7], input_name=f"{ip4_prefix}.55", record_type="PTR", record_data="test.com.")
assert_failed_change_in_error_response(response[8], input_name=f"55.{ip4_zone_name}", record_type="CNAME", record_data="test.com.",
error_messages=[f'Record Name "55.{ip4_zone_name}" Not Unique In Batch Change: cannot have multiple "CNAME" records with the same name.'])
@ -2306,8 +2384,8 @@ def test_ipv4_ptr_recordtype_add_checks(shared_zone_test_context):
# context validations: existing cname recordset
assert_failed_change_in_error_response(response[11], input_name=f"{ip4_prefix}.193", record_type="PTR", record_data="existing-ptr.",
error_messages=[f'RecordName "{ip4_prefix}.193" already exists. Your request will be manually reviewed. '
f'If you intended to update this record, you can avoid manual review by adding a DeleteRecordSet entry followed by an Add.'])
error_messages=[f'RecordName "{ip4_prefix}.193" already exists. '
f'If you intended to update this record, submit a DeleteRecordSet entry followed by an Add.'])
assert_failed_change_in_error_response(response[12], input_name=f"{ip4_prefix}.199", record_type="PTR", record_data="existing-cname.",
error_messages=[
f'CNAME Conflict: CNAME record names must be unique. Existing record with name "{ip4_prefix}.199" and type "CNAME" conflicts with this record.'])
@ -2516,8 +2594,8 @@ def test_ipv6_ptr_recordtype_add_checks(shared_zone_test_context):
# context validations: existing record sets pre-request
assert_failed_change_in_error_response(response[5], input_name=f"{ip6_prefix}:1000::bbbb", record_type="PTR",
record_data="existing.ptr.",
error_messages=[f"RecordName \"{ip6_prefix}:1000::bbbb\" already exists. Your request will be manually reviewed. "
f"If you intended to update this record, you can avoid manual review by adding a DeleteRecordSet entry followed by an Add."])
error_messages=[f"RecordName \"{ip6_prefix}:1000::bbbb\" already exists. "
f"If you intended to update this record, submit a DeleteRecordSet entry followed by an Add."])
finally:
clear_recordset_list(to_delete, client)
@ -3725,7 +3803,7 @@ def test_create_batch_delete_record_that_does_not_exists_completes(shared_zone_t
ok_zone_name = shared_zone_test_context.ok_zone["name"]
batch_change_input = {
"comments": "test delete record failures",
"comments": "test delete record",
"changes": [
get_change_A_AAAA_json(f"delete-non-existent-record.{ok_zone_name}", change_type="DeleteRecordSet")
]
@ -3734,12 +3812,46 @@ def test_create_batch_delete_record_that_does_not_exists_completes(shared_zone_t
response = client.create_batch_change(batch_change_input, status=202)
get_batch = client.get_batch_change(response["id"])
assert_that(get_batch["changes"][0]["systemMessage"], is_("This record does not exist." +
assert_that(get_batch["changes"][0]["systemMessage"], is_("This record does not exist. " +
"No further action is required."))
assert_successful_change_in_error_response(response["changes"][0], input_name=f"delete-non-existent-record.{ok_zone_name}", record_data="1.1.1.1", change_type="DeleteRecordSet")
def test_create_batch_delete_record_data_that_does_not_exists_completes(shared_zone_test_context):
"""
Test delete record set completes for non-existent record data
"""
client = shared_zone_test_context.ok_vinyldns_client
ok_zone_name = shared_zone_test_context.ok_zone["name"]
batch_change_input = {
"comments": "this is optional",
"changes": [
get_change_A_AAAA_json(f"delete-non-existent-record-data.{ok_zone_name}", address="4.5.6.7"),
]
}
batch_change_delete_input = {
"comments": "test delete record",
"changes": [
get_change_A_AAAA_json(f"delete-non-existent-record-data.{ok_zone_name}", address="1.1.1.1", change_type="DeleteRecordSet")
]
}
to_delete = []
try:
result = client.create_batch_change(batch_change_input, status=202)
completed_batch = client.wait_until_batch_change_completed(result)
record_set_list = [(change["zoneId"], change["recordSetId"]) for change in completed_batch["changes"]]
to_delete = set(record_set_list)
response = client.create_batch_change(batch_change_delete_input, status=202)
get_batch = client.get_batch_change(response["id"])
assert_that(get_batch["changes"][0]["systemMessage"], is_("Record data entered does not exist. " +
"No further action is required."))
assert_successful_change_in_error_response(response["changes"][0], input_name=f"delete-non-existent-record-data.{ok_zone_name}", record_data="1.1.1.1", change_type="DeleteRecordSet")
finally:
clear_zoneid_rsid_tuple_list(to_delete, client)
@pytest.mark.serial
def test_create_batch_delete_record_access_checks(shared_zone_test_context):
"""
@ -4109,7 +4221,7 @@ def test_create_batch_deletes_succeeds(shared_zone_test_context):
@pytest.mark.skip_production
def test_create_batch_change_with_multi_record_adds_with_multi_record_support(shared_zone_test_context):
"""
Test new recordsets with multiple records can be added in batch, but existing recordsets cannot be added to
Test new recordsets with multiple records can be added in batch.
"""
client = shared_zone_test_context.ok_vinyldns_client
ok_zone = shared_zone_test_context.ok_zone
@ -4134,7 +4246,7 @@ def test_create_batch_change_with_multi_record_adds_with_multi_record_support(sh
get_change_TXT_json(f"multi-txt.{ok_zone_name}", text="more-multi-text"),
get_change_MX_json(f"multi-mx.{ok_zone_name}", preference=0),
get_change_MX_json(f"multi-mx.{ok_zone_name}", preference=1000, exchange="bar.foo."),
get_change_A_AAAA_json(rs_fqdn, address="1.1.1.1")
get_change_A_AAAA_json(rs_fqdn, address="1.2.3.4")
],
"ownerGroupId": shared_zone_test_context.ok_group["id"]
}
@ -4152,6 +4264,637 @@ def test_create_batch_change_with_multi_record_adds_with_multi_record_support(sh
assert_successful_change_in_error_response(response["changes"][5], input_name=f"multi-txt.{ok_zone_name}", record_type="TXT", record_data="more-multi-text")
assert_successful_change_in_error_response(response["changes"][6], input_name=f"multi-mx.{ok_zone_name}", record_type="MX", record_data={"preference": 0, "exchange": "foo.bar."})
assert_successful_change_in_error_response(response["changes"][7], input_name=f"multi-mx.{ok_zone_name}", record_type="MX", record_data={"preference": 1000, "exchange": "bar.foo."})
assert_successful_change_in_error_response(response["changes"][8], input_name=rs_fqdn, record_data="1.1.1.1")
assert_successful_change_in_error_response(response["changes"][8], input_name=rs_fqdn, record_data="1.2.3.4")
finally:
clear_recordset_list(to_delete, client)
def test_ns_recordtype_add_checks(shared_zone_test_context):
"""
Test all add validations performed on NS records submitted in batch changes
"""
client = shared_zone_test_context.ok_vinyldns_client
dummy_zone_name = shared_zone_test_context.dummy_zone["name"]
dummy_group_name = shared_zone_test_context.dummy_group["name"]
ok_zone_name = shared_zone_test_context.ok_zone["name"]
existing_ns_name = generate_record_name()
existing_ns_fqdn = existing_ns_name + f".{ok_zone_name}"
existing_ns = create_recordset(shared_zone_test_context.ok_zone, existing_ns_name, "NS", [{"nsdname": "ns1.parent.com."}], 100)
existing_cname_name = generate_record_name()
existing_cname_fqdn = existing_cname_name + f".{ok_zone_name}"
existing_cname = create_recordset(shared_zone_test_context.ok_zone, existing_cname_name, "CNAME",
[{"cname": "test."}], 100)
batch_change_input = {
"changes": [
# valid change
get_change_NS_json(f"ns.{ok_zone_name}", nsdname="ns1.parent.com."),
# input validation failures
get_change_NS_json(f"bad-ttl-and-invalid-name$.{ok_zone_name}", ttl=29, nsdname="ns1.parent.com."),
# zone discovery failures
get_change_NS_json(f"no.zone.at.all.", nsdname="ns1.parent.com."),
# context validation failures
get_change_CNAME_json(f"cname-duplicate.{ok_zone_name}"),
get_change_NS_json(f"cname-duplicate.{ok_zone_name}", nsdname="ns1.parent.com."),
get_change_NS_json(existing_ns_fqdn, nsdname="ns1.parent.com."),
get_change_NS_json(existing_cname_fqdn, nsdname="ns1.parent.com."),
get_change_NS_json(f"unapproved.{ok_zone_name}", nsdname="unapproved.name.server."),
get_change_NS_json(f"user-add-unauthorized.{dummy_zone_name}", nsdname="ns1.parent.com.")
]
}
to_create = [existing_ns, existing_cname]
to_delete = []
try:
for create_json in to_create:
create_result = client.create_recordset(create_json, status=202)
to_delete.append(client.wait_until_recordset_change_status(create_result, "Complete"))
response = client.create_batch_change(batch_change_input, status=400)
# successful changes
assert_successful_change_in_error_response(response[0], input_name=f"ns.{ok_zone_name}", record_type="NS",
record_data="ns1.parent.com.")
# ttl, domain name, record data
assert_failed_change_in_error_response(response[1], input_name=f"bad-ttl-and-invalid-name$.{ok_zone_name}", ttl=29,
record_type="NS", record_data="ns1.parent.com.",
error_messages=[
'Invalid TTL: "29", must be a number between 30 and 2147483647.',
f'Invalid domain name: "bad-ttl-and-invalid-name$.{ok_zone_name}", '
"valid domain names must be letters, numbers, underscores, and hyphens, joined by dots, and terminated with a dot."])
# zone discovery failure
assert_failed_change_in_error_response(response[2], input_name="no.zone.at.all.", record_type="NS",
record_data="ns1.parent.com.",
error_messages=['Zone Discovery Failed: zone for "no.zone.at.all." does not exist in VinylDNS. '
'If zone exists, then it must be connected to in VinylDNS.'])
# context validations: cname duplicate
assert_failed_change_in_error_response(response[3], input_name=f"cname-duplicate.{ok_zone_name}", record_type="CNAME",
record_data="test.com.",
error_messages=[f"Record Name \"cname-duplicate.{ok_zone_name}\" Not Unique In Batch Change: "
f"cannot have multiple \"CNAME\" records with the same name."])
# context validations: conflicting recordsets, unauthorized error
assert_successful_change_in_error_response(response[5], input_name=existing_ns_fqdn, record_type="NS",
record_data="ns1.parent.com.")
assert_failed_change_in_error_response(response[6], input_name=existing_cname_fqdn, record_type="NS",
record_data="ns1.parent.com.",
error_messages=[f"CNAME Conflict: CNAME record names must be unique. "
f"Existing record with name \"{existing_cname_fqdn}\" and type \"CNAME\" conflicts with this record."])
assert_failed_change_in_error_response(response[7], input_name=f"unapproved.{ok_zone_name}",
record_type="NS", record_data="unapproved.name.server.",
error_messages=[f"Name Server unapproved.name.server. is not an approved name server."])
assert_failed_change_in_error_response(response[8], input_name=f"user-add-unauthorized.{dummy_zone_name}",
record_type="NS", record_data="ns1.parent.com.",
error_messages=[f"User \"ok\" is not authorized. Contact zone owner group: {dummy_group_name} at test@test.com to make DNS changes."])
finally:
clear_recordset_list(to_delete, client)
def test_ns_recordtype_update_delete_checks(shared_zone_test_context):
"""
Test all update and delete validations performed on NS records submitted in batch changes
"""
ok_client = shared_zone_test_context.ok_vinyldns_client
dummy_client = shared_zone_test_context.dummy_vinyldns_client
ok_zone = shared_zone_test_context.ok_zone
dummy_zone = shared_zone_test_context.dummy_zone
ok_zone_name = shared_zone_test_context.ok_zone["name"]
dummy_zone_name = shared_zone_test_context.dummy_zone["name"]
dummy_group_name = shared_zone_test_context.dummy_group["name"]
rs_delete_name = generate_record_name()
rs_delete_fqdn = rs_delete_name + f".{ok_zone_name}"
rs_delete_ok = create_recordset(ok_zone, rs_delete_name, "NS", [{"nsdname": "ns1.parent.com."}], 200)
rs_update_name = generate_record_name()
rs_update_fqdn = rs_update_name + f".{ok_zone_name}"
rs_update_ok = create_recordset(ok_zone, rs_update_name, "NS", [{"nsdname": "ns1.parent.com."}], 200)
rs_delete_dummy_name = generate_record_name()
rs_delete_dummy_fqdn = rs_delete_dummy_name + f".{dummy_zone_name}"
rs_delete_dummy = create_recordset(dummy_zone, rs_delete_dummy_name, "NS", [{"nsdname": "ns1.parent.com."}], 200)
rs_update_dummy_name = generate_record_name()
rs_update_dummy_fqdn = rs_update_dummy_name + f".{dummy_zone_name}"
rs_update_dummy = create_recordset(dummy_zone, rs_update_dummy_name, "NS", [{"nsdname": "ns1.parent.com."}], 200)
batch_change_input = {
"comments": "this is optional",
"changes": [
# valid changes
get_change_NS_json(rs_delete_fqdn, change_type="DeleteRecordSet", nsdname="ns1.parent.com."),
get_change_NS_json(rs_update_fqdn, change_type="DeleteRecordSet", nsdname="ns1.parent.com."),
get_change_NS_json(rs_update_fqdn, ttl=300, nsdname="ns1.parent.com."),
get_change_NS_json(f"delete-nonexistent.{ok_zone_name}", change_type="DeleteRecordSet", nsdname="ns1.parent.com."),
get_change_NS_json(f"update-nonexistent.{ok_zone_name}", change_type="DeleteRecordSet", nsdname="ns1.parent.com."),
# input validations failures
get_change_NS_json(f"invalid-name$.{ok_zone_name}", change_type="DeleteRecordSet", nsdname="ns1.parent.com."),
get_change_NS_json(f"invalid-ttl.{ok_zone_name}", ttl=29, nsdname="ns1.parent.com."),
# zone discovery failure
get_change_NS_json("no.zone.at.all.", change_type="DeleteRecordSet", nsdname="ns1.parent.com."),
# context validation failures
get_change_NS_json(f"update-nonexistent.{ok_zone_name}", nsdname="ns1.parent.com."),
get_change_NS_json(rs_delete_dummy_fqdn, change_type="DeleteRecordSet", nsdname="ns1.parent.com."),
get_change_NS_json(rs_update_dummy_fqdn, nsdname="ns1.parent.com."),
get_change_NS_json(f"unapproved.{ok_zone_name}", nsdname="unapproved.name.server."),
get_change_NS_json(rs_update_dummy_fqdn, change_type="DeleteRecordSet", nsdname="ns1.parent.com.")
]
}
to_create = [rs_delete_ok, rs_update_ok, rs_delete_dummy, rs_update_dummy]
to_delete = []
try:
for rs in to_create:
if rs["zoneId"] == dummy_zone["id"]:
create_client = dummy_client
else:
create_client = ok_client
create_rs = create_client.create_recordset(rs, status=202)
to_delete.append(create_client.wait_until_recordset_change_status(create_rs, "Complete"))
# Confirm that record set doesn't already exist
ok_client.get_recordset(ok_zone["id"], "delete-nonexistent", status=404)
response = ok_client.create_batch_change(batch_change_input, status=400)
# successful changes
assert_successful_change_in_error_response(response[0], input_name=rs_delete_fqdn, record_type="NS", record_data="ns1.parent.com.", change_type="DeleteRecordSet")
assert_successful_change_in_error_response(response[1], input_name=rs_update_fqdn, record_type="NS", record_data="ns1.parent.com.", change_type="DeleteRecordSet")
assert_successful_change_in_error_response(response[2], ttl=300, input_name=rs_update_fqdn, record_type="NS", record_data="ns1.parent.com.")
assert_successful_change_in_error_response(response[3], input_name=f"delete-nonexistent.{ok_zone_name}", record_type="NS", record_data="ns1.parent.com.", change_type="DeleteRecordSet")
assert_successful_change_in_error_response(response[4], input_name=f"update-nonexistent.{ok_zone_name}", record_type="NS", record_data="ns1.parent.com.", change_type="DeleteRecordSet")
# input validations failures: invalid input name, reverse zone error, invalid ttl
assert_failed_change_in_error_response(response[5], input_name=f"invalid-name$.{ok_zone_name}", record_type="NS", record_data="ns1.parent.com.", change_type="DeleteRecordSet",
error_messages=[f'Invalid domain name: "invalid-name$.{ok_zone_name}", valid domain names must be '
f'letters, numbers, underscores, and hyphens, joined by dots, and terminated with a dot.'])
assert_failed_change_in_error_response(response[6], input_name=f"invalid-ttl.{ok_zone_name}", ttl=29, record_type="NS", record_data="ns1.parent.com.",
error_messages=['Invalid TTL: "29", must be a number between 30 and 2147483647.'])
# zone discovery failure
assert_failed_change_in_error_response(response[7], input_name="no.zone.at.all.", record_type="NS", record_data="ns1.parent.com.", change_type="DeleteRecordSet",
error_messages=[
"Zone Discovery Failed: zone for \"no.zone.at.all.\" does not exist in VinylDNS. "
"If zone exists, then it must be connected to in VinylDNS."])
# context validation failures: record does not exist, not authorized
assert_successful_change_in_error_response(response[8], input_name=f"update-nonexistent.{ok_zone_name}", record_type="NS", record_data="ns1.parent.com.")
assert_failed_change_in_error_response(response[9], input_name=rs_delete_dummy_fqdn, record_type="NS", record_data="ns1.parent.com.", change_type="DeleteRecordSet",
error_messages=[f"User \"ok\" is not authorized. Contact zone owner group: {dummy_group_name} at test@test.com to make DNS changes."])
assert_failed_change_in_error_response(response[10], input_name=rs_update_dummy_fqdn, record_type="NS", record_data="ns1.parent.com.",
error_messages=[f"User \"ok\" is not authorized. Contact zone owner group: {dummy_group_name} at test@test.com to make DNS changes."])
assert_failed_change_in_error_response(response[11], input_name=f"unapproved.{ok_zone_name}",
record_type="NS", record_data="unapproved.name.server.",
error_messages=[f"Name Server unapproved.name.server. is not an approved name server."])
assert_failed_change_in_error_response(response[12], input_name=rs_update_dummy_fqdn, record_type="NS", record_data="ns1.parent.com.", change_type="DeleteRecordSet",
error_messages=[f"User \"ok\" is not authorized. Contact zone owner group: {dummy_group_name} at test@test.com to make DNS changes."])
finally:
# Clean up updates
dummy_deletes = [rs for rs in to_delete if rs["zone"]["id"] == dummy_zone["id"]]
ok_deletes = [rs for rs in to_delete if rs["zone"]["id"] != dummy_zone["id"]]
clear_recordset_list(dummy_deletes, dummy_client)
clear_recordset_list(ok_deletes, ok_client)
def test_naptr_recordtype_add_checks(shared_zone_test_context):
"""
Test all add validations performed on NAPTR records submitted in batch changes
"""
client = shared_zone_test_context.ok_vinyldns_client
ok_zone_name = shared_zone_test_context.ok_zone["name"]
dummy_zone_name = shared_zone_test_context.dummy_zone["name"]
dummy_group_name = shared_zone_test_context.dummy_group["name"]
ip4_zone_name = shared_zone_test_context.classless_base_zone["name"]
existing_naptr_name = generate_record_name()
existing_naptr_fqdn = f"{existing_naptr_name}.{ok_zone_name}"
existing_naptr = create_recordset(shared_zone_test_context.ok_zone, existing_naptr_name, "NAPTR", [{"order": 1, "preference": 1000, "flags": "U", "service": "E2U+sip", "regexp": "!.*!test.!", "replacement": "target.vinyldns."}], 100)
existing_cname_name = generate_record_name()
existing_cname_fqdn = f"{existing_cname_name}.{ok_zone_name}"
existing_cname = create_recordset(shared_zone_test_context.ok_zone, existing_cname_name, "CNAME", [{"cname": "test."}], 100)
good_record_fqdn = generate_record_name(ok_zone_name)
batch_change_input = {
"changes": [
# valid change
get_change_NAPTR_json(good_record_fqdn, order=1, preference=1000, flags="U", service="E2U+sip", regexp="!.*!test.!", replacement="target.vinyldns."),
# input validation failures
get_change_NAPTR_json(f"bad-ttl-and-invalid-name$.{ok_zone_name}", ttl=29, order=1, preference=1000, flags="U", service="E2U+sip", regexp="!.*!test.!", replacement="target.vinyldns."),
get_change_NAPTR_json(f"naptr.{ip4_zone_name}", order=1, preference=1000, flags="U", service="E2U+sip", regexp="!.*!test.!", replacement="target.vinyldns."),
# zone discovery failures
get_change_NAPTR_json(f"no.subzone.{ok_zone_name}", order=1, preference=1000, flags="U", service="E2U+sip", regexp="!.*!test.!", replacement="target.vinyldns."),
get_change_NAPTR_json("no.zone.at.all.", order=1, preference=1000, flags="U", service="E2U+sip", regexp="!.*!test.!", replacement="target.vinyldns."),
# context validation failures
get_change_CNAME_json(f"cname-duplicate.{ok_zone_name}"),
get_change_NAPTR_json(f"cname-duplicate.{ok_zone_name}", order=1, preference=1000, flags="U", service="E2U+sip", regexp="!.*!test.!", replacement="target.vinyldns."),
get_change_NAPTR_json(existing_naptr_fqdn, order=1, preference=1000, flags="U", service="E2U+sip", regexp="!.*!test.!", replacement="target.vinyldns."),
get_change_NAPTR_json(existing_cname_fqdn, order=1, preference=1000, flags="U", service="E2U+sip", regexp="!.*!test.!", replacement="target.vinyldns."),
get_change_NAPTR_json(f"user-add-unauthorized.{dummy_zone_name}", order=1, preference=1000, flags="U", service="E2U+sip", regexp="!.*!test.!", replacement="target.vinyldns.")
]
}
to_create = [existing_naptr, existing_cname]
to_delete = []
try:
for create_json in to_create:
create_result = client.create_recordset(create_json, status=202)
to_delete.append(client.wait_until_recordset_change_status(create_result, "Complete"))
response = client.create_batch_change(batch_change_input, status=400)
# successful changes
assert_successful_change_in_error_response(response[0], input_name=good_record_fqdn, record_type="NAPTR", record_data={"order": 1, "preference": 1000, "flags": "U", "service": "E2U+sip", "regexp": "!.*!test.!", "replacement": "target.vinyldns."})
# ttl, domain name, record data
assert_failed_change_in_error_response(response[1], input_name=f"bad-ttl-and-invalid-name$.{ok_zone_name}", ttl=29, record_type="NAPTR",
record_data={"order": 1, "preference": 1000, "flags": "U", "service": "E2U+sip", "regexp": "!.*!test.!", "replacement": "target.vinyldns."},
error_messages=['Invalid TTL: "29", must be a number between 30 and 2147483647.',
f'Invalid domain name: "bad-ttl-and-invalid-name$.{ok_zone_name}", '
"valid domain names must be letters, numbers, underscores, and hyphens, joined by dots, and terminated with a dot."])
assert_failed_change_in_error_response(response[2], input_name=f"naptr.{ip4_zone_name}", record_type="NAPTR",
record_data={"order": 1, "preference": 1000, "flags": "U", "service": "E2U+sip", "regexp": "!.*!test.!", "replacement": "target.vinyldns."},
error_messages=[f'Invalid Record Type In Reverse Zone: record with name "naptr.{ip4_zone_name}" and type "NAPTR" is not allowed in a reverse zone.'])
# zone discovery failures
assert_failed_change_in_error_response(response[3], input_name=f"no.subzone.{ok_zone_name}", record_type="NAPTR",
record_data={"order": 1, "preference": 1000, "flags": "U", "service": "E2U+sip", "regexp": "!.*!test.!", "replacement": "target.vinyldns."},
error_messages=[f'Zone Discovery Failed: zone for "no.subzone.{ok_zone_name}" does not exist in VinylDNS. '
f'If zone exists, then it must be connected to in VinylDNS.'])
assert_failed_change_in_error_response(response[4], input_name="no.zone.at.all.", record_type="NAPTR",
record_data={"order": 1, "preference": 1000, "flags": "U", "service": "E2U+sip", "regexp": "!.*!test.!", "replacement": "target.vinyldns."},
error_messages=['Zone Discovery Failed: zone for "no.zone.at.all." does not exist in VinylDNS. '
'If zone exists, then it must be connected to in VinylDNS.'])
# context validations: cname duplicate
assert_failed_change_in_error_response(response[5], input_name=f"cname-duplicate.{ok_zone_name}", record_type="CNAME",
record_data="test.com.",
error_messages=[f"Record Name \"cname-duplicate.{ok_zone_name}\" Not Unique In Batch Change: "
f"cannot have multiple \"CNAME\" records with the same name."])
# context validations: conflicting recordsets, unauthorized error
assert_successful_change_in_error_response(response[7], input_name=existing_naptr_fqdn, record_type="NAPTR",
record_data={"order": 1, "preference": 1000, "flags": "U", "service": "E2U+sip", "regexp": "!.*!test.!", "replacement": "target.vinyldns."})
assert_failed_change_in_error_response(response[8], input_name=existing_cname_fqdn, record_type="NAPTR",
record_data={"order": 1, "preference": 1000, "flags": "U", "service": "E2U+sip", "regexp": "!.*!test.!", "replacement": "target.vinyldns."},
error_messages=["CNAME Conflict: CNAME record names must be unique. "
f"Existing record with name \"{existing_cname_fqdn}\" and type \"CNAME\" conflicts with this record."])
assert_failed_change_in_error_response(response[9], input_name=f"user-add-unauthorized.{dummy_zone_name}", record_type="NAPTR",
record_data={"order": 1, "preference": 1000, "flags": "U", "service": "E2U+sip", "regexp": "!.*!test.!", "replacement": "target.vinyldns."},
error_messages=[f"User \"ok\" is not authorized. Contact zone owner group: {dummy_group_name} at test@test.com to make DNS changes."])
finally:
clear_recordset_list(to_delete, client)
def test_naptr_recordtype_update_delete_checks(shared_zone_test_context):
"""
Test all update and delete validations performed on NAPTR records submitted in batch changes
"""
ok_client = shared_zone_test_context.ok_vinyldns_client
dummy_client = shared_zone_test_context.dummy_vinyldns_client
ok_zone = shared_zone_test_context.ok_zone
dummy_zone = shared_zone_test_context.dummy_zone
dummy_zone_name = shared_zone_test_context.dummy_zone["name"]
dummy_group_name = shared_zone_test_context.dummy_group["name"]
ok_zone_name = shared_zone_test_context.ok_zone["name"]
ip4_zone_name = shared_zone_test_context.classless_base_zone["name"]
rs_delete_name = generate_record_name()
rs_delete_fqdn = rs_delete_name + f".{ok_zone_name}"
rs_delete_ok = create_recordset(ok_zone, rs_delete_name, "NAPTR", [{"order": 1, "preference": 1000, "flags": "U", "service": "E2U+sip", "regexp": "!.*!test.!", "replacement": "target.vinyldns."}], 200)
rs_update_name = generate_record_name()
rs_update_fqdn = rs_update_name + f".{ok_zone_name}"
rs_update_ok = create_recordset(ok_zone, rs_update_name, "NAPTR", [{"order": 1, "preference": 1000, "flags": "U", "service": "E2U+sip", "regexp": "!.*!test.!", "replacement": "target.vinyldns."}], 200)
rs_delete_dummy_name = generate_record_name()
rs_delete_dummy_fqdn = rs_delete_dummy_name + f".{dummy_zone_name}"
rs_delete_dummy = create_recordset(dummy_zone, rs_delete_dummy_name, "NAPTR", [{"order": 1, "preference": 1000, "flags": "U", "service": "E2U+sip", "regexp": "!.*!test.!", "replacement": "target.vinyldns."}], 200)
rs_update_dummy_name = generate_record_name()
rs_update_dummy_fqdn = rs_update_dummy_name + f".{dummy_zone_name}"
rs_update_dummy = create_recordset(dummy_zone, rs_update_dummy_name, "NAPTR", [{"order": 1, "preference": 1000, "flags": "U", "service": "E2U+sip", "regexp": "!.*!test.!", "replacement": "target.vinyldns."}], 200)
batch_change_input = {
"comments": "this is optional",
"changes": [
# valid changes
get_change_NAPTR_json(rs_delete_fqdn, change_type="DeleteRecordSet", order=1, preference=1000, flags="U", service="E2U+sip", regexp="!.*!test.!", replacement="target.vinyldns."),
get_change_NAPTR_json(rs_update_fqdn, change_type="DeleteRecordSet", order=1, preference=1000, flags="U", service="E2U+sip", regexp="!.*!test.!", replacement="target.vinyldns."),
get_change_NAPTR_json(rs_update_fqdn, ttl=300, order=1, preference=1000, flags="U", service="E2U+sip", regexp="!.*!test.!", replacement="target.vinyldns."),
get_change_NAPTR_json(f"delete-nonexistent.{ok_zone_name}", change_type="DeleteRecordSet", order=1, preference=1000, flags="U", service="E2U+sip", regexp="!.*!test.!", replacement="target.vinyldns."),
get_change_NAPTR_json(f"update-nonexistent.{ok_zone_name}", change_type="DeleteRecordSet", order=1, preference=1000, flags="U", service="E2U+sip", regexp="!.*!test.!", replacement="target.vinyldns."),
# input validations failures
get_change_NAPTR_json(f"invalid-name$.{ok_zone_name}", change_type="DeleteRecordSet", order=1, preference=1000, flags="U", service="E2U+sip", regexp="!.*!test.!", replacement="target.vinyldns."),
get_change_NAPTR_json(f"delete.{ok_zone_name}", ttl=29, order=1, preference=1000, flags="U", service="E2U+sip", regexp="!.*!test.!", replacement="target.vinyldns."),
get_change_NAPTR_json(f"naptr.{ip4_zone_name}", order=1, preference=1000, flags="U", service="E2U+sip", regexp="!.*!test.!", replacement="target.vinyldns."),
# zone discovery failures
get_change_NAPTR_json("no.zone.at.all.", change_type="DeleteRecordSet", order=1, preference=1000, flags="U", service="E2U+sip", regexp="!.*!test.!", replacement="target.vinyldns."),
# context validation failures
get_change_NAPTR_json(f"update-nonexistent.{ok_zone_name}", order=1, preference=1000, flags="U", service="E2U+sip", regexp="!.*!test.!", replacement="target.vinyldns."),
get_change_NAPTR_json(rs_delete_dummy_fqdn, change_type="DeleteRecordSet", order=1, preference=1000, flags="U", service="E2U+sip", regexp="!.*!test.!", replacement="target.vinyldns."),
get_change_NAPTR_json(rs_update_dummy_fqdn, order=1, preference=1000, flags="U", service="E2U+sip", regexp="!.*!test.!", replacement="target.vinyldns."),
get_change_NAPTR_json(rs_update_dummy_fqdn, change_type="DeleteRecordSet", order=1, preference=1000, flags="U", service="E2U+sip", regexp="!.*!test.!", replacement="target.vinyldns.")
]
}
to_create = [rs_delete_ok, rs_update_ok, rs_delete_dummy, rs_update_dummy]
to_delete = []
try:
for rs in to_create:
if rs["zoneId"] == dummy_zone["id"]:
create_client = dummy_client
else:
create_client = ok_client
create_rs = create_client.create_recordset(rs, status=202)
to_delete.append(create_client.wait_until_recordset_change_status(create_rs, "Complete"))
# Confirm that record set doesn't already exist
ok_client.get_recordset(ok_zone["id"], "delete-nonexistent", status=404)
response = ok_client.create_batch_change(batch_change_input, status=400)
# successful changes
assert_successful_change_in_error_response(response[0], input_name=rs_delete_fqdn, record_type="NAPTR", record_data={"order": 1, "preference": 1000, "flags": "U", "service": "E2U+sip", "regexp": "!.*!test.!", "replacement": "target.vinyldns."}, change_type="DeleteRecordSet")
assert_successful_change_in_error_response(response[1], input_name=rs_update_fqdn, record_type="NAPTR", record_data={"order": 1, "preference": 1000, "flags": "U", "service": "E2U+sip", "regexp": "!.*!test.!", "replacement": "target.vinyldns."}, change_type="DeleteRecordSet")
assert_successful_change_in_error_response(response[2], ttl=300, input_name=rs_update_fqdn, record_type="NAPTR", record_data={"order": 1, "preference": 1000, "flags": "U", "service": "E2U+sip", "regexp": "!.*!test.!", "replacement": "target.vinyldns."})
assert_successful_change_in_error_response(response[3], input_name=f"delete-nonexistent.{ok_zone_name}", record_type="NAPTR",
record_data={"order": 1, "preference": 1000, "flags": "U", "service": "E2U+sip", "regexp": "!.*!test.!", "replacement": "target.vinyldns."}, change_type="DeleteRecordSet")
assert_successful_change_in_error_response(response[4], input_name=f"update-nonexistent.{ok_zone_name}", record_type="NAPTR",
record_data={"order": 1, "preference": 1000, "flags": "U", "service": "E2U+sip", "regexp": "!.*!test.!", "replacement": "target.vinyldns."}, change_type="DeleteRecordSet")
# input validations failures: invalid input name, reverse zone error, invalid ttl
assert_failed_change_in_error_response(response[5], input_name=f"invalid-name$.{ok_zone_name}", record_type="NAPTR", record_data={"order": 1, "preference": 1000, "flags": "U", "service": "E2U+sip", "regexp": "!.*!test.!", "replacement": "target.vinyldns."},
change_type="DeleteRecordSet",
error_messages=[f'Invalid domain name: "invalid-name$.{ok_zone_name}", valid domain names must be letters, '
f'numbers, underscores, and hyphens, joined by dots, and terminated with a dot.'])
assert_failed_change_in_error_response(response[6], input_name=f"delete.{ok_zone_name}", ttl=29, record_type="NAPTR",
record_data={"order": 1, "preference": 1000, "flags": "U", "service": "E2U+sip", "regexp": "!.*!test.!", "replacement": "target.vinyldns."},
error_messages=['Invalid TTL: "29", must be a number between 30 and 2147483647.'])
assert_failed_change_in_error_response(response[7], input_name=f"naptr.{ip4_zone_name}", record_type="NAPTR",
record_data={"order": 1, "preference": 1000, "flags": "U", "service": "E2U+sip", "regexp": "!.*!test.!", "replacement": "target.vinyldns."},
error_messages=[f'Invalid Record Type In Reverse Zone: record with name "naptr.{ip4_zone_name}" '
f'and type "NAPTR" is not allowed in a reverse zone.'])
# zone discovery failure
assert_failed_change_in_error_response(response[8], input_name="no.zone.at.all.", record_type="NAPTR",
record_data={"order": 1, "preference": 1000, "flags": "U", "service": "E2U+sip", "regexp": "!.*!test.!", "replacement": "target.vinyldns."}, change_type="DeleteRecordSet",
error_messages=["Zone Discovery Failed: zone for \"no.zone.at.all.\" does not exist in VinylDNS. "
"If zone exists, then it must be connected to in VinylDNS."])
# context validation failures: record does not exist, not authorized
assert_successful_change_in_error_response(response[9], input_name=f"update-nonexistent.{ok_zone_name}", record_type="NAPTR",
record_data={"order": 1, "preference": 1000, "flags": "U", "service": "E2U+sip", "regexp": "!.*!test.!", "replacement": "target.vinyldns."})
assert_failed_change_in_error_response(response[10], input_name=rs_delete_dummy_fqdn, record_type="NAPTR",
record_data={"order": 1, "preference": 1000, "flags": "U", "service": "E2U+sip", "regexp": "!.*!test.!", "replacement": "target.vinyldns."}, change_type="DeleteRecordSet",
error_messages=[f"User \"ok\" is not authorized. Contact zone owner group: {dummy_group_name} at test@test.com to make DNS changes."])
assert_failed_change_in_error_response(response[11], input_name=rs_update_dummy_fqdn, record_type="NAPTR",
record_data={"order": 1, "preference": 1000, "flags": "U", "service": "E2U+sip", "regexp": "!.*!test.!", "replacement": "target.vinyldns."},
error_messages=[f"User \"ok\" is not authorized. Contact zone owner group: {dummy_group_name} at test@test.com to make DNS changes."])
assert_failed_change_in_error_response(response[12], input_name=rs_update_dummy_fqdn, record_type="NAPTR",
record_data={"order": 1, "preference": 1000, "flags": "U", "service": "E2U+sip", "regexp": "!.*!test.!", "replacement": "target.vinyldns."}, change_type="DeleteRecordSet",
error_messages=[f"User \"ok\" is not authorized. Contact zone owner group: {dummy_group_name} at test@test.com to make DNS changes."])
finally:
# Clean up updates
dummy_deletes = [rs for rs in to_delete if rs["zone"]["id"] == dummy_zone["id"]]
ok_deletes = [rs for rs in to_delete if rs["zone"]["id"] != dummy_zone["id"]]
clear_recordset_list(dummy_deletes, dummy_client)
clear_recordset_list(ok_deletes, ok_client)
def test_srv_recordtype_add_checks(shared_zone_test_context):
"""
Test all add validations performed on SRV records submitted in batch changes
"""
client = shared_zone_test_context.ok_vinyldns_client
ok_zone_name = shared_zone_test_context.ok_zone["name"]
dummy_zone_name = shared_zone_test_context.dummy_zone["name"]
dummy_group_name = shared_zone_test_context.dummy_group["name"]
ip4_zone_name = shared_zone_test_context.classless_base_zone["name"]
existing_srv_name = generate_record_name()
existing_srv_fqdn = f"{existing_srv_name}.{ok_zone_name}"
existing_srv = create_recordset(shared_zone_test_context.ok_zone, existing_srv_name, "SRV", [{"priority": 1000, "weight": 5, "port": 20, "target": "bar.foo."}], 100)
existing_cname_name = generate_record_name()
existing_cname_fqdn = f"{existing_cname_name}.{ok_zone_name}"
existing_cname = create_recordset(shared_zone_test_context.ok_zone, existing_cname_name, "CNAME", [{"cname": "test."}], 100)
good_record_fqdn = generate_record_name(ok_zone_name)
batch_change_input = {
"changes": [
# valid change
get_change_SRV_json(good_record_fqdn, priority=1000, weight=5, port=20, target="bar.foo."),
# input validation failures
get_change_SRV_json(f"bad-ttl-and-invalid-name$.{ok_zone_name}", ttl=29, priority=1000, weight=5, port=20, target="bar.foo."),
get_change_SRV_json(f"srv.{ip4_zone_name}", priority=1000, weight=5, port=20, target="bar.foo."),
# zone discovery failures
get_change_SRV_json(f"no.subzone.{ok_zone_name}", priority=1000, weight=5, port=20, target="bar.foo."),
get_change_SRV_json("no.zone.at.all.", priority=1000, weight=5, port=20, target="bar.foo."),
# context validation failures
get_change_CNAME_json(f"cname-duplicate.{ok_zone_name}"),
get_change_SRV_json(f"cname-duplicate.{ok_zone_name}", priority=1000, weight=5, port=20, target="bar.foo."),
get_change_SRV_json(existing_srv_fqdn, priority=1000, weight=5, port=20, target="bar.foo."),
get_change_SRV_json(existing_cname_fqdn, priority=1000, weight=5, port=20, target="bar.foo."),
get_change_SRV_json(f"user-add-unauthorized.{dummy_zone_name}", priority=1000, weight=5, port=20, target="bar.foo.")
]
}
to_create = [existing_srv, existing_cname]
to_delete = []
try:
for create_json in to_create:
create_result = client.create_recordset(create_json, status=202)
to_delete.append(client.wait_until_recordset_change_status(create_result, "Complete"))
response = client.create_batch_change(batch_change_input, status=400)
# successful changes
assert_successful_change_in_error_response(response[0], input_name=good_record_fqdn, record_type="SRV", record_data={"priority": 1000, "weight": 5, "port": 20, "target": "bar.foo."})
# ttl, domain name, record data
assert_failed_change_in_error_response(response[1], input_name=f"bad-ttl-and-invalid-name$.{ok_zone_name}", ttl=29, record_type="SRV",
record_data={"priority": 1000, "weight": 5, "port": 20, "target": "bar.foo."},
error_messages=['Invalid TTL: "29", must be a number between 30 and 2147483647.',
f'Invalid domain name: "bad-ttl-and-invalid-name$.{ok_zone_name}", '
"valid domain names must be letters, numbers, underscores, and hyphens, joined by dots, and terminated with a dot."])
assert_failed_change_in_error_response(response[2], input_name=f"srv.{ip4_zone_name}", record_type="SRV",
record_data={"priority": 1000, "weight": 5, "port": 20, "target": "bar.foo."},
error_messages=[f'Invalid Record Type In Reverse Zone: record with name "srv.{ip4_zone_name}" and type "SRV" is not allowed in a reverse zone.'])
# zone discovery failures
assert_failed_change_in_error_response(response[3], input_name=f"no.subzone.{ok_zone_name}", record_type="SRV",
record_data={"priority": 1000, "weight": 5, "port": 20, "target": "bar.foo."},
error_messages=[f'Zone Discovery Failed: zone for "no.subzone.{ok_zone_name}" does not exist in VinylDNS. '
f'If zone exists, then it must be connected to in VinylDNS.'])
assert_failed_change_in_error_response(response[4], input_name="no.zone.at.all.", record_type="SRV",
record_data={"priority": 1000, "weight": 5, "port": 20, "target": "bar.foo."},
error_messages=['Zone Discovery Failed: zone for "no.zone.at.all." does not exist in VinylDNS. '
'If zone exists, then it must be connected to in VinylDNS.'])
# context validations: cname duplicate
assert_failed_change_in_error_response(response[5], input_name=f"cname-duplicate.{ok_zone_name}", record_type="CNAME",
record_data="test.com.",
error_messages=[f"Record Name \"cname-duplicate.{ok_zone_name}\" Not Unique In Batch Change: "
f"cannot have multiple \"CNAME\" records with the same name."])
# context validations: conflicting recordsets, unauthorized error
assert_successful_change_in_error_response(response[7], input_name=existing_srv_fqdn, record_type="SRV",
record_data={"priority": 1000, "weight": 5, "port": 20, "target": "bar.foo."})
assert_failed_change_in_error_response(response[8], input_name=existing_cname_fqdn, record_type="SRV",
record_data={"priority": 1000, "weight": 5, "port": 20, "target": "bar.foo."},
error_messages=["CNAME Conflict: CNAME record names must be unique. "
f"Existing record with name \"{existing_cname_fqdn}\" and type \"CNAME\" conflicts with this record."])
assert_failed_change_in_error_response(response[9], input_name=f"user-add-unauthorized.{dummy_zone_name}", record_type="SRV",
record_data={"priority": 1000, "weight": 5, "port": 20, "target": "bar.foo."},
error_messages=[f"User \"ok\" is not authorized. Contact zone owner group: {dummy_group_name} at test@test.com to make DNS changes."])
finally:
clear_recordset_list(to_delete, client)
def test_srv_recordtype_update_delete_checks(shared_zone_test_context):
"""
Test all update and delete validations performed on SRV records submitted in batch changes
"""
ok_client = shared_zone_test_context.ok_vinyldns_client
dummy_client = shared_zone_test_context.dummy_vinyldns_client
ok_zone = shared_zone_test_context.ok_zone
dummy_zone = shared_zone_test_context.dummy_zone
dummy_zone_name = shared_zone_test_context.dummy_zone["name"]
dummy_group_name = shared_zone_test_context.dummy_group["name"]
ok_zone_name = shared_zone_test_context.ok_zone["name"]
ip4_zone_name = shared_zone_test_context.classless_base_zone["name"]
rs_delete_name = generate_record_name()
rs_delete_fqdn = rs_delete_name + f".{ok_zone_name}"
rs_delete_ok = create_recordset(ok_zone, rs_delete_name, "SRV", [{"priority": 1000, "weight": 5, "port": 20, "target": "bar.foo."}], 200)
rs_update_name = generate_record_name()
rs_update_fqdn = rs_update_name + f".{ok_zone_name}"
rs_update_ok = create_recordset(ok_zone, rs_update_name, "SRV", [{"priority": 1000, "weight": 5, "port": 20, "target": "bar.foo."}], 200)
rs_delete_dummy_name = generate_record_name()
rs_delete_dummy_fqdn = rs_delete_dummy_name + f".{dummy_zone_name}"
rs_delete_dummy = create_recordset(dummy_zone, rs_delete_dummy_name, "SRV", [{"priority": 1000, "weight": 5, "port": 20, "target": "bar.foo."}], 200)
rs_update_dummy_name = generate_record_name()
rs_update_dummy_fqdn = rs_update_dummy_name + f".{dummy_zone_name}"
rs_update_dummy = create_recordset(dummy_zone, rs_update_dummy_name, "SRV", [{"priority": 1000, "weight": 5, "port": 20, "target": "bar.foo."}], 200)
batch_change_input = {
"comments": "this is optional",
"changes": [
# valid changes
get_change_SRV_json(rs_delete_fqdn, change_type="DeleteRecordSet", priority=1000, weight=5, port=20, target="bar.foo."),
get_change_SRV_json(rs_update_fqdn, change_type="DeleteRecordSet", priority=1000, weight=5, port=20, target="bar.foo."),
get_change_SRV_json(rs_update_fqdn, ttl=300, priority=1000, weight=5, port=20, target="bar.foo."),
get_change_SRV_json(f"delete-nonexistent.{ok_zone_name}", change_type="DeleteRecordSet", priority=1000, weight=5, port=20, target="bar.foo."),
get_change_SRV_json(f"update-nonexistent.{ok_zone_name}", change_type="DeleteRecordSet", priority=1000, weight=5, port=20, target="bar.foo."),
# input validations failures
get_change_SRV_json(f"invalid-name$.{ok_zone_name}", change_type="DeleteRecordSet", priority=1000, weight=5, port=20, target="bar.foo."),
get_change_SRV_json(f"delete.{ok_zone_name}", ttl=29, priority=1000, weight=5, port=20, target="bar.foo."),
get_change_SRV_json(f"srv.{ip4_zone_name}", priority=1000, weight=5, port=20, target="bar.foo."),
# zone discovery failures
get_change_SRV_json("no.zone.at.all.", change_type="DeleteRecordSet", priority=1000, weight=5, port=20, target="bar.foo."),
# context validation failures
get_change_SRV_json(f"update-nonexistent.{ok_zone_name}", priority=1000, weight=5, port=20, target="bar.foo."),
get_change_SRV_json(rs_delete_dummy_fqdn, change_type="DeleteRecordSet", priority=1000, weight=5, port=20, target="bar.foo."),
get_change_SRV_json(rs_update_dummy_fqdn, priority=1000, weight=5, port=20, target="bar.foo."),
get_change_SRV_json(rs_update_dummy_fqdn, change_type="DeleteRecordSet", priority=1000, weight=5, port=20, target="bar.foo.")
]
}
to_create = [rs_delete_ok, rs_update_ok, rs_delete_dummy, rs_update_dummy]
to_delete = []
try:
for rs in to_create:
if rs["zoneId"] == dummy_zone["id"]:
create_client = dummy_client
else:
create_client = ok_client
create_rs = create_client.create_recordset(rs, status=202)
to_delete.append(create_client.wait_until_recordset_change_status(create_rs, "Complete"))
# Confirm that record set doesn't already exist
ok_client.get_recordset(ok_zone["id"], "delete-nonexistent", status=404)
response = ok_client.create_batch_change(batch_change_input, status=400)
# successful changes
assert_successful_change_in_error_response(response[0], input_name=rs_delete_fqdn, record_type="SRV", record_data={"priority": 1000, "weight": 5, "port": 20, "target": "bar.foo."}, change_type="DeleteRecordSet")
assert_successful_change_in_error_response(response[1], input_name=rs_update_fqdn, record_type="SRV", record_data={"priority": 1000, "weight": 5, "port": 20, "target": "bar.foo."}, change_type="DeleteRecordSet")
assert_successful_change_in_error_response(response[2], ttl=300, input_name=rs_update_fqdn, record_type="SRV", record_data={"priority": 1000, "weight": 5, "port": 20, "target": "bar.foo."})
assert_successful_change_in_error_response(response[3], input_name=f"delete-nonexistent.{ok_zone_name}", record_type="SRV",
record_data={"priority": 1000, "weight": 5, "port": 20, "target": "bar.foo."}, change_type="DeleteRecordSet")
assert_successful_change_in_error_response(response[4], input_name=f"update-nonexistent.{ok_zone_name}", record_type="SRV",
record_data={"priority": 1000, "weight": 5, "port": 20, "target": "bar.foo."}, change_type="DeleteRecordSet")
# input validations failures: invalid input name, reverse zone error, invalid ttl
assert_failed_change_in_error_response(response[5], input_name=f"invalid-name$.{ok_zone_name}", record_type="SRV", record_data={"priority": 1000, "weight": 5, "port": 20, "target": "bar.foo."},
change_type="DeleteRecordSet",
error_messages=[f'Invalid domain name: "invalid-name$.{ok_zone_name}", valid domain names must be letters, '
f'numbers, underscores, and hyphens, joined by dots, and terminated with a dot.'])
assert_failed_change_in_error_response(response[6], input_name=f"delete.{ok_zone_name}", ttl=29, record_type="SRV",
record_data={"priority": 1000, "weight": 5, "port": 20, "target": "bar.foo."},
error_messages=['Invalid TTL: "29", must be a number between 30 and 2147483647.'])
assert_failed_change_in_error_response(response[7], input_name=f"srv.{ip4_zone_name}", record_type="SRV",
record_data={"priority": 1000, "weight": 5, "port": 20, "target": "bar.foo."},
error_messages=[f'Invalid Record Type In Reverse Zone: record with name "srv.{ip4_zone_name}" '
f'and type "SRV" is not allowed in a reverse zone.'])
# zone discovery failure
assert_failed_change_in_error_response(response[8], input_name="no.zone.at.all.", record_type="SRV",
record_data={"priority": 1000, "weight": 5, "port": 20, "target": "bar.foo."}, change_type="DeleteRecordSet",
error_messages=["Zone Discovery Failed: zone for \"no.zone.at.all.\" does not exist in VinylDNS. "
"If zone exists, then it must be connected to in VinylDNS."])
# context validation failures: record does not exist, not authorized
assert_successful_change_in_error_response(response[9], input_name=f"update-nonexistent.{ok_zone_name}", record_type="SRV",
record_data={"priority": 1000, "weight": 5, "port": 20, "target": "bar.foo."})
assert_failed_change_in_error_response(response[10], input_name=rs_delete_dummy_fqdn, record_type="SRV",
record_data=None, change_type="DeleteRecordSet",
error_messages=[f"User \"ok\" is not authorized. Contact zone owner group: {dummy_group_name} at test@test.com to make DNS changes."])
assert_failed_change_in_error_response(response[11], input_name=rs_update_dummy_fqdn, record_type="SRV",
record_data={"priority": 1000, "weight": 5, "port": 20, "target": "bar.foo."},
error_messages=[f"User \"ok\" is not authorized. Contact zone owner group: {dummy_group_name} at test@test.com to make DNS changes."])
assert_failed_change_in_error_response(response[12], input_name=rs_update_dummy_fqdn, record_type="SRV",
record_data=None, change_type="DeleteRecordSet",
error_messages=[f"User \"ok\" is not authorized. Contact zone owner group: {dummy_group_name} at test@test.com to make DNS changes."])
finally:
# Clean up updates
dummy_deletes = [rs for rs in to_delete if rs["zone"]["id"] == dummy_zone["id"]]
ok_deletes = [rs for rs in to_delete if rs["zone"]["id"] != dummy_zone["id"]]
clear_recordset_list(dummy_deletes, dummy_client)
clear_recordset_list(ok_deletes, ok_client)

View File

@ -77,7 +77,7 @@ def test_toggle_processing(shared_zone_test_context):
# Create changes to make sure we can process after the toggle
# attempt to perform an update
ok_zone["email"] = "foo@bar.com"
ok_zone["email"] = "test@test.com"
zone_change_result = client.update_zone(ok_zone, status=202)
# attempt to a create a record

View File

@ -10,7 +10,7 @@ def test_create_group_success(shared_zone_test_context):
try:
new_group = {
"name": f"test-create-group-success{shared_zone_test_context.partition_id}",
"name": "test-create-group-success{shared_zone_test_context.partition_id}",
"email": "test@test.com",
"description": "this is a description",
"members": [{"id": "ok"}],
@ -32,6 +32,67 @@ def test_create_group_success(shared_zone_test_context):
if result:
client.delete_group(result["id"], status=(200, 404))
def test_create_group_success_wildcard(shared_zone_test_context):
"""
Tests that creating a group works
"""
client = shared_zone_test_context.ok_vinyldns_client
result = None
try:
new_group = {
"name": "test-create-group-success_wildcard{shared_zone_test_context.partition_id}",
"email": "test@ok.dummy.com",
"description": "this is a description",
"members": [{"id": "ok"}],
"admins": [{"id": "ok"}]
}
result = client.create_group(new_group, status=200)
assert_that(result["name"], is_(new_group["name"]))
assert_that(result["email"], is_(new_group["email"]))
assert_that(result["description"], is_(new_group["description"]))
assert_that(result["status"], is_("Active"))
assert_that(result["created"], not_none())
assert_that(result["id"], not_none())
assert_that(result["members"], has_length(1))
assert_that(result["members"][0]["id"], is_("ok"))
assert_that(result["admins"], has_length(1))
assert_that(result["admins"][0]["id"], is_("ok"))
finally:
if result:
client.delete_group(result["id"], status=(200, 404))
def test_create_group_success_number_of_dots(shared_zone_test_context):
"""
Tests that creating a group works
"""
client = shared_zone_test_context.ok_vinyldns_client
result = None
try:
new_group = {
"name": "test-create-group-success_wildcard{shared_zone_test_context.partition_id}",
"email": "test@ok.dummy.com",
"description": "this is a description",
"members": [{"id": "ok"}],
"admins": [{"id": "ok"}]
}
result = client.create_group(new_group, status=200)
assert_that(result["name"], is_(new_group["name"]))
assert_that(result["email"], is_(new_group["email"]))
assert_that(result["description"], is_(new_group["description"]))
assert_that(result["status"], is_("Active"))
assert_that(result["created"], not_none())
assert_that(result["id"], not_none())
assert_that(result["members"], has_length(1))
assert_that(result["members"][0]["id"], is_("ok"))
assert_that(result["admins"], has_length(1))
assert_that(result["admins"][0]["id"], is_("ok"))
finally:
if result:
client.delete_group(result["id"], status=(200, 404))
def test_creator_is_an_admin(shared_zone_test_context):
"""
@ -114,7 +175,52 @@ def test_create_group_without_name_or_email(shared_zone_test_context):
"Missing Group.name",
"Missing Group.email"
))
def test_create_group_with_invalid_email_domain(shared_zone_test_context):
"""
Tests that creating a group With Invalid email fails
"""
client = shared_zone_test_context.ok_vinyldns_client
new_group = {
"name": "invalid-email",
"email": "test@abc.com",
"description": "this is a description",
"members": [{"id": "ok"}],
"admins": [{"id": "ok"}]
}
error = client.create_group(new_group, status=400)
assert_that(error, is_("Please enter a valid Email. Valid domains should end with test.com,dummy.com"))
def test_create_group_with_invalid_email(shared_zone_test_context):
"""
Tests that creating a group With Invalid email fails
"""
client = shared_zone_test_context.ok_vinyldns_client
new_group = {
"name": "invalid-email",
"email": "test.abc.com",
"description": "this is a description",
"members": [{"id": "ok"}],
"admins": [{"id": "ok"}]
}
error = client.create_group(new_group, status=400)
assert_that(error, is_("Please enter a valid Email."))
def test_create_group_with_invalid_email_number_of_dots(shared_zone_test_context):
"""
Tests that creating a group With Invalid email fails
"""
client = shared_zone_test_context.ok_vinyldns_client
new_group = {
"name": "invalid-email",
"email": "test@ok.ok.dummy.com",
"description": "this is a description",
"members": [{"id": "ok"}],
"admins": [{"id": "ok"}]
}
error = client.create_group(new_group, status=400)
assert_that(error, is_("Please enter a valid Email. Number of dots allowed after @ is 2"))
def test_create_group_without_members_or_admins(shared_zone_test_context):
"""

View File

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

View File

@ -681,6 +681,24 @@ def test_create_dotted_cname_record_succeeds_if_all_dotted_hosts_config_satisfie
client.wait_until_recordset_change_status(delete_result, "Complete")
def test_create_IPv4_cname_record_fails(shared_zone_test_context):
"""
Test that creating a CNAME record set as IPv4 address records returns an error.
"""
client = shared_zone_test_context.ok_vinyldns_client
zone = shared_zone_test_context.parent_zone
apex_cname_rs = {
"zoneId": zone["id"],
"name": "test_create_cname_with_ipaddress",
"type": "CNAME",
"ttl": 500,
"records": [{"cname": "1.2.3.4"}]
}
error = client.create_recordset(apex_cname_rs, status=400)
assert_that(error, is_(f'Invalid CNAME: 1.2.3.4, valid CNAME record data cannot be an IP address.'))
def test_create_cname_with_multiple_records(shared_zone_test_context):
"""
Test that creating a CNAME record set with multiple records returns an error
@ -1770,7 +1788,7 @@ def test_create_ipv4_ptr_recordset_with_verify_in_classless(shared_zone_test_con
try:
new_rs = {
"zoneId": reverse4_zone["id"],
"name": "196",
"name": "193",
"type": "PTR",
"ttl": 100,
"records": [

View File

@ -88,7 +88,60 @@ def test_get_recordset_doesnt_exist(shared_zone_test_context):
client.get_recordset(shared_zone_test_context.ok_zone["id"], "123", status=404)
@pytest.mark.serial
def test_get_recordset_count_status_code(shared_zone_test_context):
"""
Test getting recordset count for a valid zoneid should return 200
"""
client = shared_zone_test_context.ok_vinyldns_client
client.get_recordset_count(shared_zone_test_context.ok_zone["id"],status=200)
def test_get_recordset_count_error(shared_zone_test_context):
"""
Test getting recordset count for a invalid zoneid should return 404
"""
client = shared_zone_test_context.ok_vinyldns_client
client.get_recordset_count("999",status=404)
@pytest.mark.skip(reason="test is passing and failing with different result_recordset_count values")
def test_get_recordset_count_by_zoneid(shared_zone_test_context):
"""
Test getting a recordset with name @
"""
client = shared_zone_test_context.ok_vinyldns_client
ok_zone = shared_zone_test_context.ok_zone
result_rs = None
try:
new_rs = {
"zoneId": ok_zone["id"],
"name": "@",
"type": "TXT",
"ttl": 100,
"records": [
{
"text": "someText"
}
]
}
result = client.create_recordset(new_rs, status=202)
result_rs = client.wait_until_recordset_change_status(result, "Complete")["recordSet"]
# Get the recordset we just made and verify
result = client.get_recordset(result_rs["zoneId"], result_rs["id"])
result_recordset_count = client.get_recordset_count(result_rs["zoneId"],status=200)
result_rs = result["recordSet"]
expected_rs = new_rs
expected_rs["name"] = ok_zone["name"]
verify_recordset(result_rs, expected_rs)
assert_that(result_recordset_count, is_({'count': 10}))
finally:
if result_rs:
delete_result = client.delete_recordset(result_rs["zoneId"], result_rs["id"], status=202)
client.wait_until_recordset_change_status(delete_result, "Complete")
def test_at_get_recordset(shared_zone_test_context):
"""
Test getting a recordset with name @

View File

@ -30,6 +30,39 @@ def check_changes_response(response, recordChanges=False, nextId=False, startFro
assert_that(change["userName"], is_("history-user"))
def check_change_history_response(response, fqdn, type, recordChanges=False, nextId=False, startFrom=False,
maxItems=100):
"""
:param type: type of the record
:param fqdn: fqdn of the record
:param response: return value of list_recordset_changes()
:param recordChanges: true if not empty or False if empty, cannot check exact values because don't have access to all attributes
:param nextId: true if exists, false if doesn't, wouldn't be able to check exact value
:param startFrom: the string for startFrom or false if doesnt exist
:param maxItems: maxItems is defined as an Int by default so will always return an Int
"""
assert_that(response, has_key("zoneId")) # always defined as random string
if recordChanges:
assert_that(response["recordSetChanges"], is_not(has_length(0)))
else:
assert_that(response["recordSetChanges"], has_length(0))
if nextId:
assert_that(response, has_key("nextId"))
else:
assert_that(response, is_not(has_key("nextId")))
if startFrom:
assert_that(response["startFrom"], is_(startFrom))
else:
assert_that(response, is_not(has_key("startFrom")))
assert_that(response["maxItems"], is_(maxItems))
for change in response["recordSetChanges"]:
assert_that(change["userName"], is_("history-user"))
for recordset in change["recordSet"]:
assert_that(change["recordSet"]["type"], is_(type))
assert_that(change["recordSet"]["name"] + "." + response["recordSetChanges"][0]["zone"]["name"], is_(fqdn))
def test_list_recordset_changes_no_authorization(shared_zone_test_context):
"""
Test that recordset changes without authorization fails
@ -143,12 +176,12 @@ def test_list_recordset_changes_exhausted(shared_zone_test_context):
def test_list_recordset_returning_no_changes(shared_zone_test_context):
"""
Pass in startFrom of 0 should return empty list because start key is created time
Pass in startFrom of "2000" should return empty list because start key exceeded number of recordset changes
"""
client = shared_zone_test_context.history_client
original_zone = shared_zone_test_context.history_zone
response = client.list_recordset_changes(original_zone["id"], start_from="0", max_items=None)
check_changes_response(response, recordChanges=False, startFrom="0", nextId=False)
response = client.list_recordset_changes(original_zone["id"], start_from=2000, max_items=None)
check_changes_response(response, recordChanges=False, startFrom=2000, nextId=False)
def test_list_recordset_changes_default_max_items(shared_zone_test_context):
@ -174,3 +207,109 @@ def test_list_recordset_changes_max_items_boundaries(shared_zone_test_context):
assert_that(too_large, is_("maxItems was 101, maxItems must be between 0 exclusive and 100 inclusive"))
assert_that(too_small, is_("maxItems was 0, maxItems must be between 0 exclusive and 100 inclusive"))
def test_list_recordset_history_no_authorization(shared_zone_test_context):
"""
Test that recordset history without authorization fails
"""
client = shared_zone_test_context.history_client
zone_id = shared_zone_test_context.history_zone["id"]
fqdn = "test-create-cname-ok.system-test-history1."
type = "CNAME"
client.list_recordset_change_history(zone_id, fqdn, type, sign_request=False, status=401)
def test_list_recordset_history_member_auth_success(shared_zone_test_context):
"""
Test recordset history succeeds with membership auth for member of admin group
"""
client = shared_zone_test_context.history_client
zone_id = shared_zone_test_context.history_zone["id"]
fqdn = "test-create-cname-ok.system-test-history1."
type = "CNAME"
response = client.list_recordset_change_history(zone_id, fqdn, type, status=200)
check_change_history_response(response, fqdn, type, recordChanges=True, startFrom=False, nextId=False)
def test_list_recordset_history_member_auth_no_access(shared_zone_test_context):
"""
Test recordset history fails for user not in admin group with no acl rules
"""
client = shared_zone_test_context.ok_vinyldns_client
zone_id = shared_zone_test_context.history_zone["id"]
fqdn = "test-create-cname-ok.system-test-history1."
type = "CNAME"
client.list_recordset_change_history(zone_id, fqdn, type, status=403)
def test_list_recordset_history_success(shared_zone_test_context):
"""
Test recordset history succeeds with membership auth for member of admin group
"""
client = shared_zone_test_context.history_client
zone_id = shared_zone_test_context.history_zone["id"]
fqdn = "test-create-cname-ok.system-test-history1."
type = "CNAME"
response = client.list_recordset_change_history(zone_id, fqdn, type, status=200)
check_change_history_response(response, fqdn, type, recordChanges=True, startFrom=False, nextId=False)
def test_list_recordset_history_paging(shared_zone_test_context):
"""
Test paging for recordset history can use previous nextId as start key of next page
"""
client = shared_zone_test_context.history_client
zone_id = shared_zone_test_context.history_zone["id"]
fqdn = "test-create-cname-ok.system-test-history1."
type = "CNAME"
response_1 = client.list_recordset_change_history(zone_id, fqdn, type, start_from=None, max_items=1)
response_2 = client.list_recordset_change_history(zone_id, fqdn, type, start_from=response_1["nextId"], max_items=1)
check_change_history_response(response_1, fqdn, type, recordChanges=True, nextId=True, startFrom=False, maxItems=1)
check_change_history_response(response_2, fqdn, type, recordChanges=True, nextId=True, startFrom=response_1["nextId"], maxItems=1)
def test_list_recordset_history_returning_no_changes(shared_zone_test_context):
"""
Pass in startFrom of "2000" should return empty list because start key exceeded number of recordset change history
"""
client = shared_zone_test_context.history_client
zone_id = shared_zone_test_context.history_zone["id"]
fqdn = "test-create-cname-ok.system-test-history1."
type = "CNAME"
response = client.list_recordset_change_history(zone_id, fqdn, type, start_from=2000, max_items=None)
assert_that(response["recordSetChanges"], has_length(0))
assert_that(response["startFrom"], is_(2000))
assert_that(response["maxItems"], is_(100))
def test_list_recordset_history_default_max_items(shared_zone_test_context):
"""
Test default max items is 100
"""
client = shared_zone_test_context.history_client
zone_id = shared_zone_test_context.history_zone["id"]
fqdn = "test-create-cname-ok.system-test-history1."
type = "CNAME"
response = client.list_recordset_change_history(zone_id, fqdn, type, start_from=None, max_items=None)
check_change_history_response(response, fqdn, type, recordChanges=True, startFrom=False, nextId=False, maxItems=100)
def test_list_recordset_history_max_items_boundaries(shared_zone_test_context):
"""
Test 0 < max_items <= 100
"""
client = shared_zone_test_context.history_client
zone_id = shared_zone_test_context.history_zone["id"]
fqdn = "test-create-cname-ok.system-test-history1."
type = "CNAME"
too_large = client.list_recordset_change_history(zone_id, fqdn, type, start_from=None, max_items=101, status=400)
too_small = client.list_recordset_change_history(zone_id, fqdn, type, start_from=None, max_items=0, status=400)
assert_that(too_large, is_("maxItems was 101, maxItems must be between 0 exclusive and 100 inclusive"))
assert_that(too_small, is_("maxItems was 0, maxItems must be between 0 exclusive and 100 inclusive"))

View File

@ -303,7 +303,7 @@ def test_update_recordset_replace_2_records_with_1_different_record(shared_zone_
]
}
result = client.create_recordset(new_rs, status=202)
assert_that(result["changeType"], is_("Create"))
assert_that(result["status"], is_("Pending"))
assert_that(result["created"], is_not(none()))
@ -373,7 +373,7 @@ def test_update_existing_record_set_add_record(shared_zone_test_context):
]
}
result = client.create_recordset(new_rs, status=202)
assert_that(result["changeType"], is_("Create"))
assert_that(result["status"], is_("Pending"))
assert_that(result["created"], is_not(none()))
@ -1593,8 +1593,7 @@ def test_update_fails_for_unapplied_unsynced_record_change(shared_zone_test_cont
]
update_response = client.update_recordset(update_rs, status=202)
response = client.wait_until_recordset_change_status(update_response, "Failed")
assert_that(response["systemMessage"], is_(f"Failed validating update to DNS for change \"{response['id']}\": \"{a_rs['name']}\": "
f"This record set is out of sync with the DNS backend; sync this zone before attempting to update this record set."))
assert_that(response["systemMessage"], is_(f"This record set is out of sync with the DNS backend. Sync this zone before attempting to update this record set."))
finally:
try:
delete_result = client.delete_recordset(zone["id"], create_rs["id"], status=202)
@ -1789,6 +1788,115 @@ def test_update_from_unassociated_user_in_shared_zone_fails(shared_zone_test_con
delete_result = shared_client.delete_recordset(zone["id"], create_rs["id"], status=202)
shared_client.wait_until_recordset_change_status(delete_result, "Complete")
def test_update_from_super_user_in_shared_zone_passes_when_owner_group_is_only_update(shared_zone_test_context):
"""
Test that updating with a superuser passes when the zone is set to shared and the owner group is the only change
"""
super_user_client = shared_zone_test_context.super_user_client
shared_record_group = shared_zone_test_context.shared_record_group
dummy_group = shared_zone_test_context.dummy_group
shared_client = shared_zone_test_context.shared_zone_vinyldns_client
shared_zone = shared_zone_test_context.shared_zone
update_rs = None
try:
record_json = create_recordset(shared_zone, "test_shared_success", "A", [{"address": "1.1.1.1"}])
record_json["ownerGroupId"] = shared_record_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_record_group["id"]))
update["ownerGroupId"] = dummy_group["id"]
update_response = super_user_client.update_recordset(update, status=202)
update_rs = shared_client.wait_until_recordset_change_status(update_response, "Complete")["recordSet"]
assert_that(update_rs["ownerGroupId"], is_(dummy_group["id"]))
finally:
if update_rs:
delete_result = shared_client.delete_recordset(shared_zone["id"], update_rs["id"], status=202)
shared_client.wait_until_recordset_change_status(delete_result, "Complete")
def test_update_from_unassociated_user_in_shared_zone_fails_when_owner_group_is_only_update(shared_zone_test_context):
"""
Test that updating with a user not in the record owner group fails when the zone is set to shared and
the owner group is the only change
"""
ok_client = shared_zone_test_context.ok_vinyldns_client
shared_record_group = shared_zone_test_context.shared_record_group
dummy_group = shared_zone_test_context.dummy_group
shared_client = shared_zone_test_context.shared_zone_vinyldns_client
shared_zone = shared_zone_test_context.shared_zone
create_rs = None
try:
record_json = create_recordset(shared_zone, "test_shared_fail", "A", [{"address": "1.1.1.1"}])
record_json["ownerGroupId"] = shared_record_group["id"]
create_response = shared_client.create_recordset(record_json, status=202)
create_rs = shared_client.wait_until_recordset_change_status(create_response, "Complete")["recordSet"]
assert_that(create_rs["ownerGroupId"], is_(shared_record_group["id"]))
update = create_rs
update["ownerGroupId"] = dummy_group["id"]
error = ok_client.update_recordset(update, status=422)
assert_that(error, is_(f"User not in record owner group with id \"{dummy_group['id']}\""))
finally:
if create_rs:
delete_result = shared_client.delete_recordset(shared_zone["id"], create_rs["id"], status=202)
shared_client.wait_until_recordset_change_status(delete_result, "Complete")
def test_update_from_super_user_in_private_zone_fails_when_owner_group_is_only_update(shared_zone_test_context):
"""
Test that updating with a superuser fails when the zone is set to private and the owner group is the only change
"""
ok_client = shared_zone_test_context.ok_vinyldns_client
super_user_client = shared_zone_test_context.super_user_client
ok_record_group = shared_zone_test_context.ok_group
dummy_group = shared_zone_test_context.dummy_group
ok_zone = shared_zone_test_context.ok_zone
create_rs = None
try:
record_json = create_recordset(ok_zone, "test_private_fail", "A", [{"address": "1.1.1.1"}])
record_json["ownerGroupId"] = ok_record_group["id"]
create_response = ok_client.create_recordset(record_json, status=202)
create_rs = ok_client.wait_until_recordset_change_status(create_response, "Complete")["recordSet"]
assert_that(create_rs["ownerGroupId"], is_(ok_record_group["id"]))
update = create_rs
update["ownerGroupId"] = dummy_group["id"]
error = super_user_client.update_recordset(update, status=403)
assert_that(error, is_(f'User super-user does not have access to update test-private-fail.{ok_zone["name"]}'))
finally:
if create_rs:
delete_result = ok_client.delete_recordset(ok_zone["id"], create_rs["id"], status=202)
ok_client.wait_until_recordset_change_status(delete_result, "Complete")
def test_update_from_super_user_in_shared_zone_fails_when_owner_group_is_not_the_only_update(shared_zone_test_context):
"""
Test that updating with a superuser fails when the zone is set to shared and the owner group is not the only change
"""
super_user_client = shared_zone_test_context.super_user_client
shared_record_group = shared_zone_test_context.shared_record_group
dummy_group = shared_zone_test_context.dummy_group
shared_client = shared_zone_test_context.shared_zone_vinyldns_client
shared_zone = shared_zone_test_context.shared_zone
create_rs = None
try:
record_json = create_recordset(shared_zone, "test_shared_fail", "A", [{"address": "1.1.1.1"}])
record_json["ownerGroupId"] = shared_record_group["id"]
create_response = shared_client.create_recordset(record_json, status=202)
create_rs = shared_client.wait_until_recordset_change_status(create_response, "Complete")["recordSet"]
assert_that(create_rs["ownerGroupId"], is_(shared_record_group["id"]))
update = create_rs
update["ownerGroupId"] = dummy_group["id"]
update["ttl"] = update["ttl"] + 100
error = super_user_client.update_recordset(update, status=403)
assert_that(error, is_(f'User super-user does not have access to update test-shared-fail.{shared_zone["name"]}'))
finally:
if create_rs:
delete_result = shared_client.delete_recordset(shared_zone["id"], create_rs["id"], status=202)
shared_client.wait_until_recordset_change_status(delete_result, "Complete")
@pytest.mark.serial
def test_update_from_acl_for_shared_zone_passes(shared_zone_test_context):
@ -1819,6 +1927,572 @@ 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"}], recordSetGroupChange={"ownerShipTransferStatus": None, "requestedOwnerGroupId": None})
record_json = {
"zoneId": ok_zone["id"],
"name": "test_update_success",
"type": "A",
"ttl": 38400,
"records": [
{"address": "1.1.1.1"}
],
"recordSetGroupChange": {"ownerShipTransferStatus": "None",
"requestedOwnerGroupId": None}
}
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):
"""

View File

@ -28,12 +28,14 @@ class SharedZoneTestContext(object):
self.dummy_vinyldns_client = VinylDNSClient(VinylDNSTestContext.vinyldns_url, "dummyAccessKey", "dummySecretKey")
self.shared_zone_vinyldns_client = VinylDNSClient(VinylDNSTestContext.vinyldns_url, "sharedZoneUserAccessKey", "sharedZoneUserSecretKey")
self.support_user_client = VinylDNSClient(VinylDNSTestContext.vinyldns_url, "supportUserAccessKey", "supportUserSecretKey")
self.super_user_client = VinylDNSClient(VinylDNSTestContext.vinyldns_url, "superUserAccessKey", "superUserSecretKey")
self.unassociated_client = VinylDNSClient(VinylDNSTestContext.vinyldns_url, "listGroupAccessKey", "listGroupSecretKey")
self.test_user_client = VinylDNSClient(VinylDNSTestContext.vinyldns_url, "testUserAccessKey", "testUserSecretKey")
self.history_client = VinylDNSClient(VinylDNSTestContext.vinyldns_url, "history-key", "history-secret")
self.non_user_client = VinylDNSClient(VinylDNSTestContext.vinyldns_url, "not-exist-key", "not-exist-secret")
self.clients = [self.ok_vinyldns_client, self.dummy_vinyldns_client, self.shared_zone_vinyldns_client, self.support_user_client,
self.unassociated_client, self.test_user_client, self.history_client, self.non_user_client]
self.clients = [self.ok_vinyldns_client, self.dummy_vinyldns_client, self.shared_zone_vinyldns_client,
self.support_user_client, self.super_user_client, self.unassociated_client,
self.test_user_client, self.history_client, self.non_user_client]
self.list_zones = ListZonesTestContext(partition_id)
self.list_zones_client = self.list_zones.client
self.list_records_context = ListRecordSetsTestContext(partition_id)

View File

@ -95,6 +95,49 @@ def test_create_zone_success(shared_zone_test_context):
client.abandon_zones([result_zone["id"]], status=202)
def test_create_zone_success_number_of_dots(shared_zone_test_context):
"""
Test successfully creating a zone
"""
client = shared_zone_test_context.ok_vinyldns_client
result_zone = None
try:
# Include a space in the zone name to verify that it is trimmed and properly formatted
zone_name = f"one-time{shared_zone_test_context.partition_id} "
zone = {
"name": zone_name,
"email": "test@ok.dummy.com",
"adminGroupId": shared_zone_test_context.ok_group["id"],
"backendId": "func-test-backend"
}
result = client.create_zone(zone, status=202)
result_zone = result["zone"]
client.wait_until_zone_active(result_zone["id"])
get_result = client.get_zone(result_zone["id"])
get_zone = get_result["zone"]
assert_that(get_zone["name"], is_(zone["name"].strip() + "."))
assert_that(get_zone["email"], is_(zone["email"]))
assert_that(get_zone["adminGroupId"], is_(zone["adminGroupId"]))
assert_that(get_zone["latestSync"], is_not(none()))
assert_that(get_zone["status"], is_("Active"))
assert_that(get_zone["backendId"], is_("func-test-backend"))
# confirm that the recordsets in DNS have been saved in vinyldns
recordsets = client.list_recordsets_by_zone(result_zone["id"])["recordSets"]
assert_that(len(recordsets), is_(7))
for rs in recordsets:
small_rs = dict((k, rs[k]) for k in ["name", "type", "records"])
small_rs["records"] = small_rs["records"]
assert_that(retrieve_dns_records(shared_zone_test_context), has_item(small_rs))
finally:
if result_zone:
client.abandon_zones([result_zone["id"]], status=202)
@pytest.mark.skip_production
def test_create_zone_without_transfer_connection_leaves_it_empty(shared_zone_test_context):
"""
@ -179,6 +222,59 @@ def test_create_invalid_zone_data(shared_zone_test_context):
errors = client.create_zone(zone, status=400)["errors"]
assert_that(errors, contains_inanyorder("Do not know how to convert JString(invalid_value) into boolean"))
def test_create_invalid_email(shared_zone_test_context):
"""
Test that creating a zone with invalid email
"""
client = shared_zone_test_context.ok_vinyldns_client
zone_name = f"one-time{shared_zone_test_context.partition_id} "
zone = {
"name": zone_name,
"email": "test.abc.com",
"adminGroupId": shared_zone_test_context.ok_group["id"],
"backendId": "func-test-backend"
}
errors = client.create_zone(zone, status=400)
assert_that(errors, is_("Please enter a valid Email."))
def test_create_invalid_email_number_of_dots(shared_zone_test_context):
"""
Test that creating a zone with invalid email
"""
client = shared_zone_test_context.ok_vinyldns_client
zone_name = f"one-time{shared_zone_test_context.partition_id} "
zone = {
"name": zone_name,
"email": "test@abc.ok.dummy.com",
"adminGroupId": shared_zone_test_context.ok_group["id"],
"backendId": "func-test-backend"
}
errors = client.create_zone(zone, status=400)
assert_that(errors, is_("Please enter a valid Email. Number of dots allowed after @ is 2"))
def test_create_invalid_domain(shared_zone_test_context):
"""
Test that creating a zone with invalid domain
"""
client = shared_zone_test_context.ok_vinyldns_client
zone_name = f"one-time{shared_zone_test_context.partition_id} "
zone = {
"name": zone_name,
"email": "test@abc.com",
"adminGroupId": shared_zone_test_context.ok_group["id"],
"backendId": "func-test-backend"
}
errors = client.create_zone(zone, status=400)
assert_that(errors, is_("Please enter a valid Email. Valid domains should end with test.com,dummy.com"))
@pytest.mark.serial
def test_create_zone_with_connection_failure(shared_zone_test_context):

View File

@ -46,6 +46,7 @@ def test_get_zone_shared_by_id_non_owner(shared_zone_test_context):
assert_that(retrieved["shared"], is_(True))
assert_that(retrieved["accessLevel"], is_("Read"))
def test_get_zone_private_by_id_fails_without_access(shared_zone_test_context):
"""
Test get an existing zone by id without access
@ -72,6 +73,29 @@ def test_get_zone_by_id_no_authorization(shared_zone_test_context):
client.get_zone("123456", sign_request=False, status=401)
def test_get_common_zone_details_by_id(shared_zone_test_context):
"""
Test get an existing zone's common details by id
"""
client = shared_zone_test_context.ok_vinyldns_client
result = client.get_common_zone_details(shared_zone_test_context.system_test_zone["id"], status=200)
retrieved = result["zone"]
assert_that(retrieved["name"], is_(shared_zone_test_context.system_test_zone["name"]))
assert_that(retrieved["email"], is_(shared_zone_test_context.system_test_zone["email"]))
assert_that(retrieved["adminGroupName"], is_(shared_zone_test_context.ok_group["name"]))
assert_that(retrieved["adminGroupId"], is_(shared_zone_test_context.ok_group["id"]))
def test_get_common_zone_details_by_id_returns_404_when_not_found(shared_zone_test_context):
"""
Test get an existing zone returns a 404 when the zone is not found
"""
client = shared_zone_test_context.ok_vinyldns_client
client.get_common_zone_details(str(uuid.uuid4()), status=404)
@pytest.mark.serial
def test_get_zone_by_id_includes_acl_display_name(shared_zone_test_context):
"""

View File

@ -53,7 +53,7 @@ def test_list_zone_changes_member_auth_no_access(shared_zone_test_context):
@pytest.mark.serial
def test_list_zone_changes_member_auth_with_acl(shared_zone_test_context):
"""
Test list zone changes succeeds for user with acl rules
Test list zone changes fails for user with acl rules
"""
zone = shared_zone_test_context.ok_zone
acl_rule = generate_acl_rule("Write", userId="dummy")
@ -62,7 +62,7 @@ def test_list_zone_changes_member_auth_with_acl(shared_zone_test_context):
client.list_zone_changes(zone["id"], status=403)
add_ok_acl_rules(shared_zone_test_context, [acl_rule])
client.list_zone_changes(zone["id"], status=200)
client.list_zone_changes(zone["id"], status=403)
finally:
clear_ok_acl_rules(shared_zone_test_context)

View File

@ -4,6 +4,7 @@ import pytest
from utils import *
from vinyldns_context import VinylDNSTestContext
from datetime import datetime, timezone, timedelta
@pytest.mark.serial
@ -45,7 +46,7 @@ def test_update_zone_success(shared_zone_test_context):
result_zone = result["zone"]
client.wait_until_zone_active(result_zone["id"])
result_zone["email"] = "foo@bar.com"
result_zone["email"] = "test@dummy.com"
result_zone["acl"]["rules"] = [acl_rule]
update_result = client.update_zone(result_zone, status=202)
client.wait_until_zone_change_status_synced(update_result)
@ -57,7 +58,7 @@ def test_update_zone_success(shared_zone_test_context):
get_result = client.get_zone(result_zone["id"])
uz = get_result["zone"]
assert_that(uz["email"], is_("foo@bar.com"))
assert_that(uz["email"], is_("test@dummy.com"))
assert_that(uz["updated"], is_not(none()))
acl = uz["acl"]
@ -66,6 +67,378 @@ def test_update_zone_success(shared_zone_test_context):
if result_zone:
client.abandon_zones([result_zone["id"]], status=202)
def test_update_zone_success_wildcard(shared_zone_test_context):
"""
Test updating a zone for email validation wildcard
"""
client = shared_zone_test_context.ok_vinyldns_client
result_zone = None
try:
zone_name = f"one-time{shared_zone_test_context.partition_id}"
acl_rule = {
"accessLevel": "Read",
"description": "test-acl-updated-by-updatezn",
"userId": "ok",
"recordMask": "www-*",
"recordTypes": ["A", "AAAA", "CNAME"]
}
zone = {
"name": zone_name,
"email": "test@test.com",
"adminGroupId": shared_zone_test_context.ok_group["id"],
"connection": {
"name": "vinyldns.",
"keyName": VinylDNSTestContext.dns_key_name,
"key": VinylDNSTestContext.dns_key,
"primaryServer": VinylDNSTestContext.name_server_ip
},
"transferConnection": {
"name": "vinyldns.",
"keyName": VinylDNSTestContext.dns_key_name,
"key": VinylDNSTestContext.dns_key,
"primaryServer": VinylDNSTestContext.name_server_ip
}
}
result = client.create_zone(zone, status=202)
result_zone = result["zone"]
client.wait_until_zone_active(result_zone["id"])
result_zone["email"] = "test@ok.dummy.com"
result_zone["acl"]["rules"] = [acl_rule]
update_result = client.update_zone(result_zone, status=202)
client.wait_until_zone_change_status_synced(update_result)
assert_that(update_result["changeType"], is_("Update"))
assert_that(update_result["userId"], is_("ok"))
assert_that(update_result, has_key("created"))
get_result = client.get_zone(result_zone["id"])
uz = get_result["zone"]
assert_that(uz["email"], is_("test@ok.dummy.com"))
assert_that(uz["updated"], is_not(none()))
acl = uz["acl"]
verify_acl_rule_is_present_once(acl_rule, acl)
finally:
if result_zone:
client.abandon_zones([result_zone["id"]], status=202)
def test_update_zone_success_number_of_dots(shared_zone_test_context):
"""
Test updating a zone for email validation wildcard
"""
client = shared_zone_test_context.ok_vinyldns_client
result_zone = None
try:
zone_name = f"one-time{shared_zone_test_context.partition_id}"
acl_rule = {
"accessLevel": "Read",
"description": "test-acl-updated-by-updatezn",
"userId": "ok",
"recordMask": "www-*",
"recordTypes": ["A", "AAAA", "CNAME"]
}
zone = {
"name": zone_name,
"email": "test@test.com",
"adminGroupId": shared_zone_test_context.ok_group["id"],
"connection": {
"name": "vinyldns.",
"keyName": VinylDNSTestContext.dns_key_name,
"key": VinylDNSTestContext.dns_key,
"primaryServer": VinylDNSTestContext.name_server_ip
},
"transferConnection": {
"name": "vinyldns.",
"keyName": VinylDNSTestContext.dns_key_name,
"key": VinylDNSTestContext.dns_key,
"primaryServer": VinylDNSTestContext.name_server_ip
}
}
result = client.create_zone(zone, status=202)
result_zone = result["zone"]
client.wait_until_zone_active(result_zone["id"])
result_zone["email"] = "test@ok.dummy.com"
result_zone["acl"]["rules"] = [acl_rule]
update_result = client.update_zone(result_zone, status=202)
client.wait_until_zone_change_status_synced(update_result)
assert_that(update_result["changeType"], is_("Update"))
assert_that(update_result["userId"], is_("ok"))
assert_that(update_result, has_key("created"))
get_result = client.get_zone(result_zone["id"])
uz = get_result["zone"]
assert_that(uz["email"], is_("test@ok.dummy.com"))
assert_that(uz["updated"], is_not(none()))
acl = uz["acl"]
verify_acl_rule_is_present_once(acl_rule, acl)
finally:
if result_zone:
client.abandon_zones([result_zone["id"]], status=202)
def test_update_invalid_email(shared_zone_test_context):
"""
Test that updating a zone with invalid email
"""
client = shared_zone_test_context.ok_vinyldns_client
result_zone = None
try:
zone_name = f"one-time{shared_zone_test_context.partition_id}"
acl_rule = {
"accessLevel": "Read",
"description": "test-acl-updated-by-updatezn",
"userId": "ok",
"recordMask": "www-*",
"recordTypes": ["A", "AAAA", "CNAME"]
}
zone = {
"name": zone_name,
"email": "test@test.com",
"adminGroupId": shared_zone_test_context.ok_group["id"],
"connection": {
"name": "vinyldns.",
"keyName": VinylDNSTestContext.dns_key_name,
"key": VinylDNSTestContext.dns_key,
"primaryServer": VinylDNSTestContext.name_server_ip
},
"transferConnection": {
"name": "vinyldns.",
"keyName": VinylDNSTestContext.dns_key_name,
"key": VinylDNSTestContext.dns_key,
"primaryServer": VinylDNSTestContext.name_server_ip
}
}
result = client.create_zone(zone, status=202)
result_zone = result["zone"]
client.wait_until_zone_active(result_zone["id"])
result_zone["email"] = "test.trial.com"
errors = client.update_zone(result_zone, status=400)
assert_that(errors, is_("Please enter a valid Email."))
finally:
if result_zone:
client.abandon_zones([result_zone["id"]], status=202)
def test_update_invalid_domain(shared_zone_test_context):
"""
Test that updating a zone with invalid domain
"""
client = shared_zone_test_context.ok_vinyldns_client
result_zone = None
try:
zone_name = f"one-time{shared_zone_test_context.partition_id}"
acl_rule = {
"accessLevel": "Read",
"description": "test-acl-updated-by-updatezn",
"userId": "ok",
"recordMask": "www-*",
"recordTypes": ["A", "AAAA", "CNAME"]
}
zone = {
"name": zone_name,
"email": "test@test.com",
"adminGroupId": shared_zone_test_context.ok_group["id"],
"connection": {
"name": "vinyldns.",
"keyName": VinylDNSTestContext.dns_key_name,
"key": VinylDNSTestContext.dns_key,
"primaryServer": VinylDNSTestContext.name_server_ip
},
"transferConnection": {
"name": "vinyldns.",
"keyName": VinylDNSTestContext.dns_key_name,
"key": VinylDNSTestContext.dns_key,
"primaryServer": VinylDNSTestContext.name_server_ip
}
}
result = client.create_zone(zone, status=202)
result_zone = result["zone"]
client.wait_until_zone_active(result_zone["id"])
result_zone["email"] = "test@trial.com"
errors = client.update_zone(result_zone, status=400)
assert_that(errors, is_("Please enter a valid Email. Valid domains should end with test.com,dummy.com"))
finally:
if result_zone:
client.abandon_zones([result_zone["id"]], status=202)
def test_update_invalid_email_number_of_dots(shared_zone_test_context):
"""
Test that updating a zone with invalid domain
"""
client = shared_zone_test_context.ok_vinyldns_client
result_zone = None
try:
zone_name = f"one-time{shared_zone_test_context.partition_id}"
acl_rule = {
"accessLevel": "Read",
"description": "test-acl-updated-by-updatezn",
"userId": "ok",
"recordMask": "www-*",
"recordTypes": ["A", "AAAA", "CNAME"]
}
zone = {
"name": zone_name,
"email": "test@test.com",
"adminGroupId": shared_zone_test_context.ok_group["id"],
"connection": {
"name": "vinyldns.",
"keyName": VinylDNSTestContext.dns_key_name,
"key": VinylDNSTestContext.dns_key,
"primaryServer": VinylDNSTestContext.name_server_ip
},
"transferConnection": {
"name": "vinyldns.",
"keyName": VinylDNSTestContext.dns_key_name,
"key": VinylDNSTestContext.dns_key,
"primaryServer": VinylDNSTestContext.name_server_ip
}
}
result = client.create_zone(zone, status=202)
result_zone = result["zone"]
client.wait_until_zone_active(result_zone["id"])
result_zone["email"] = "test@ok.ok.dummy.com"
errors = client.update_zone(result_zone, status=400)
assert_that(errors, is_("Please enter a valid Email. Number of dots allowed after @ is 2"))
finally:
if result_zone:
client.abandon_zones([result_zone["id"]], status=202)
def test_update_zone_sync_schedule_fails(shared_zone_test_context):
"""
Test updating a zone with a schedule for zone sync fails when the user is not an admin user
"""
client = shared_zone_test_context.ok_vinyldns_client
result_zone = None
try:
zone_name = f"one-time{shared_zone_test_context.partition_id}"
zone = {
"name": zone_name,
"email": "test@test.com",
"adminGroupId": shared_zone_test_context.ok_group["id"],
"connection": {
"name": "vinyldns.",
"keyName": VinylDNSTestContext.dns_key_name,
"key": VinylDNSTestContext.dns_key,
"primaryServer": VinylDNSTestContext.name_server_ip
},
"transferConnection": {
"name": "vinyldns.",
"keyName": VinylDNSTestContext.dns_key_name,
"key": VinylDNSTestContext.dns_key,
"primaryServer": VinylDNSTestContext.name_server_ip
}
}
result = client.create_zone(zone, status=202)
result_zone = result["zone"]
client.wait_until_zone_active(result_zone["id"])
# schedule zone sync every 5 seconds
result_zone["recurrenceSchedule"] = "0/5 0 0 ? * * *"
error = client.update_zone(result_zone, status=403)
assert_that(error, contains_string("User 'ok' is not authorized to schedule zone sync in this zone."))
finally:
if result_zone:
client.abandon_zones([result_zone["id"]], status=202)
def test_update_zone_sync_schedule_success(shared_zone_test_context):
"""
Test updating a zone with a schedule for zone sync successfully sync zone at scheduled time
"""
client = shared_zone_test_context.ok_vinyldns_client
result_zone = None
try:
zone_name = f"one-time{shared_zone_test_context.partition_id}"
zone = {
"name": zone_name,
"email": "test@test.com",
"adminGroupId": shared_zone_test_context.ok_group["id"],
"connection": {
"name": "vinyldns.",
"keyName": VinylDNSTestContext.dns_key_name,
"key": VinylDNSTestContext.dns_key,
"primaryServer": VinylDNSTestContext.name_server_ip
},
"transferConnection": {
"name": "vinyldns.",
"keyName": VinylDNSTestContext.dns_key_name,
"key": VinylDNSTestContext.dns_key,
"primaryServer": VinylDNSTestContext.name_server_ip
}
}
result = client.create_zone(zone, status=202)
result_zone = result["zone"]
client.wait_until_zone_active(result_zone["id"])
# schedule zone sync every 5 seconds
result_zone["recurrenceSchedule"] = "0/5 0 0 ? * * *"
# Get the current time in the local timezone
now = datetime.now()
# Convert the time to the UTC timezone
utc_time = now.astimezone(timezone.utc)
super_user_client = shared_zone_test_context.support_user_client
update_result = super_user_client.update_zone(result_zone, status=202)
super_user_client.wait_until_zone_change_status_synced(update_result)
assert_that(update_result["changeType"], is_("Update"))
assert_that(update_result["userId"], is_("support-user-id"))
assert_that(update_result, has_key("created"))
get_result = client.get_zone(result_zone["id"])
uz = get_result["zone"]
assert_that(uz["recurrenceSchedule"], is_("0/5 0 0 ? * * *"))
assert_that(uz["updated"], is_not(none()))
# Add + or - 2 seconds to the current time as there may be a slight change than the exact scheduled time
utc_time_1 = utc_time - timedelta(seconds=1)
utc_time_2 = utc_time - timedelta(seconds=2)
utc_time_3 = utc_time + timedelta(seconds=1)
utc_time_4 = utc_time + timedelta(seconds=2)
# Format the time as a string in the desired format
time_str = utc_time.strftime('%Y-%m-%dT%H:%M:%SZ')
time_str_1 = utc_time_1.strftime('%Y-%m-%dT%H:%M:%SZ')
time_str_2 = utc_time_2.strftime('%Y-%m-%dT%H:%M:%SZ')
time_str_3 = utc_time_3.strftime('%Y-%m-%dT%H:%M:%SZ')
time_str_4 = utc_time_4.strftime('%Y-%m-%dT%H:%M:%SZ')
time_list = [time_str, time_str_1, time_str_2, time_str_3, time_str_4]
# Check if zone sync was performed at scheduled time
assert_that(time_list, has_item(uz["latestSync"]))
finally:
if result_zone:
client.abandon_zones([result_zone["id"]], status=202)
def test_update_bad_acl_fails(shared_zone_test_context):
"""
@ -318,12 +691,12 @@ def test_create_acl_user_rule_invalid_cidr_failure(shared_zone_test_context):
"accessLevel": "Read",
"description": "test-acl-user-id",
"userId": "789",
"recordMask": "10.0.0.0/50",
"recordMask": "10.0.0/50",
"recordTypes": ["PTR"]
}
errors = client.add_zone_acl_rule(shared_zone_test_context.ip4_reverse_zone["id"], acl_rule, status=400)
assert_that(errors, contains_string("PTR types must have no mask or a valid CIDR mask: IPv4 mask must be between 0 and 32"))
assert_that(errors, contains_string("PTR types must have no mask or a valid CIDR mask: Invalid CIDR block"))
@pytest.mark.serial
@ -626,7 +999,7 @@ def test_update_reverse_v4_zone(shared_zone_test_context):
client = shared_zone_test_context.ok_vinyldns_client
zone = copy.deepcopy(shared_zone_test_context.ip4_reverse_zone)
zone["email"] = "update-test@bar.com"
zone["email"] = "test@test.com"
update_result = client.update_zone(zone, status=202)
client.wait_until_zone_change_status_synced(update_result)
@ -638,7 +1011,7 @@ def test_update_reverse_v4_zone(shared_zone_test_context):
get_result = client.get_zone(zone["id"])
uz = get_result["zone"]
assert_that(uz["email"], is_("update-test@bar.com"))
assert_that(uz["email"], is_("test@test.com"))
assert_that(uz["updated"], is_not(none()))
@ -649,7 +1022,7 @@ def test_update_reverse_v6_zone(shared_zone_test_context):
client = shared_zone_test_context.ok_vinyldns_client
zone = copy.deepcopy(shared_zone_test_context.ip6_reverse_zone)
zone["email"] = "update-test@bar.com"
zone["email"] = "test@test.com"
update_result = client.update_zone(zone, status=202)
client.wait_until_zone_change_status_synced(update_result)
@ -661,7 +1034,7 @@ def test_update_reverse_v6_zone(shared_zone_test_context):
get_result = client.get_zone(zone["id"])
uz = get_result["zone"]
assert_that(uz["email"], is_("update-test@bar.com"))
assert_that(uz["email"], is_("test@test.com"))
assert_that(uz["updated"], is_not(none()))
@ -831,6 +1204,7 @@ def test_normal_user_cannot_update_shared_zone_flag(shared_zone_test_context):
error = shared_zone_test_context.ok_vinyldns_client.update_zone(zone_update, status=403)
assert_that(error, contains_string("Not authorized to update zone shared status from false to true."))
@pytest.mark.serial
def test_update_connection_info_success(shared_zone_test_context):
"""

View File

@ -509,6 +509,57 @@ def get_change_MX_json(input_name, ttl=200, preference=None, exchange=None, chan
return json
def get_change_NS_json(input_name, ttl=200, nsdname=None, change_type="Add"):
json = {
"changeType": change_type,
"inputName": input_name,
"type": "NS"
}
if change_type == "Add":
json["ttl"] = ttl
json["record"] = {
"nsdname": nsdname
}
return json
def get_change_SRV_json(input_name, ttl=200, priority=None, weight=None, port=None, target=None, change_type="Add"):
json = {
"changeType": change_type,
"inputName": input_name,
"type": "SRV",
}
if change_type == "Add":
json["ttl"] = ttl
json["record"] = {
"priority": priority,
"weight": weight,
"port": port,
"target": target
}
return json
def get_change_NAPTR_json(input_name, ttl=200, order=None, preference=None, flags=None, service=None, regexp=None, replacement=None, change_type="Add"):
json = {
"changeType": change_type,
"inputName": input_name,
"type": "NAPTR",
}
if change_type == "Add":
json["ttl"] = ttl
json["record"] = {
"order": order,
"preference": preference,
"flags": flags,
"service": service,
"regexp": regexp,
"replacement": replacement
}
return json
def create_recordset(zone, rname, recordset_type, rdata_list, ttl=200, ownergroup_id=None):
recordset_data = {
"zoneId": zone["id"],

View File

@ -51,7 +51,8 @@ class VinylDNSClient(object):
self.session.close()
self.session_not_found_ok.close()
def requests_retry_not_found_ok_session(self, retries=20, backoff_factor=0.1, status_forcelist=(500, 502, 504), session=None):
def requests_retry_not_found_ok_session(self, retries=20, backoff_factor=0.1, status_forcelist=(500, 502, 504),
session=None):
session = session or requests.Session()
retry = Retry(
total=retries,
@ -79,7 +80,8 @@ class VinylDNSClient(object):
session.mount("https://", adapter)
return session
def make_request(self, url, method="GET", headers=None, body_string=None, sign_request=True, not_found_ok=False, **kwargs):
def make_request(self, url, method="GET", headers=None, body_string=None, sign_request=True, not_found_ok=False,
**kwargs):
# pull out status or None
status_code = kwargs.pop("status", None)
@ -100,13 +102,15 @@ class VinylDNSClient(object):
for k, v in query.items())
if sign_request:
signed_headers, signed_body = self.sign_request(method, path, body_string, query, with_headers=headers or {}, **kwargs)
signed_headers, signed_body = self.sign_request(method, path, body_string, query,
with_headers=headers or {}, **kwargs)
else:
signed_headers = headers or {}
signed_body = body_string
if not_found_ok:
response = self.session_not_found_ok.request(method, url, data=signed_body, headers=signed_headers, **kwargs)
response = self.session_not_found_ok.request(method, url, data=signed_body, headers=signed_headers,
**kwargs)
else:
response = self.session.request(method, url, data=signed_body, headers=signed_headers, **kwargs)
@ -388,6 +392,17 @@ class VinylDNSClient(object):
return data
def get_common_zone_details(self, zone_id, **kwargs):
"""
Gets common zone details which can be seen by all users for the given zone id
:param zone_id: the id of the zone to retrieve
:return: the zone, or will 404 if not found
"""
url = urljoin(self.index_url, "/zones/{0}/details".format(zone_id))
response, data = self.make_request(url, "GET", self.headers, not_found_ok=True, **kwargs)
return data
def get_zone_by_name(self, zone_name, **kwargs):
"""
Gets a zone for the given zone name
@ -445,7 +460,31 @@ class VinylDNSClient(object):
response, data = self.make_request(url, "GET", self.headers, not_found_ok=True, **kwargs)
return data
def list_zones(self, name_filter=None, start_from=None, max_items=None, search_by_admin_group=False, ignore_access=False, **kwargs):
def list_recordset_change_history(self, zone_id, fqdn, record_type, start_from=None, max_items=None, **kwargs):
"""
Gets the record's change history for the given zone, record fqdn and record type
:param zone_id: the id of the zone to retrieve
:param fqdn: the record's fqdn
:param record_type: the record's type
:param start_from: the start key of the page
:param max_items: the page limit
:return: the zone, or will 404 if not found
"""
args = []
if start_from:
args.append("startFrom={0}".format(start_from))
if max_items is not None:
args.append("maxItems={0}".format(max_items))
args.append("zoneId={0}".format(zone_id))
args.append("fqdn={0}".format(fqdn))
args.append("recordType={0}".format(record_type))
url = urljoin(self.index_url, "recordsetchange/history") + "?" + "&".join(args)
response, data = self.make_request(url, "GET", self.headers, not_found_ok=True, **kwargs)
return data
def list_zones(self, name_filter=None, start_from=None, max_items=None, search_by_admin_group=False,
ignore_access=False, **kwargs):
"""
Gets a list of zones that currently exist
:return: a list of zones
@ -522,6 +561,17 @@ class VinylDNSClient(object):
response, data = self.make_request(url, "GET", self.headers, None, not_found_ok=True, **kwargs)
return data
def get_recordset_count(self, zone_id,**kwargs):
"""
Get count of record set in managed records tab
:param zone_id: the zone id the recordset belongs to
:return: the value of count
"""
url = urljoin(self.index_url, "/zones/{0}/recordsetcount".format(zone_id))
response, data = self.make_request(url, "GET", self.headers, None, not_found_ok=True, **kwargs)
return data
def get_recordset_change(self, zone_id, rs_id, change_id, **kwargs):
"""
Gets an existing recordset change
@ -535,7 +585,8 @@ class VinylDNSClient(object):
response, data = self.make_request(url, "GET", self.headers, None, not_found_ok=True, **kwargs)
return data
def list_recordsets_by_zone(self, zone_id, start_from=None, max_items=None, record_name_filter=None, record_type_filter=None, name_sort=None, **kwargs):
def list_recordsets_by_zone(self, zone_id, start_from=None, max_items=None, record_name_filter=None,
record_type_filter=None, name_sort=None, **kwargs):
"""
Retrieves all recordsets in a zone
:param zone_id: the zone to retrieve
@ -618,7 +669,8 @@ class VinylDNSClient(object):
_, data = self.make_request(url, "POST", self.headers, **kwargs)
return data
def list_batch_change_summaries(self, start_from=None, max_items=None, ignore_access=False, approval_status=None, **kwargs):
def list_batch_change_summaries(self, start_from=None, max_items=None, ignore_access=False, approval_status=None,
**kwargs):
"""
Gets list of user's batch change summaries
:return: the content of the response
@ -660,7 +712,8 @@ class VinylDNSClient(object):
:return: the content of the response
"""
url = urljoin(self.index_url, "/zones/{0}/acl/rules".format(zone_id))
response, data = self.make_request(url, "PUT", self.headers, json.dumps(acl_rule), sign_request=sign_request, **kwargs)
response, data = self.make_request(url, "PUT", self.headers, json.dumps(acl_rule), sign_request=sign_request,
**kwargs)
return data
@ -804,16 +857,11 @@ class VinylDNSClient(object):
while change["status"] != expected_status and retries > 0:
time.sleep(RETRY_WAIT)
retries -= 1
latest_change = self.get_recordset_change(change["recordSet"]["zoneId"], change["recordSet"]["id"], change["id"], status=(200, 404))
latest_change = self.get_recordset_change(change["recordSet"]["zoneId"], change["recordSet"]["id"],
change["id"], status=(200, 404))
if type(latest_change) != str:
change = latest_change
if change["status"] != expected_status:
print("Failed waiting for record change status")
print(json.dumps(change, indent=3))
if "systemMessage" in change:
print("systemMessage is " + change["systemMessage"])
assert_that(change["status"], is_(expected_status))
return change
@ -838,10 +886,6 @@ class VinylDNSClient(object):
else:
change = latest_change
if not self.batch_is_completed(change):
print("Failed waiting for record change status")
print(change)
assert_that(self.batch_is_completed(change), is_(True))
return change

View File

@ -127,7 +127,10 @@ vinyldns {
from = ${?EMAIL_FROM}
}
}
valid-email-config{
email-domains = ["test.com","*dummy.com"]
number-of-dots= 2
}
sns {
class-name = "vinyldns.apadi.notifier.sns.SnsNotifierProvider"
class-name = ${?SNS_CLASS_NAME}

View File

@ -16,46 +16,14 @@
package vinyldns.api
import cats.effect._
import cats.implicits._
import vinyldns.api.domain.batch.BatchChangeInterfaces.ValidatedBatch
import vinyldns.api.domain.batch.BatchTransformations.ChangeForValidation
import scala.concurrent.duration._
import org.scalatest.Assertions._
import org.scalatest.matchers.{MatchResult, Matcher}
import scala.concurrent.ExecutionContext
trait CatsHelpers {
private implicit val timer: Timer[IO] = IO.timer(ExecutionContext.global)
private implicit val cs: ContextShift[IO] =
IO.contextShift(scala.concurrent.ExecutionContext.global)
def await[E, T](f: => IO[T], duration: FiniteDuration = 60.seconds): T = {
val i: IO[Either[E, T]] = f.attempt.map {
case Right(ok) => Right(ok.asInstanceOf[T])
case Left(e) => Left(e.asInstanceOf[E])
}
awaitResultOf[E, T](i, duration).toOption.get
}
// Waits for the future to complete, then returns the value as an Either[Throwable, T]
def awaitResultOf[E, T](
f: => IO[Either[E, T]],
duration: FiniteDuration = 60.seconds
): Either[E, T] = {
val timeOut = IO.sleep(duration) *> IO(new RuntimeException("Timed out waiting for result"))
IO.race(timeOut, f).unsafeRunSync().toOption.get
}
// Assumes that the result of the future operation will be successful, this will fail on a left disjunction
def rightResultOf[E, T](f: => IO[Either[E, T]], duration: FiniteDuration = 60.seconds): T =
rightValue(awaitResultOf[E, T](f, duration))
// Assumes that the result of the future operation will fail, this will error on a right disjunction
def leftResultOf[E, T](f: => IO[Either[E, T]], duration: FiniteDuration = 60.seconds): E =
leftValue(awaitResultOf(f, duration))
def leftValue[E, T](t: Either[E, T]): E = t match {
case Right(x) => fail(s"expected left value, got right: $x")

View File

@ -18,54 +18,15 @@ package vinyldns.api
import cats.data.Validated.{Invalid, Valid}
import cats.data.ValidatedNel
import cats.effect._
import cats.implicits._
import cats.scalatest.ValidatedMatchers
import org.scalatest.matchers.should.Matchers
import org.scalatest.propspec.AnyPropSpec
import scala.concurrent.ExecutionContext
import scala.concurrent.duration._
import scala.reflect.ClassTag
final case class TimeoutException(message: String) extends Throwable(message)
trait ResultHelpers {
private implicit val timer: Timer[IO] = IO.timer(ExecutionContext.global)
private implicit val cs: ContextShift[IO] =
IO.contextShift(scala.concurrent.ExecutionContext.global)
def await[T](f: => IO[_], duration: FiniteDuration = 60.seconds): T =
awaitResultOf[T](f.map(_.asInstanceOf[T]).attempt, duration).toOption.get
// Waits for the future to complete, then returns the value as an Either[Throwable, T]
def awaitResultOf[T](
f: => IO[Either[Throwable, T]],
duration: FiniteDuration = 60.seconds
): Either[Throwable, T] = {
val timeOut = IO.sleep(duration) *> IO(
TimeoutException("Timed out waiting for result").asInstanceOf[Throwable]
)
IO.race(timeOut, f.handleError(e => Left(e))).unsafeRunSync() match {
case Left(e) => Left(e)
case Right(ok) => ok
}
}
// Assumes that the result of the future operation will be successful, this will fail on a left disjunction
def rightResultOf[T](f: => IO[Either[Throwable, T]], duration: FiniteDuration = 60.seconds): T =
awaitResultOf[T](f, duration) match {
case Right(result) => result
case Left(error) => throw error
}
// Assumes that the result of the future operation will fail, this will error on a right disjunction
def leftResultOf[T](
f: => IO[Either[Throwable, T]],
duration: FiniteDuration = 60.seconds
): Throwable = awaitResultOf(f, duration).swap.toOption.get
def leftValue[T](t: Either[Throwable, T]): Throwable = t.swap.toOption.get

View File

@ -38,9 +38,9 @@ trait VinylDNSTestHelpers {
val highValueDomainRegexList: List[Regex] = List(new Regex("high-value-domain.*"))
val highValueDomainIpList: List[IpAddress] =
(IpAddress("192.0.2.252") ++ IpAddress("192.0.2.253") ++ IpAddress(
(IpAddress.fromString("192.0.2.252") ++ IpAddress.fromString("192.0.2.253") ++ IpAddress.fromString(
"fd69:27cc:fe91:0:0:0:0:ffff"
) ++ IpAddress(
) ++ IpAddress.fromString(
"fd69:27cc:fe91:0:0:0:ffff:0"
)).toList
@ -58,9 +58,9 @@ trait VinylDNSTestHelpers {
val manualReviewDomainList: List[Regex] = List(new Regex("needs-review.*"))
val manualReviewIpList: List[IpAddress] =
(IpAddress("192.0.2.254") ++ IpAddress("192.0.2.255") ++ IpAddress(
(IpAddress.fromString("192.0.2.254") ++ IpAddress.fromString("192.0.2.255") ++ IpAddress.fromString(
"fd69:27cc:fe91:0:0:0:ffff:1"
) ++ IpAddress("fd69:27cc:fe91:0:0:0:ffff:2")).toList
) ++ IpAddress.fromString("fd69:27cc:fe91:0:0:0:ffff:2")).toList
val manualReviewZoneNameList: Set[String] = Set("zone.needs.review.")
@ -85,7 +85,7 @@ trait VinylDNSTestHelpers {
LimitsConfig(100,100,1000,1500,100,100,100)
val testServerConfig: ServerConfig =
ServerConfig(100, 100, 100, 100, true, approvedNameServers, "blue", "unset", "vinyldns.", false, true, true)
ServerConfig(100, 100, 100, 100, true, approvedNameServers, "blue", "unset", "vinyldns.", false, true, true, true)
val batchChangeConfig: BatchChangeConfig =
BatchChangeConfig(batchChangeLimit, sharedApprovedTypes, v6DiscoveryNibbleBoundaries)

View File

@ -30,6 +30,7 @@ import org.xbill.DNS
import org.xbill.DNS.{Lookup, Name, TSIG}
import vinyldns.api.backend.dns.DnsProtocol._
import vinyldns.core.crypto.{CryptoAlgebra, NoOpCrypto}
import vinyldns.core.domain.Encrypted
import vinyldns.core.domain.backend.BackendResponse
import vinyldns.core.domain.record.RecordType._
import vinyldns.core.domain.record._
@ -47,7 +48,7 @@ class DnsBackendSpec
with EitherValues {
private val zoneConnection =
ZoneConnection("vinyldns.", "vinyldns.", "nzisn+4G2ldMn0q1CV3vsg==", "10.1.1.1")
ZoneConnection("vinyldns.", "vinyldns.", Encrypted("nzisn+4G2ldMn0q1CV3vsg=="), "10.1.1.1")
private val testZone = Zone("vinyldns", "test@test.com")
private val testA = RecordSet(
testZone.id,

View File

@ -21,6 +21,7 @@ import org.scalatest.BeforeAndAfterAll
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpec
import vinyldns.api.backend.dns.DnsBackendProviderConfig
import vinyldns.core.domain.Encrypted
import vinyldns.core.domain.zone.ZoneConnection
import vinyldns.core.repository.RepositoryName._
@ -68,7 +69,7 @@ class VinylDNSConfigSpec extends AnyWordSpec with Matchers with BeforeAndAfterAl
}
"load specified backends" in {
val zc = ZoneConnection("vinyldns.", "vinyldns.", "nzisn+4G2ldMn0q1CV3vsg==", sys.env.getOrElse("DEFAULT_DNS_ADDRESS", "127.0.0.1:19001"))
val zc = ZoneConnection("vinyldns.", "vinyldns.", Encrypted("nzisn+4G2ldMn0q1CV3vsg=="), sys.env.getOrElse("DEFAULT_DNS_ADDRESS", "127.0.0.1:19001"))
val tc = zc.copy()
val backends = underTest.backendConfigs.backendProviders

View File

@ -22,7 +22,7 @@ import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks
import org.scalatest.propspec.AnyPropSpec
import org.scalatest.matchers.should.Matchers
import vinyldns.api.ValidationTestImprovements._
import vinyldns.core.domain.{InvalidDomainName, InvalidCname, InvalidLength}
import vinyldns.core.domain.{InvalidDomainName, Fqdn, InvalidCname, InvalidLength}
class DomainValidationsSpec
extends AnyPropSpec
@ -77,6 +77,54 @@ class DomainValidationsSpec
validateHostName("asterisk.domain*.name.") shouldBe invalid
}
property("Shortest fqdn name should be valid") {
val fqdn = Fqdn("a.")
validateCname(fqdn, false) shouldBe valid
}
property("Ip address in cname should be invalid") {
val fqdn = Fqdn("1.2.3.4")
validateCname(fqdn, false) shouldBe invalid
}
property("Longest fqdn name should be valid") {
val fqdn = Fqdn(("a" * 50 + ".") * 5)
validateCname(fqdn, false) shouldBe valid
}
property("fqdn name should pass property-based testing") {
forAll(domainGenerator) { domain: String =>
val domains= Fqdn(domain)
whenever(validateHostName(domains).isValid) {
domains.fqdn.length should be > 0
domains.fqdn.length should be < 256
(domains.fqdn should fullyMatch).regex(validFQDNRegex)
domains.fqdn should endWith(".")
}
}
}
property("fqdn names beginning with invalid characters should fail with InvalidCname") {
validateCname(Fqdn("/slash.domain.name."), false).failWith[InvalidCname]
validateCname(Fqdn("-hyphen.domain.name."), false).failWith[InvalidCname]
}
property("fqdn names with underscores should pass property-based testing") {
validateCname(Fqdn("_underscore.domain.name."), false).isValid
validateCname(Fqdn("under_score.domain.name."), false).isValid
validateCname(Fqdn("underscore._domain.name."), false).isValid
}
// For wildcard records. '*' can only be in the beginning followed by '.' and domain name
property("fqdn names beginning with asterisk should pass property-based testing") {
validateCname(Fqdn("*.domain.name."), false) shouldBe valid
validateCname(Fqdn("aste*risk.domain.name."),false) shouldBe invalid
validateCname(Fqdn("*asterisk.domain.name."),false) shouldBe invalid
validateCname(Fqdn("asterisk*.domain.name."),false) shouldBe invalid
validateCname(Fqdn("asterisk.*domain.name."),false)shouldBe invalid
validateCname(Fqdn("asterisk.domain*.name."),false) shouldBe invalid
}
property("Valid Ipv4 addresses should pass property-based testing") {
forAll(validIpv4Gen) { validIp: String =>
val res = validateIpv4Address(validIp)
@ -112,50 +160,50 @@ class DomainValidationsSpec
}
property("Shortest cname should be valid") {
validateCname("a.",true) shouldBe valid
validateCname("a.",false) shouldBe valid
validateIsReverseCname("a.",true) shouldBe valid
validateIsReverseCname("a.",false) shouldBe valid
}
property("Longest cname should be valid") {
val name = ("a" * 50 + ".") * 5
validateCname(name,true) shouldBe valid
validateCname(name,false) shouldBe valid
validateIsReverseCname(name,true) shouldBe valid
validateIsReverseCname(name,false) shouldBe valid
}
property("Cnames with underscores should pass property-based testing") {
validateCname("_underscore.domain.name.",true).isValid
validateCname("under_score.domain.name.",true).isValid
validateCname("underscore._domain.name.",true).isValid
validateCname("_underscore.domain.name.",false).isValid
validateCname("under_score.domain.name.",false).isValid
validateCname("underscore._domain.name.",false).isValid
validateIsReverseCname("_underscore.domain.name.",true).isValid
validateIsReverseCname("under_score.domain.name.",true).isValid
validateIsReverseCname("underscore._domain.name.",true).isValid
validateIsReverseCname("_underscore.domain.name.",false).isValid
validateIsReverseCname("under_score.domain.name.",false).isValid
validateIsReverseCname("underscore._domain.name.",false).isValid
}
// For wildcard records. '*' can only be in the beginning followed by '.' and domain name
property("Cnames beginning with asterisk should pass property-based testing") {
validateCname("*.domain.name.",true) shouldBe valid
validateCname("aste*risk.domain.name.",true) shouldBe invalid
validateCname("*asterisk.domain.name.",true) shouldBe invalid
validateCname("asterisk*.domain.name.",true) shouldBe invalid
validateCname("asterisk.*domain.name.",true) shouldBe invalid
validateCname("asterisk.domain*.name.",true) shouldBe invalid
validateCname("*.domain.name.",false) shouldBe valid
validateCname("aste*risk.domain.name.",false) shouldBe invalid
validateCname("*asterisk.domain.name.",false) shouldBe invalid
validateCname("asterisk*.domain.name.",false) shouldBe invalid
validateCname("asterisk.*domain.name.",false) shouldBe invalid
validateCname("asterisk.domain*.name.",false) shouldBe invalid
validateIsReverseCname("*.domain.name.",true) shouldBe valid
validateIsReverseCname("aste*risk.domain.name.",true) shouldBe invalid
validateIsReverseCname("*asterisk.domain.name.",true) shouldBe invalid
validateIsReverseCname("asterisk*.domain.name.",true) shouldBe invalid
validateIsReverseCname("asterisk.*domain.name.",true) shouldBe invalid
validateIsReverseCname("asterisk.domain*.name.",true) shouldBe invalid
validateIsReverseCname("*.domain.name.",false) shouldBe valid
validateIsReverseCname("aste*risk.domain.name.",false) shouldBe invalid
validateIsReverseCname("*asterisk.domain.name.",false) shouldBe invalid
validateIsReverseCname("asterisk*.domain.name.",false) shouldBe invalid
validateIsReverseCname("asterisk.*domain.name.",false) shouldBe invalid
validateIsReverseCname("asterisk.domain*.name.",false) shouldBe invalid
}
property("Cname names with forward slash should pass with reverse zone") {
validateCname("/slash.cname.name.",true).isValid
validateCname("slash./cname.name.",true).isValid
validateCname("slash.cname./name.",true).isValid
validateIsReverseCname("/slash.cname.name.",true).isValid
validateIsReverseCname("slash./cname.name.",true).isValid
validateIsReverseCname("slash.cname./name.",true).isValid
}
property("Cname names with forward slash should fail with forward zone") {
validateCname("/slash.cname.name.",false).failWith[InvalidCname]
validateCname("slash./cname.name.",false).failWith[InvalidCname]
validateCname("slash.cname./name.",false).failWith[InvalidCname]
validateIsReverseCname("/slash.cname.name.",false).failWith[InvalidCname]
validateIsReverseCname("slash./cname.name.",false).failWith[InvalidCname]
validateIsReverseCname("slash.cname./name.",false).failWith[InvalidCname]
}
}

View File

@ -137,8 +137,8 @@ class ReverseZoneHelpersSpec
}
"recordsetIsWithinCidrMask" should {
"when testing IPv4" should {
"filter in/out record set based on CIDR rule of 0 (lower bound for ip4 CIDR rules)" in {
val mask = "120.1.2.0/0"
"filter in/out record set based on CIDR rule of 1 (lower bound for ip4 CIDR rules)" in {
val mask = "120.1.2.0/1"
val znTrue = Zone("40.120.in-addr.arpa.", "email")
val rsTrue =
RecordSet("id", "20.3", RecordType.PTR, 200, RecordSetStatus.Active, Instant.now.truncatedTo(ChronoUnit.MILLIS))
@ -151,7 +151,7 @@ class ReverseZoneHelpersSpec
}
"filter in/out record set based on CIDR rule of 8" in {
val mask = "10.10.32/19"
val mask = "10.10.32.0/19"
val zone = Zone("10.10.in-addr.arpa.", "email")
val recordSet =
RecordSet("id", "90.44", RecordType.PTR, 200, RecordSetStatus.Active, Instant.now.truncatedTo(ChronoUnit.MILLIS))

View File

@ -26,7 +26,7 @@ import vinyldns.api.domain.zone.{NotAuthorizedError, RecordSetInfo, RecordSetLis
import vinyldns.core.TestMembershipData._
import vinyldns.core.TestRecordSetData._
import vinyldns.core.TestZoneData._
import vinyldns.core.domain.Fqdn
import vinyldns.core.domain.{Encrypted, Fqdn}
import vinyldns.core.domain.auth.AuthPrincipal
import vinyldns.core.domain.membership.User
import vinyldns.core.domain.record._
@ -95,7 +95,7 @@ class AccessValidationsSpec
VinylDNSTestHelpers.sharedApprovedTypes
)
private val testUser = User("test", "test", "test", isTest = true)
private val testUser = User("test", "test", Encrypted("test"), isTest = true)
"canSeeZone" should {
"return a NotAuthorizedError if the user is not admin or super user with no acl rules" in {
@ -131,6 +131,41 @@ class AccessValidationsSpec
}
}
"canSeeZoneChange" should {
"return a NotAuthorizedError if the user is not admin or super user with no acl rules" in {
val error = leftValue(accessValidationTest.canSeeZoneChange(okAuth, zoneNotAuthorized))
error shouldBe a[NotAuthorizedError]
}
"return true if the user is an admin or super user" in {
val auth = okAuth.copy(
signedInUser = okAuth.signedInUser.copy(isSuper = true),
memberGroupIds = Seq.empty
)
accessValidationTest.canSeeZoneChange(auth, okZone) should be(right)
}
"return false if there is an acl rule for the user in the zone" in {
val rule = ACLRule(AccessLevel.Read, userId = Some(okAuth.userId))
val zoneIn = zoneNotAuthorized.copy(acl = ZoneACL(Set(rule)))
val error = leftValue(accessValidationTest.canSeeZoneChange(okAuth, zoneIn))
error shouldBe a[NotAuthorizedError]
}
"return true if the user is a support admin" in {
val supportAuth = okAuth.copy(
signedInUser = okAuth.signedInUser.copy(isSupport = true),
memberGroupIds = Seq.empty
)
accessValidationTest.canSeeZone(supportAuth, okZone) should be(right)
}
"return true if the zone is shared and user does not have other access" in {
accessValidationTest.canSeeZone(okAuth, sharedZone) should be(right)
}
}
"canChangeZone" should {
"return a NotAuthorizedError if the user is not admin or super user" in {
val error = leftValue(
@ -255,6 +290,7 @@ class AccessValidationsSpec
) should be(right)
}
}
"canUpdateRecordSet" should {
"return a NotAuthorizedError if the user has AccessLevel.NoAccess" in {
val error = leftValue(
@ -292,6 +328,13 @@ class AccessValidationsSpec
)
}
"return true if the user has AccessLevel.Read or AccessLevel.NoAccess and superUserCanUpdateOwnerGroup is true" in {
accessValidationTest.canUpdateRecordSet(userAuthRead, "test", RecordType.A, zoneInRead,
None, superUserCanUpdateOwnerGroup = true) should be(right)
accessValidationTest.canUpdateRecordSet(userAuthNone, "test", RecordType.A, zoneInNone,
None, superUserCanUpdateOwnerGroup = true) should be(right)
}
"return true if the user is in the owner group and the zone is shared" in {
val zone = okZone.copy(shared = true)
val record = aaaa.copy(zoneId = zone.id, ownerGroupId = Some(oneUserDummyGroup.id))
@ -365,7 +408,7 @@ class AccessValidationsSpec
RecordType.PTR,
zoneIp4,
None,
List(PTRData(Fqdn("test.foo.comcast.net")))
newRecordData = List(PTRData(Fqdn("test.foo.comcast.net")))
) should be(right)
}
}
@ -957,8 +1000,8 @@ class AccessValidationsSpec
"ruleAppliesToRecordNameIPv4" should {
"filter in/out record set based on CIDR rule of 0 (lower bound for ip4 CIDR rules)" in {
val aclRule = userReadAcl.copy(recordMask = Some("120.1.2.0/0"))
"filter in/out record set based on CIDR rule of 1 (lower bound for ip4 CIDR rules)" in {
val aclRule = userReadAcl.copy(recordMask = Some("120.1.2.0/1"))
val znTrue = Zone("40.120.in-addr.arpa.", "email")
val rsTrue =
RecordSet("id", "20.3", RecordType.PTR, 200, RecordSetStatus.Active, Instant.now.truncatedTo(ChronoUnit.MILLIS))

Some files were not shown because too many files have changed in this diff Show More