Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added teamcatalog integration and product area to BQ schema #3

Merged
merged 4 commits into from
Jun 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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")
}

Expand Down
9 changes: 6 additions & 3 deletions src/main/kotlin/no/nav/security/BigQuery.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<IssueCountRecord>) = runCatching {
Expand All @@ -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
))
}

Expand Down Expand Up @@ -82,5 +84,6 @@ class IssueCountRecord(
val repositoryName: String,
val vulnerabilityAlertsEnabled: Boolean,
val vulnerabilityCount: Int,
val isArchived: Boolean
val isArchived: Boolean,
var productArea: String?
)
52 changes: 52 additions & 0 deletions src/main/kotlin/no/nav/security/EntraTokenProvider.kt
Original file line number Diff line number Diff line change
@@ -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<Token>().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)
}
}
23 changes: 15 additions & 8 deletions src/main/kotlin/no/nav/security/Main.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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")
2 changes: 1 addition & 1 deletion src/main/kotlin/no/nav/security/NaisApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<GithubRepository>): List<IssueCountRecord> =
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<String> {
val repoFullName = "navikt/$repoName"
Expand Down
53 changes: 53 additions & 0 deletions src/main/kotlin/no/nav/security/Teamcatalog.kt
Original file line number Diff line number Diff line change
@@ -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<IssueCountRecord>) {
val activeProductAreas = httpClient.get { url("$baseUrl/productarea?status=ACTIVE") }
.body<ProductAreaResponse>()

// Fetch all teams in active product areas and return list of product areas
val listOfProductAreasWithNaisTeams: List<TeamResponse> = activeProductAreas.content.map {
httpClient.get { url("$baseUrl/productArea/${it.id}") }
.body<TeamResponse>()
}

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

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

89 changes: 89 additions & 0 deletions src/test/kotlin/no/nav/security/TeamcatalogTest.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 1 addition & 1 deletion src/test/resources/logback.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
</encoder>
</appender>

<root level="trace">
<root level="INFO">
<appender-ref ref="STDOUT"/>
</root>

Expand Down