diff --git a/build.gradle.kts b/build.gradle.kts index 511317f..4ca5d8f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -35,7 +35,6 @@ dependencies { implementation(kotlin("stdlib")) implementation("io.ktor:ktor-client-cio-jvm:$ktorVersion") implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion") - implementation("io.ktor:ktor-client-logging:$ktorVersion") implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion") implementation("com.expediagroup:graphql-kotlin-ktor-client:$expediaGraphQlVersion") @@ -47,6 +46,7 @@ dependencies { testImplementation("org.junit.jupiter:junit-jupiter-api:$junitJupiterVersion") testImplementation("org.junit.jupiter:junit-jupiter-params:$junitJupiterVersion") + testImplementation("io.ktor:ktor-client-mock:$ktorVersion") testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:$junitJupiterVersion") } diff --git a/src/main/kotlin/no/nav/security/BigQuery.kt b/src/main/kotlin/no/nav/security/BigQuery.kt index 1a1c051..ceb4b67 100644 --- a/src/main/kotlin/no/nav/security/BigQuery.kt +++ b/src/main/kotlin/no/nav/security/BigQuery.kt @@ -31,7 +31,8 @@ class BigQuery(projectID: String) { Field.of("repositoryName", StandardSQLTypeName.STRING), Field.of("vulnerabilityAlertsEnabled", StandardSQLTypeName.BOOL), Field.of("vulnerabilityCount", StandardSQLTypeName.INT64), - Field.of("isArchived", StandardSQLTypeName.BOOL) + Field.of("isArchived", StandardSQLTypeName.BOOL), + Field.of("productArea", StandardSQLTypeName.STRING) ) fun insert(records: List) = runCatching { @@ -47,7 +48,8 @@ class BigQuery(projectID: String) { "repositoryName" to it.repositoryName, "vulnerabilityAlertsEnabled" to it.vulnerabilityAlertsEnabled, "vulnerabilityCount" to it.vulnerabilityCount, - "isArchived" to it.isArchived + "isArchived" to it.isArchived, + "productArea" to it.productArea )) } @@ -82,5 +84,6 @@ class IssueCountRecord( val repositoryName: String, val vulnerabilityAlertsEnabled: Boolean, val vulnerabilityCount: Int, - val isArchived: Boolean + val isArchived: Boolean, + var productArea: String? ) diff --git a/src/main/kotlin/no/nav/security/EntraTokenProvider.kt b/src/main/kotlin/no/nav/security/EntraTokenProvider.kt new file mode 100644 index 0000000..7a22a4e --- /dev/null +++ b/src/main/kotlin/no/nav/security/EntraTokenProvider.kt @@ -0,0 +1,52 @@ +package no.nav.security + +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.request.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import java.net.URI +import java.net.URL + +class EntraTokenProvider( + private val scope: String, + private val client: HttpClient +) { + private var config: AzureConfig = AzureConfig( + tokenEndpoint = URI((requiredFromEnv("AZURE_OPENID_CONFIG_TOKEN_ENDPOINT"))).toURL(), + clientId = requiredFromEnv("AZURE_APP_CLIENT_ID"), + clientSecret = requiredFromEnv("AZURE_APP_CLIENT_SECRET"), + issuer = requiredFromEnv("AZURE_OPENID_CONFIG_ISSUER"), + jwks = URI(requiredFromEnv("AZURE_OPENID_CONFIG_JWKS_URI")).toURL() + ) + + suspend fun getClientCredentialToken() = + getAccessToken("client_id=${config.clientId}&client_secret=${config.clientSecret}&scope=$scope&grant_type=client_credentials") + + private suspend fun getAccessToken(body: String): String { + val response = client.post(config.tokenEndpoint) { + accept(ContentType.Application.Json) + contentType(ContentType.Application.FormUrlEncoded) + setBody(body) + } + if(response.status.isSuccess()) { + return response.body().access_token + } else { + throw RuntimeException("Failed to get access token: ${response.status.value}") + } + } + + private companion object { + private data class AzureConfig( + val tokenEndpoint: URL, + val clientId: String, + val clientSecret: String, + val jwks: URL, + val issuer: String + ) + + private data class Token(val expires_in: Long, val access_token: String) + } +} \ No newline at end of file diff --git a/src/main/kotlin/no/nav/security/Main.kt b/src/main/kotlin/no/nav/security/Main.kt index 2933fe8..6607822 100644 --- a/src/main/kotlin/no/nav/security/Main.kt +++ b/src/main/kotlin/no/nav/security/Main.kt @@ -1,14 +1,13 @@ package no.nav.security -import io.ktor.client.HttpClient -import io.ktor.client.engine.cio.CIO +import io.ktor.client.* +import io.ktor.client.engine.cio.* import io.ktor.client.plugins.* -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.client.plugins.logging.* +import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.request.* import io.ktor.http.* import io.ktor.http.HttpHeaders.UserAgent -import io.ktor.serialization.kotlinx.json.json +import io.ktor.serialization.kotlinx.json.* import kotlinx.coroutines.runBlocking import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json @@ -22,11 +21,20 @@ fun main(): Unit = runBlocking { val github = GitHub(httpClient = httpClient(requiredFromEnv("GITHUB_TOKEN"))) val naisApi = NaisApi(http = httpClient(requiredFromEnv("NAIS_API_TOKEN"))) val slack = Slack(httpClient = httpClient("yolo"), requiredFromEnv("SLACK_WEBHOOK")) + val teamkatalogAccessToken = EntraTokenProvider( + scope = "api://prod-gcp.org.team-catalog-backend/.default", client = httpClient("yolo") + ).getClientCredentialToken() + val teamcatalog = Teamcatalog(httpClient = httpClient(teamkatalogAccessToken)) + logger.info("Looking for GitHub repos") val githubRepositories = github.fetchOrgRepositories() logger.info("Fetched ${githubRepositories.size} repositories from GitHub") + val repositoryWithOwners = naisApi.adminsFor(githubRepositories) logger.info("Fetched ${repositoryWithOwners.size} repo owners from NAIS API") + + teamcatalog.updateRecordsWithProductAreasForTeams(repositoryWithOwners) + bq.insert(repositoryWithOwners).fold( { rowCount -> logger.info("Inserted $rowCount rows into BigQuery") }, { ex -> slack.send( @@ -59,10 +67,9 @@ internal fun httpClient(authToken: String) = HttpClient(CIO) { header(UserAgent, "NAV IT McBotFace") } } - } -private fun requiredFromEnv(name: String) = +internal fun requiredFromEnv(name: String) = System.getProperty(name) ?: System.getenv(name) - ?: throw RuntimeException("unable to find '$name' in environment") + ?: throw RuntimeException("unable to find '$name' in environment") \ No newline at end of file diff --git a/src/main/kotlin/no/nav/security/NaisApi.kt b/src/main/kotlin/no/nav/security/NaisApi.kt index 58e9557..105c1b2 100644 --- a/src/main/kotlin/no/nav/security/NaisApi.kt +++ b/src/main/kotlin/no/nav/security/NaisApi.kt @@ -10,7 +10,7 @@ class NaisApi(private val http: HttpClient) { private val baseUrl = "https://console.nav.cloud.nais.io/query" suspend fun adminsFor(repositories: List): List = - repositories.map {IssueCountRecord(adminsFor(it.name), it.pushedAt, it.name, it.hasVulnerabilityAlertsEnabled, it.vulnerabilityAlerts, it.isArchived)} + repositories.map {IssueCountRecord(adminsFor(it.name), it.pushedAt, it.name, it.hasVulnerabilityAlertsEnabled, it.vulnerabilityAlerts, it.isArchived, null)} private suspend fun adminsFor(repoName: String?): List { val repoFullName = "navikt/$repoName" diff --git a/src/main/kotlin/no/nav/security/Teamcatalog.kt b/src/main/kotlin/no/nav/security/Teamcatalog.kt new file mode 100644 index 0000000..058423f --- /dev/null +++ b/src/main/kotlin/no/nav/security/Teamcatalog.kt @@ -0,0 +1,53 @@ +package no.nav.security + +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.request.* +import kotlinx.serialization.Serializable + +class Teamcatalog( + val httpClient: HttpClient +) { + + private val baseUrl = "https://teamkatalog-api.intern.nav.no" + + suspend fun updateRecordsWithProductAreasForTeams(teams: List) { + val activeProductAreas = httpClient.get { url("$baseUrl/productarea?status=ACTIVE") } + .body() + + // Fetch all teams in active product areas and return list of product areas + val listOfProductAreasWithNaisTeams: List = activeProductAreas.content.map { + httpClient.get { url("$baseUrl/productArea/${it.id}") } + .body() + } + + var foundTeams = 0 + // Iterate through teams and find the product area for each naisTeam + teams.map { team -> + // Find the product area for the team if the team has a naisTeam + listOfProductAreasWithNaisTeams.find { productAreas -> + productAreas.naisTeams.any { naisTeam -> team.owners.contains(naisTeam) } + }?.let { result -> + // Find the product area with the same id as the id from last find + activeProductAreas.content.find { po -> + po.id == result.id + }?.let { productArea -> + // Set the product area for the team + team.productArea = productArea.name + foundTeams++ + } + } + } + logger.info("Found product area for $foundTeams teams") + } + + @Serializable + internal data class ProductAreaResponse(val content: List) + + @Serializable + internal data class ProductArea(val id: String, val name: String) + + @Serializable + internal data class TeamResponse(val id: String, val name: String, val naisTeams: List) +} + diff --git a/src/test/kotlin/no/nav/security/TeamcatalogTest.kt b/src/test/kotlin/no/nav/security/TeamcatalogTest.kt new file mode 100644 index 0000000..b7d1df0 --- /dev/null +++ b/src/test/kotlin/no/nav/security/TeamcatalogTest.kt @@ -0,0 +1,89 @@ +package no.nav.security + +import io.ktor.client.* +import io.ktor.client.engine.mock.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class TeamcatalogTest { + + @Test + @OptIn(kotlinx.serialization.ExperimentalSerializationApi::class) + fun `updateRecordsWithProductAreasForTeams should update teams with product areas`() = runBlocking { + val httpClient = HttpClient(MockEngine) { + install(ContentNegotiation) { + json(json = Json { + explicitNulls = false + ignoreUnknownKeys = true + }) + } + engine { + addHandler { request -> + when (request.url.fullPath) { + "/productarea?status=ACTIVE" -> { + respond( + Json.encodeToString( + Teamcatalog.ProductAreaResponse( + listOf( + Teamcatalog.ProductArea("1", "PO AppSec"), + Teamcatalog.ProductArea("2", "PO Platform") + ) + ) + ), headers = headersOf("Content-Type", ContentType.Application.Json.toString()) + ) + } + "/productArea/1" -> { + respond( + Json.encodeToString( + Teamcatalog.TeamResponse(id= "1", name ="The Best AppSec", naisTeams = listOf("appsec")) + ), headers = headersOf("Content-Type", ContentType.Application.Json.toString()) + ) + } + "/productArea/2" -> { + respond( + Json.encodeToString( + Teamcatalog.TeamResponse(id = "2", name = "NAIS Team", naisTeams = listOf("nais")) + ), headers = headersOf("Content-Type", ContentType.Application.Json.toString()) + ) + } + "/productArea/3" -> { + respond( + Json.encodeToString( + Teamcatalog.TeamResponse(id = "3", name = "A-Team", naisTeams = emptyList()) + ), headers = headersOf("Content-Type", ContentType.Application.Json.toString()) + ) + } + "/productArea/4" -> { + respond( + Json.encodeToString( + Teamcatalog.TeamResponse(id = "4", name = "B-Team", naisTeams = emptyList()) + ), headers = headersOf("Content-Type", ContentType.Application.Json.toString()) + ) + } + else -> error("Unhandled ${request.url.fullPath}") + } + } + } + } + + val teamcatalog = Teamcatalog(httpClient) + val teams = listOf( + IssueCountRecord(listOf("appsec"), "2022-01-01", "repo1", true, 1, false, null), + IssueCountRecord(listOf("nais"), "2022-01-01", "repo2", true, 1, false, null), + IssueCountRecord(emptyList(), "2022-01-01", "repo3", true, 1, false, null) + ) + + teamcatalog.updateRecordsWithProductAreasForTeams(teams) + + assertEquals("PO AppSec", teams[0].productArea) + assertEquals("PO Platform", teams[1].productArea) + assertNull(teams[2].productArea) + } +} \ No newline at end of file diff --git a/src/test/resources/logback.xml b/src/test/resources/logback.xml index 001ec01..7e17d31 100644 --- a/src/test/resources/logback.xml +++ b/src/test/resources/logback.xml @@ -5,7 +5,7 @@ - +