Merged in pat-doorman-jira (pull request #19)

Doorman-Jira integration

Approved-by: Shams Asari
This commit is contained in:
Patrick Kuo 2017-02-16 17:50:57 +00:00
commit 638bfcd131
10 changed files with 150 additions and 34 deletions

View File

@ -68,4 +68,11 @@ dependencies {
testCompile 'junit:junit:4.12' testCompile 'junit:junit:4.12'
testCompile "org.assertj:assertj-core:${assertj_version}" testCompile "org.assertj:assertj-core:${assertj_version}"
testCompile "com.nhaarman:mockito-kotlin:0.6.1" 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"
} }

View File

@ -1,6 +1,7 @@
package com.r3.corda.doorman package com.r3.corda.doorman
import com.r3.corda.doorman.OptionParserHelper.toConfigWithOptions import com.r3.corda.doorman.OptionParserHelper.toConfigWithOptions
import com.typesafe.config.Config
import net.corda.core.div import net.corda.core.div
import net.corda.node.services.config.ConfigHelper import net.corda.node.services.config.ConfigHelper
import net.corda.node.services.config.getOrElse 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("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("keygen", "Generate CA keypair and certificate using provide Root CA key.").withOptionalArg()
accepts("rootKeygen", "Generate Root CA keypair and certificate.").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("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("rootStorePath", "Root CA keystore filepath, default to [basedir]/certificates/rootCAKeystore.jks.").withRequiredArg().describedAs("filepath")
accepts("keystorePassword", "CA keystore password.").withRequiredArg().describedAs("password") 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 caPrivateKeyPassword: String? by config.getOrElse { null }
val rootKeystorePassword: String? by config.getOrElse { null } val rootKeystorePassword: String? by config.getOrElse { null }
val rootPrivateKeyPassword: 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 host: String by config
val port: Int 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 keygen: Boolean by config.getOrElse { false }
private val rootKeygen: 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 { enum class Mode {
DOORMAN, CA_KEYGEN, ROOT_KEYGEN 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
}
} }

View File

@ -1,9 +1,11 @@
package com.r3.corda.doorman package com.r3.corda.doorman
import com.atlassian.jira.rest.client.internal.async.AsynchronousJiraRestClientFactory
import com.google.common.net.HostAndPort import com.google.common.net.HostAndPort
import com.r3.corda.doorman.DoormanServer.Companion.logger import com.r3.corda.doorman.DoormanServer.Companion.logger
import com.r3.corda.doorman.persistence.CertificationRequestStorage import com.r3.corda.doorman.persistence.CertificationRequestStorage
import com.r3.corda.doorman.persistence.DBCertificateRequestStorage import com.r3.corda.doorman.persistence.DBCertificateRequestStorage
import com.r3.corda.doorman.persistence.JiraCertificateRequestStorage
import net.corda.core.createDirectories import net.corda.core.createDirectories
import net.corda.core.crypto.X509Utilities import net.corda.core.crypto.X509Utilities
import net.corda.core.crypto.X509Utilities.CACertAndKey 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.CORDA_ROOT_CA_PRIVATE_KEY
import net.corda.core.crypto.X509Utilities.addOrReplaceKey import net.corda.core.crypto.X509Utilities.addOrReplaceKey
import net.corda.core.crypto.X509Utilities.createIntermediateCert 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.loadCertificateAndKey
import net.corda.core.crypto.X509Utilities.loadKeyStore import net.corda.core.crypto.X509Utilities.loadKeyStore
import net.corda.core.crypto.X509Utilities.loadOrCreateKeyStore import net.corda.core.crypto.X509Utilities.loadOrCreateKeyStore
@ -31,6 +34,7 @@ import org.glassfish.jersey.servlet.ServletContainer
import java.io.Closeable import java.io.Closeable
import java.lang.Thread.sleep import java.lang.Thread.sleep
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.net.URI
import java.security.cert.Certificate import java.security.cert.Certificate
import kotlin.concurrent.thread import kotlin.concurrent.thread
import kotlin.system.exitProcess import kotlin.system.exitProcess
@ -159,22 +163,33 @@ private fun DoormanParameters.startDoorman() {
val caCertAndKey = X509Utilities.loadCertificateAndKey(keystore, caPrivateKeyPassword, CORDA_INTERMEDIATE_CA_PRIVATE_KEY) val caCertAndKey = X509Utilities.loadCertificateAndKey(keystore, caPrivateKeyPassword, CORDA_INTERMEDIATE_CA_PRIVATE_KEY)
// Create DB connection. // Create DB connection.
val (datasource, database) = configureDatabase(dataSourceProperties) val (datasource, database) = configureDatabase(dataSourceProperties)
val storage = DBCertificateRequestStorage(database)
// Daemon thread approving all request periodically. val requestStorage = DBCertificateRequestStorage(database)
if (approveAll) {
thread(name = "Request Approval Daemon", isDaemon = true) { val storage = if (jiraConfig == null) {
logger.warn("Doorman server is in 'Approve All' mode, this will approve all incoming certificate signing request.") logger.warn("Doorman server is in 'Approve All' mode, this will approve all incoming certificate signing request.")
while (true) { // Approve all pending request.
sleep(10.seconds.toMillis()) object : CertificationRequestStorage by requestStorage {
for (id in storage.getPendingRequestIds()) { // The doorman is in approve all mode, returns all pending request id as approved request id.
storage.approveRequest(id, { override fun getApprovedRequestIds() = getPendingRequestIds()
JcaPKCS10CertificationRequest(it.request).run { }
X509Utilities.createServerCert(subject, publicKey, caCertAndKey, } else {
if (it.ipAddress == it.hostName) listOf() else listOf(it.hostName), listOf(it.ipAddress)) val jiraClient = AsynchronousJiraRestClientFactory().createWithBasicHttpAuthentication(URI(jiraConfig.address), jiraConfig.username, jiraConfig.password)
} JiraCertificateRequestStorage(requestStorage, jiraClient, jiraConfig.projectCode, jiraConfig.doneTransitionCode)
}) }
logger.info("Approved request $id")
// 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")
} }
} }
} }

View File

@ -26,7 +26,7 @@ interface CertificationRequestStorage {
/** /**
* Approve the given request by generating and storing a new certificate using the provided generator. * 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. * Reject the given request using the given reason.
@ -35,9 +35,13 @@ interface CertificationRequestStorage {
/** /**
* Retrieve list of request IDs waiting for approval. * 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> 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) data class CertificationRequestData(val hostName: String, val ipAddress: String, val request: PKCS10CertificationRequest)

View File

@ -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) { databaseTransaction(database) {
val request = singleRequestWhere { DataTable.requestId eq requestId and DataTable.processTimestamp.isNull() } val request = singleRequestWhere { DataTable.requestId eq requestId and DataTable.processTimestamp.isNull() }
if (request != null) { if (request != null) {
withFinalizables { finalizables -> withFinalizables { finalizables ->
DataTable.update({ DataTable.requestId eq requestId }) { DataTable.update({ DataTable.requestId eq requestId }) {
it[certificate] = serializeToBlob(certificateGenerator(request), finalizables) it[certificate] = serializeToBlob(request.generateCertificate(), finalizables)
it[processTimestamp] = Instant.now() 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? { private fun singleRequestWhere(where: SqlExpressionBuilder.() -> Op<Boolean>): CertificationRequestData? {
return DataTable return DataTable
.select(where) .select(where)

View File

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

View File

@ -3,7 +3,6 @@ port = 0
keystorePath = ${basedir}"/certificates/caKeystore.jks" keystorePath = ${basedir}"/certificates/caKeystore.jks"
keystorePassword = "password" keystorePassword = "password"
caPrivateKeyPassword = "password" caPrivateKeyPassword = "password"
approveAll = true
dataSourceProperties { dataSourceProperties {
dataSourceClassName = org.h2.jdbcx.JdbcDataSource dataSourceClassName = org.h2.jdbcx.JdbcDataSource
@ -11,4 +10,12 @@ dataSourceProperties {
"dataSource.user" = sa "dataSource.user" = sa
"dataSource.password" = "" "dataSource.password" = ""
} }
h2port = 0 h2port = 0
jiraConfig{
address = "https://doorman-jira-host/"
projectCode = "TD"
username = "username"
password = "password"
doneTransitionCode = 41
}

View File

@ -7,11 +7,10 @@ import kotlin.test.assertTrue
class DoormanParametersTest { class DoormanParametersTest {
@Test @Test
fun `parse arg correctly`() { 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(DoormanParameters.Mode.CA_KEYGEN, params.mode)
assertEquals("./testDummyPath.jks", params.keystorePath.toString()) assertEquals("./testDummyPath.jks", params.keystorePath.toString())
assertEquals(0, params.port) assertEquals(0, params.port)
assertTrue(params.approveAll)
val params2 = DoormanParameters(arrayOf("--keystorePath", "./testDummyPath.jks", "--port", "1000")) val params2 = DoormanParameters(arrayOf("--keystorePath", "./testDummyPath.jks", "--port", "1000"))
assertEquals(DoormanParameters.Mode.DOORMAN, params2.mode) assertEquals(DoormanParameters.Mode.DOORMAN, params2.mode)

View File

@ -86,9 +86,9 @@ class DoormanServiceTest {
assertThat(pollForResponse(id)).isEqualTo(PollResponse.NotReady) assertThat(pollForResponse(id)).isEqualTo(PollResponse.NotReady)
storage.approveRequest(id) { storage.approveRequest(id) {
JcaPKCS10CertificationRequest(it.request).run { JcaPKCS10CertificationRequest(request).run {
X509Utilities.createServerCert(subject, publicKey, intermediateCA, 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))
} }
} }

View File

@ -133,14 +133,14 @@ class DBCertificateRequestStorageTest {
private fun approveRequest(requestId: String) { private fun approveRequest(requestId: String) {
storage.approveRequest(requestId) { storage.approveRequest(requestId) {
JcaPKCS10CertificationRequest(it.request).run { JcaPKCS10CertificationRequest(request).run {
X509Utilities.createServerCert( X509Utilities.createServerCert(
subject, subject,
publicKey, publicKey,
intermediateCA, intermediateCA,
if (it.ipAddress == it.hostName) listOf() else listOf(it.hostName), if (ipAddress == hostName) listOf() else listOf(hostName),
listOf(it.ipAddress)) listOf(ipAddress))
} }
} }
} }
} }