diff --git a/doorman/build.gradle b/doorman/build.gradle index 457d845d87..b78e7ce979 100644 --- a/doorman/build.gradle +++ b/doorman/build.gradle @@ -68,4 +68,11 @@ dependencies { testCompile 'junit:junit:4.12' testCompile "org.assertj:assertj-core:${assertj_version}" testCompile "com.nhaarman:mockito-kotlin:0.6.1" + + compile ('com.atlassian.jira:jira-rest-java-client-core:4.0.0'){ + // The jira client includes jersey-core 1.5 which breaks everything. + exclude module: 'jersey-core' + } + // Needed by jira rest client + compile "com.atlassian.fugue:fugue:2.6.1" } diff --git a/doorman/src/main/kotlin/com/r3/corda/doorman/DoormanParameters.kt b/doorman/src/main/kotlin/com/r3/corda/doorman/DoormanParameters.kt index adee96471f..805dc10d06 100644 --- a/doorman/src/main/kotlin/com/r3/corda/doorman/DoormanParameters.kt +++ b/doorman/src/main/kotlin/com/r3/corda/doorman/DoormanParameters.kt @@ -1,6 +1,7 @@ package com.r3.corda.doorman import com.r3.corda.doorman.OptionParserHelper.toConfigWithOptions +import com.typesafe.config.Config import net.corda.core.div import net.corda.node.services.config.ConfigHelper import net.corda.node.services.config.getOrElse @@ -14,7 +15,6 @@ class DoormanParameters(args: Array<String>) { accepts("basedir", "Overriding configuration filepath, default to current directory.").withRequiredArg().describedAs("filepath") accepts("keygen", "Generate CA keypair and certificate using provide Root CA key.").withOptionalArg() accepts("rootKeygen", "Generate Root CA keypair and certificate.").withOptionalArg() - accepts("approveAll", "Approve all certificate signing request.").withOptionalArg() accepts("keystorePath", "CA keystore filepath, default to [basedir]/certificates/caKeystore.jks.").withRequiredArg().describedAs("filepath") accepts("rootStorePath", "Root CA keystore filepath, default to [basedir]/certificates/rootCAKeystore.jks.").withRequiredArg().describedAs("filepath") accepts("keystorePassword", "CA keystore password.").withRequiredArg().describedAs("password") @@ -32,10 +32,10 @@ class DoormanParameters(args: Array<String>) { val caPrivateKeyPassword: String? by config.getOrElse { null } val rootKeystorePassword: String? by config.getOrElse { null } val rootPrivateKeyPassword: String? by config.getOrElse { null } - val approveAll: Boolean by config.getOrElse { false } val host: String by config val port: Int by config - val dataSourceProperties: Properties by config + val dataSourceProperties: Properties by config + val jiraConfig = if (config.hasPath("jiraConfig")) JiraConfig(config.getConfig("jiraConfig")) else null private val keygen: Boolean by config.getOrElse { false } private val rootKeygen: Boolean by config.getOrElse { false } @@ -44,6 +44,12 @@ class DoormanParameters(args: Array<String>) { enum class Mode { DOORMAN, CA_KEYGEN, ROOT_KEYGEN } + + class JiraConfig(config: Config) { + val address: String by config + val projectCode: String by config + val username: String by config + val password: String by config + val doneTransitionCode: Int by config + } } - - diff --git a/doorman/src/main/kotlin/com/r3/corda/doorman/Main.kt b/doorman/src/main/kotlin/com/r3/corda/doorman/Main.kt index bd51d30a75..93e0b6ec89 100644 --- a/doorman/src/main/kotlin/com/r3/corda/doorman/Main.kt +++ b/doorman/src/main/kotlin/com/r3/corda/doorman/Main.kt @@ -1,9 +1,11 @@ package com.r3.corda.doorman +import com.atlassian.jira.rest.client.internal.async.AsynchronousJiraRestClientFactory import com.google.common.net.HostAndPort import com.r3.corda.doorman.DoormanServer.Companion.logger import com.r3.corda.doorman.persistence.CertificationRequestStorage import com.r3.corda.doorman.persistence.DBCertificateRequestStorage +import com.r3.corda.doorman.persistence.JiraCertificateRequestStorage import net.corda.core.createDirectories import net.corda.core.crypto.X509Utilities import net.corda.core.crypto.X509Utilities.CACertAndKey @@ -13,6 +15,7 @@ import net.corda.core.crypto.X509Utilities.CORDA_ROOT_CA import net.corda.core.crypto.X509Utilities.CORDA_ROOT_CA_PRIVATE_KEY import net.corda.core.crypto.X509Utilities.addOrReplaceKey import net.corda.core.crypto.X509Utilities.createIntermediateCert +import net.corda.core.crypto.X509Utilities.createServerCert import net.corda.core.crypto.X509Utilities.loadCertificateAndKey import net.corda.core.crypto.X509Utilities.loadKeyStore import net.corda.core.crypto.X509Utilities.loadOrCreateKeyStore @@ -31,6 +34,7 @@ import org.glassfish.jersey.servlet.ServletContainer import java.io.Closeable import java.lang.Thread.sleep import java.net.InetSocketAddress +import java.net.URI import java.security.cert.Certificate import kotlin.concurrent.thread import kotlin.system.exitProcess @@ -159,22 +163,33 @@ private fun DoormanParameters.startDoorman() { val caCertAndKey = X509Utilities.loadCertificateAndKey(keystore, caPrivateKeyPassword, CORDA_INTERMEDIATE_CA_PRIVATE_KEY) // Create DB connection. val (datasource, database) = configureDatabase(dataSourceProperties) - val storage = DBCertificateRequestStorage(database) - // Daemon thread approving all request periodically. - if (approveAll) { - thread(name = "Request Approval Daemon", isDaemon = true) { - logger.warn("Doorman server is in 'Approve All' mode, this will approve all incoming certificate signing request.") - while (true) { - sleep(10.seconds.toMillis()) - for (id in storage.getPendingRequestIds()) { - storage.approveRequest(id, { - JcaPKCS10CertificationRequest(it.request).run { - X509Utilities.createServerCert(subject, publicKey, caCertAndKey, - if (it.ipAddress == it.hostName) listOf() else listOf(it.hostName), listOf(it.ipAddress)) - } - }) - logger.info("Approved request $id") + + val requestStorage = DBCertificateRequestStorage(database) + + val storage = if (jiraConfig == null) { + logger.warn("Doorman server is in 'Approve All' mode, this will approve all incoming certificate signing request.") + // Approve all pending request. + object : CertificationRequestStorage by requestStorage { + // The doorman is in approve all mode, returns all pending request id as approved request id. + override fun getApprovedRequestIds() = getPendingRequestIds() + } + } else { + val jiraClient = AsynchronousJiraRestClientFactory().createWithBasicHttpAuthentication(URI(jiraConfig.address), jiraConfig.username, jiraConfig.password) + JiraCertificateRequestStorage(requestStorage, jiraClient, jiraConfig.projectCode, jiraConfig.doneTransitionCode) + } + + // Daemon thread approving request periodically. + thread(name = "Request Approval Daemon") { + while (true) { + sleep(10.seconds.toMillis()) + // TODO: Handle rejected request? + for (id in storage.getApprovedRequestIds()) { + storage.approveRequest(id) { + val request = JcaPKCS10CertificationRequest(request) + createServerCert(request.subject, request.publicKey, caCertAndKey, + if (ipAddress == hostName) listOf() else listOf(hostName), listOf(ipAddress)) } + logger.info("Approved request $id") } } } diff --git a/doorman/src/main/kotlin/com/r3/corda/doorman/persistence/CertificationRequestStorage.kt b/doorman/src/main/kotlin/com/r3/corda/doorman/persistence/CertificationRequestStorage.kt index 0e9db59c0f..3f63756f20 100644 --- a/doorman/src/main/kotlin/com/r3/corda/doorman/persistence/CertificationRequestStorage.kt +++ b/doorman/src/main/kotlin/com/r3/corda/doorman/persistence/CertificationRequestStorage.kt @@ -26,7 +26,7 @@ interface CertificationRequestStorage { /** * Approve the given request by generating and storing a new certificate using the provided generator. */ - fun approveRequest(requestId: String, certificateGenerator: (CertificationRequestData) -> Certificate) + fun approveRequest(requestId: String, generateCertificate: CertificationRequestData.() -> Certificate) /** * Reject the given request using the given reason. @@ -35,9 +35,13 @@ interface CertificationRequestStorage { /** * Retrieve list of request IDs waiting for approval. - * TODO : This is used for the background thread to approve request automatically without KYC checks, should be removed after testnet. */ fun getPendingRequestIds(): List<String> + + /** + * Retrieve list of approved request IDs. + */ + fun getApprovedRequestIds(): List<String> } data class CertificationRequestData(val hostName: String, val ipAddress: String, val request: PKCS10CertificationRequest) diff --git a/doorman/src/main/kotlin/com/r3/corda/doorman/persistence/DBCertificateRequestStorage.kt b/doorman/src/main/kotlin/com/r3/corda/doorman/persistence/DBCertificateRequestStorage.kt index 1bd0809655..62282b1254 100644 --- a/doorman/src/main/kotlin/com/r3/corda/doorman/persistence/DBCertificateRequestStorage.kt +++ b/doorman/src/main/kotlin/com/r3/corda/doorman/persistence/DBCertificateRequestStorage.kt @@ -83,13 +83,13 @@ class DBCertificateRequestStorage(private val database: Database) : Certificatio } } - override fun approveRequest(requestId: String, certificateGenerator: (CertificationRequestData) -> Certificate) { + override fun approveRequest(requestId: String, generateCertificate: CertificationRequestData.() -> Certificate) { databaseTransaction(database) { val request = singleRequestWhere { DataTable.requestId eq requestId and DataTable.processTimestamp.isNull() } if (request != null) { withFinalizables { finalizables -> DataTable.update({ DataTable.requestId eq requestId }) { - it[certificate] = serializeToBlob(certificateGenerator(request), finalizables) + it[certificate] = serializeToBlob(request.generateCertificate(), finalizables) it[processTimestamp] = Instant.now() } } @@ -121,6 +121,8 @@ class DBCertificateRequestStorage(private val database: Database) : Certificatio } } + override fun getApprovedRequestIds(): List<String> = emptyList() + private fun singleRequestWhere(where: SqlExpressionBuilder.() -> Op<Boolean>): CertificationRequestData? { return DataTable .select(where) diff --git a/doorman/src/main/kotlin/com/r3/corda/doorman/persistence/JiraCertificateRequestStorage.kt b/doorman/src/main/kotlin/com/r3/corda/doorman/persistence/JiraCertificateRequestStorage.kt new file mode 100644 index 0000000000..bca5678d43 --- /dev/null +++ b/doorman/src/main/kotlin/com/r3/corda/doorman/persistence/JiraCertificateRequestStorage.kt @@ -0,0 +1,76 @@ +package com.r3.corda.doorman.persistence + +import com.atlassian.jira.rest.client.api.JiraRestClient +import com.atlassian.jira.rest.client.api.domain.Field +import com.atlassian.jira.rest.client.api.domain.IssueType +import com.atlassian.jira.rest.client.api.domain.input.IssueInputBuilder +import com.atlassian.jira.rest.client.api.domain.input.TransitionInput +import net.corda.core.crypto.X509Utilities +import net.corda.core.crypto.commonName +import net.corda.core.utilities.loggerFor +import org.bouncycastle.asn1.x500.style.BCStyle +import org.bouncycastle.openssl.jcajce.JcaPEMWriter +import org.bouncycastle.util.io.pem.PemObject +import java.io.StringWriter +import java.security.cert.Certificate + +class JiraCertificateRequestStorage(val delegate: CertificationRequestStorage, + val jiraClient: JiraRestClient, + val projectCode: String, + val doneTransitionCode: Int) : CertificationRequestStorage by delegate { + private enum class Status { + Approved, Rejected + } + + companion object { + private val logger = loggerFor<JiraCertificateRequestStorage>() + } + + // The JIRA project must have a Request ID field and the Task issue type. + private val requestIdField: Field = jiraClient.metadataClient.fields.claim().find { it.name == "Request ID" }!! + private val taskIssueType: IssueType = jiraClient.metadataClient.issueTypes.claim().find { it.name == "Task" }!! + + override fun saveRequest(certificationData: CertificationRequestData): String { + val requestId = delegate.saveRequest(certificationData) + // Make sure request has been accepted. + val response = getResponse(requestId) + if (response !is CertificateResponse.Unauthorised) { + val request = StringWriter() + JcaPEMWriter(request).use { + it.writeObject(PemObject("CERTIFICATE REQUEST", certificationData.request.encoded)) + } + val commonName = certificationData.request.subject.commonName + val email = certificationData.request.subject.getRDNs(BCStyle.EmailAddress).firstOrNull()?.first?.value + val nearestCity = certificationData.request.subject.getRDNs(BCStyle.L).firstOrNull()?.first?.value + + val issue = IssueInputBuilder().setIssueTypeId(taskIssueType.id) + .setProjectKey(projectCode) + .setDescription("Legal Name: $commonName\nNearest City: $nearestCity\nEmail: $email\n\n{code}$request{code}") + .setSummary(commonName) + .setFieldValue(requestIdField.id, requestId) + // This will block until the issue is created. + jiraClient.issueClient.createIssue(issue.build()).fail { logger.error("Exception when creating JIRA issue.", it) }.claim() + } + return requestId + } + + override fun approveRequest(requestId: String, generateCertificate: CertificationRequestData.() -> Certificate) { + delegate.approveRequest(requestId, generateCertificate) + // Certificate should be created, retrieving it to attach to the jira task. + val certificate = (getResponse(requestId) as? CertificateResponse.Ready)?.certificate + // Jira only support ~ (contains) search for custom textfield. + val issue = jiraClient.searchClient.searchJql("'Request ID' ~ $requestId").claim().issues.firstOrNull() + if (issue != null) { + jiraClient.issueClient.transition(issue, TransitionInput(doneTransitionCode)).fail { logger.error("Exception when transiting JIRA status.", it) }.claim() + jiraClient.issueClient.addAttachment(issue.attachmentsUri, certificate?.encoded?.inputStream(), "${X509Utilities.CORDA_CLIENT_CA}.cer") + .fail { logger.error("Exception when uploading attachment to JIRA.", it) }.claim() + } + } + + override fun getApprovedRequestIds(): List<String> = getRequestByStatus(Status.Approved) + + private fun getRequestByStatus(status: Status): List<String> { + val issues = jiraClient.searchClient.searchJql("project = $projectCode AND status = $status").claim().issues + return issues.map { it.getField(requestIdField.id)?.value?.toString() }.filterNotNull() + } +} diff --git a/doorman/src/main/resources/reference.conf b/doorman/src/main/resources/reference.conf index 446362dba0..dcf1ccff1c 100644 --- a/doorman/src/main/resources/reference.conf +++ b/doorman/src/main/resources/reference.conf @@ -3,7 +3,6 @@ port = 0 keystorePath = ${basedir}"/certificates/caKeystore.jks" keystorePassword = "password" caPrivateKeyPassword = "password" -approveAll = true dataSourceProperties { dataSourceClassName = org.h2.jdbcx.JdbcDataSource @@ -11,4 +10,12 @@ dataSourceProperties { "dataSource.user" = sa "dataSource.password" = "" } -h2port = 0 \ No newline at end of file +h2port = 0 + +jiraConfig{ + address = "https://doorman-jira-host/" + projectCode = "TD" + username = "username" + password = "password" + doneTransitionCode = 41 +} diff --git a/doorman/src/test/kotlin/com/r3/corda/doorman/DoormanParametersTest.kt b/doorman/src/test/kotlin/com/r3/corda/doorman/DoormanParametersTest.kt index 7767d010c0..2f53e80e02 100644 --- a/doorman/src/test/kotlin/com/r3/corda/doorman/DoormanParametersTest.kt +++ b/doorman/src/test/kotlin/com/r3/corda/doorman/DoormanParametersTest.kt @@ -7,11 +7,10 @@ import kotlin.test.assertTrue class DoormanParametersTest { @Test fun `parse arg correctly`() { - val params = DoormanParameters(arrayOf("--keygen", "--keystorePath", "./testDummyPath.jks", "--approveAll")) + val params = DoormanParameters(arrayOf("--keygen", "--keystorePath", "./testDummyPath.jks")) assertEquals(DoormanParameters.Mode.CA_KEYGEN, params.mode) assertEquals("./testDummyPath.jks", params.keystorePath.toString()) assertEquals(0, params.port) - assertTrue(params.approveAll) val params2 = DoormanParameters(arrayOf("--keystorePath", "./testDummyPath.jks", "--port", "1000")) assertEquals(DoormanParameters.Mode.DOORMAN, params2.mode) diff --git a/doorman/src/test/kotlin/com/r3/corda/doorman/DoormanServiceTest.kt b/doorman/src/test/kotlin/com/r3/corda/doorman/DoormanServiceTest.kt index 6ad0fb07d4..314ba29945 100644 --- a/doorman/src/test/kotlin/com/r3/corda/doorman/DoormanServiceTest.kt +++ b/doorman/src/test/kotlin/com/r3/corda/doorman/DoormanServiceTest.kt @@ -86,9 +86,9 @@ class DoormanServiceTest { assertThat(pollForResponse(id)).isEqualTo(PollResponse.NotReady) storage.approveRequest(id) { - JcaPKCS10CertificationRequest(it.request).run { + JcaPKCS10CertificationRequest(request).run { X509Utilities.createServerCert(subject, publicKey, intermediateCA, - if (it.ipAddress == it.hostName) listOf() else listOf(it.hostName), listOf(it.ipAddress)) + if (ipAddress == hostName) listOf() else listOf(hostName), listOf(ipAddress)) } } diff --git a/doorman/src/test/kotlin/com/r3/corda/doorman/internal/persistence/DBCertificateRequestStorageTest.kt b/doorman/src/test/kotlin/com/r3/corda/doorman/internal/persistence/DBCertificateRequestStorageTest.kt index 6a84306b33..6b0593441a 100644 --- a/doorman/src/test/kotlin/com/r3/corda/doorman/internal/persistence/DBCertificateRequestStorageTest.kt +++ b/doorman/src/test/kotlin/com/r3/corda/doorman/internal/persistence/DBCertificateRequestStorageTest.kt @@ -133,14 +133,14 @@ class DBCertificateRequestStorageTest { private fun approveRequest(requestId: String) { storage.approveRequest(requestId) { - JcaPKCS10CertificationRequest(it.request).run { + JcaPKCS10CertificationRequest(request).run { X509Utilities.createServerCert( subject, publicKey, intermediateCA, - if (it.ipAddress == it.hostName) listOf() else listOf(it.hostName), - listOf(it.ipAddress)) + if (ipAddress == hostName) listOf() else listOf(hostName), + listOf(ipAddress)) } } } -} \ No newline at end of file +}