Skip to content

Commit

Permalink
ID-1301 talk to ecm for nih username (Round 2) (#1407)
Browse files Browse the repository at this point in the history
  • Loading branch information
tlangs authored Aug 8, 2024
1 parent 0494d56 commit abd3618
Show file tree
Hide file tree
Showing 28 changed files with 862 additions and 85 deletions.
2 changes: 1 addition & 1 deletion local-dev/templates/docker-rsync-local-orch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ start_server () {
--network=fc-orch \
-e JAVA_OPTS="$DOCKER_JAVA_OPTS" \
sbtscala/scala-sbt:eclipse-temurin-jammy-17.0.10_7_1.10.1_2.13.14 \
sbt \~reStart
bash -c "git config --global --add safe.directory /app && sbt \~reStart"

docker cp config/firecloud-account.pem orch-sbt:/etc/firecloud-account.pem
docker cp config/firecloud-account.json orch-sbt:/etc/firecloud-account.json
Expand Down
5 changes: 5 additions & 0 deletions local-dev/templates/firecloud-orchestration.conf.ctmpl
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,11 @@ cwds {
bucketName = "cwds-batchupsert-dev"
}

externalCreds {
baseUrl = "https://externalcreds.dsde-dev.broadinstitute.org"
enabled = true
}

firecloud {
baseUrl = "https://firecloud-orchestration.dsde-dev.broadinstitute.org"
portalUrl = "https://firecloud.dsde-dev.broadinstitute.org"
Expand Down
8 changes: 7 additions & 1 deletion project/Dependencies.scala
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ object Dependencies {
val excludeAkkaHttp = ExclusionRule(organization = "com.typesafe.akka", name = "akka-http_2.13")
val excludeSprayJson = ExclusionRule(organization = "com.typesafe.akka", name = "akka-http-spray-json_2.13")

val excludeSpring = ExclusionRule(organization = "org.springframework")
val excludeSpringBoot = ExclusionRule(organization = "org.springframework.boot")
val excludeSpringJcl = ExclusionRule(organization = "org.springframework", name = "spring-jcl")

// Overrides for transitive dependencies. These apply - via Settings.scala - to all projects in this codebase.
// These are overrides only; if the direct dependencies stop including any of these, they will not be included
// by being listed here.
Expand Down Expand Up @@ -50,9 +54,11 @@ object Dependencies {
excludeGuava("org.broadinstitute.dsde.workbench" %% "workbench-util" % s"0.10-$workbenchLibsHash"),
"org.broadinstitute.dsde.workbench" %% "workbench-google2" % s"0.36-$workbenchLibsHash",
"org.broadinstitute.dsde.workbench" %% "workbench-oauth2" % s"0.7-$workbenchLibsHash",
"org.broadinstitute.dsde.workbench" %% "sam-client" % "0.1-ef83073",
"org.broadinstitute.dsde.workbench" %% "sam-client" % "v0.0.263",
"org.broadinstitute.dsde.workbench" %% "workbench-notifications" %s"0.6-$workbenchLibsHash",
"org.databiosphere" % "workspacedataservice-client-okhttp-jakarta" % "0.2.167-SNAPSHOT",
"bio.terra" % "externalcreds-client-resttemplate" % "1.44.0-20240725.201427-1" excludeAll(excludeSpring, excludeSpringBoot),
"org.springframework" % "spring-web" % "6.1.11" excludeAll(excludeSpringBoot, excludeSpringJcl),

"com.typesafe.akka" %% "akka-actor" % akkaV,
"com.typesafe.akka" %% "akka-slf4j" % akkaV,
Expand Down
1 change: 1 addition & 0 deletions project/Merging.scala
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ object Merging {
// Error: /home/sbtuser/.cache/coursier/v1/https/repo1.maven.org/maven2/com/typesafe/akka/akka-protobuf-v3_2.13/2.6.19/akka-protobuf-v3_2.13-2.6.19.jar:google/protobuf/struct.proto
case PathList("google", "protobuf", _ @ _*) => MergeStrategy.first
case PathList("META-INF", "versions", "9", "OSGI-INF", "MANIFEST.MF") => MergeStrategy.first
case PathList("META-INF", "spring", "aot.factories") => MergeStrategy.first
case x => oldStrategy(x)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ case class Application(agoraDAO: AgoraDAO,
thurloeDAO: ThurloeDAO,
shareLogDAO: ShareLogDAO,
shibbolethDAO: ShibbolethDAO,
cwdsDAO: CwdsDAO)
cwdsDAO: CwdsDAO,
ecmDAO: ExternalCredsDAO)
3 changes: 2 additions & 1 deletion src/main/scala/org/broadinstitute/dsde/firecloud/Boot.scala
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ object Boot extends App with LazyLogging {
val rawlsDAO: RawlsDAO = new HttpRawlsDAO
val samDAO: SamDAO = new HttpSamDAO
val thurloeDAO: ThurloeDAO = new HttpThurloeDAO
val ecmDAO: ExternalCredsDAO = if (FireCloudConfig.ExternalCreds.enabled) new HttpExternalCredsDAO else new DisabledExternalCredsDAO

// can be disabled
val agoraDAO: AgoraDAO = whenEnabled[AgoraDAO](FireCloudConfig.Agora.enabled, new HttpAgoraDAO(FireCloudConfig.Agora))
Expand All @@ -110,7 +111,7 @@ object Boot extends App with LazyLogging {
val searchDAO: SearchDAO = elasticSearchClient.map(new ElasticSearchDAO(_, FireCloudConfig.ElasticSearch.indexName, researchPurposeSupport)).getOrElse(DisabledServiceFactory.newDisabledService[SearchDAO])
val shareLogDAO: ShareLogDAO = elasticSearchClient.map(new ElasticSearchShareLogDAO(_, FireCloudConfig.ElasticSearch.shareLogIndexName)).getOrElse(DisabledServiceFactory.newDisabledService[ShareLogDAO])

Application(agoraDAO, googleServicesDAO, ontologyDAO, rawlsDAO, samDAO, searchDAO, researchPurposeSupport, thurloeDAO, shareLogDAO, shibbolethDAO, cwdsDAO)
Application(agoraDAO, googleServicesDAO, ontologyDAO, rawlsDAO, samDAO, searchDAO, researchPurposeSupport, thurloeDAO, shareLogDAO, shibbolethDAO, cwdsDAO, ecmDAO)
}

private def whenEnabled[T : ClassTag](enabled: Boolean, realService: => T): T = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package org.broadinstitute.dsde.firecloud
import akka.http.scaladsl.model.Uri
import akka.http.scaladsl.model.Uri.{Authority, Host, Query}
import com.typesafe.config.{Config, ConfigFactory, ConfigObject}
import org.broadinstitute.dsde.firecloud.service.{FireCloudDirectiveUtils, NihWhitelist}
import org.broadinstitute.dsde.firecloud.service.{FireCloudDirectiveUtils, NihAllowlist}
import org.broadinstitute.dsde.rawls.model.{EntityQuery, SortDirections}
import org.broadinstitute.dsde.workbench.model.WorkbenchGroupName

Expand Down Expand Up @@ -125,6 +125,13 @@ object FireCloudConfig {
lazy val bucket: String = cwds.getString("bucketName")
}

object ExternalCreds {
// lazy - only required when External Credentials is enabled
private lazy val externalCreds = config.getConfig("externalCreds")
lazy val baseUrl: String = externalCreds.getString("baseUrl")
lazy val enabled: Boolean = externalCreds.getBoolean("enabled")
}

object FireCloud {
private val firecloud = config.getConfig("firecloud")
val fireCloudId = firecloud.getString("fireCloudId")
Expand All @@ -147,15 +154,15 @@ object FireCloudConfig {
// lazy - only required when nih is enabled
private lazy val nih = config.getConfig("nih")
lazy val whitelistBucket = nih.getString("whitelistBucket")
lazy val whitelists: Set[NihWhitelist] = {
lazy val whitelists: Set[NihAllowlist] = {
val whitelistConfigs = nih.getConfig("whitelists")

whitelistConfigs.root.asScala.collect { case (name, configObject:ConfigObject) =>
val config = configObject.toConfig
val rawlsGroup = config.getString("rawlsGroup")
val fileName = config.getString("fileName")

NihWhitelist(name, WorkbenchGroupName(rawlsGroup), fileName)
NihAllowlist(name, WorkbenchGroupName(rawlsGroup), fileName)
}
}.toSet
val enabled = nih.optionalBoolean("enabled").getOrElse(true)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package org.broadinstitute.dsde.firecloud.dataaccess

import com.typesafe.scalalogging.LazyLogging
import org.broadinstitute.dsde.firecloud.model.{LinkedEraAccount, UserInfo, WithAccessToken}

import scala.concurrent.Future

class DisabledExternalCredsDAO extends ExternalCredsDAO with LazyLogging {

override def getLinkedAccount(implicit userInfo: UserInfo): Future[Option[LinkedEraAccount]] = Future.successful {
logger.warn("Getting Linked eRA Account from ECM, but ECM is disabled.")
None
}

override def putLinkedEraAccount(linkedEraAccount: LinkedEraAccount)(implicit orchInfo: WithAccessToken): Future[Unit] = Future.successful {
logger.warn("Putting Linked eRA Account to ECM, but ECM is disabled.")
}

override def deleteLinkedEraAccount(userInfo: UserInfo)(implicit orchInfo: WithAccessToken): Future[Unit] = Future.successful {
logger.warn("Deleting Linked eRA Account from ECM, but ECM is disabled.")
}

override def getLinkedEraAccountForUsername(username: String)(implicit orchInfo: WithAccessToken): Future[Option[LinkedEraAccount]] = Future.successful {
logger.warn("Getting Linked eRA Account for username from ECM, but ECM is disabled.")
None
}

override def getActiveLinkedEraAccounts(implicit orchInfo: WithAccessToken): Future[Seq[LinkedEraAccount]] = Future.successful {
logger.warn("Getting Active Linked eRA Accounts from ECM, but ECM is disabled.")
Seq.empty
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package org.broadinstitute.dsde.firecloud.dataaccess

import org.broadinstitute.dsde.firecloud.model.{LinkedEraAccount, UserInfo, WithAccessToken}
import org.databiosphere.workspacedata.client.ApiException

import scala.concurrent.Future

trait ExternalCredsDAO {

@throws(classOf[ApiException])
def getLinkedAccount(implicit userInfo: UserInfo): Future[Option[LinkedEraAccount]]

@throws(classOf[ApiException])
def putLinkedEraAccount(linkedEraAccount: LinkedEraAccount)(implicit orchInfo: WithAccessToken): Future[Unit]

@throws(classOf[ApiException])
def deleteLinkedEraAccount(userInfo: UserInfo)(implicit orchInfo: WithAccessToken): Future[Unit]

@throws(classOf[ApiException])
def getLinkedEraAccountForUsername(username: String)(implicit orchInfo: WithAccessToken): Future[Option[LinkedEraAccount]]

@throws(classOf[ApiException])
def getActiveLinkedEraAccounts(implicit orchInfo: WithAccessToken): Future[Seq[LinkedEraAccount]]

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package org.broadinstitute.dsde.firecloud.dataaccess

import bio.terra.externalcreds.api.OauthApi
import bio.terra.externalcreds.api.AdminApi
import bio.terra.externalcreds.client.ApiClient
import bio.terra.externalcreds.model.Provider
import com.google.api.client.http.HttpStatusCodes
import org.broadinstitute.dsde.firecloud.FireCloudConfig
import org.broadinstitute.dsde.firecloud.model.LinkedEraAccount.unapply
import org.broadinstitute.dsde.firecloud.model.{LinkedEraAccount, UserInfo, WithAccessToken}
import org.broadinstitute.dsde.workbench.model.WorkbenchException
import org.joda.time.DateTime
import org.springframework.web.client.{HttpClientErrorException, RestTemplate}

import scala.concurrent.{ExecutionContext, Future}
import scala.jdk.CollectionConverters._

class HttpExternalCredsDAO(implicit val executionContext: ExecutionContext) extends ExternalCredsDAO {

private lazy val restTemplate = new RestTemplate

private def handleError[A](e: HttpClientErrorException, operation: String): Option[A] = {
e.getStatusCode.value() match {
case HttpStatusCodes.STATUS_CODE_NOT_FOUND => None
case _ => throw new WorkbenchException(s"Failed to $operation: ${e.getMessage}")
}
}

override def getLinkedAccount(implicit userInfo: UserInfo): Future[Option[LinkedEraAccount]] = Future {
val oauthApi: OauthApi = getOauthApi(userInfo.accessToken.token)
try {
val linkInfo = oauthApi.getLink(Provider.ERA_COMMONS)
Some(LinkedEraAccount(userInfo.id, linkInfo.getExternalUserId, new DateTime(linkInfo.getExpirationTimestamp)))
} catch {
case e: HttpClientErrorException => handleError(e, "GET eRA Linked Account")
}
}

override def putLinkedEraAccount(linkedEraAccount: LinkedEraAccount)(implicit orchInfo: WithAccessToken): Future[Unit] = Future {
val adminApi = getAdminApi(orchInfo.accessToken.token)
adminApi.putLinkedAccountWithFakeToken(unapply(linkedEraAccount), Provider.ERA_COMMONS)
}

override def deleteLinkedEraAccount(userInfo: UserInfo)(implicit orchInfo: WithAccessToken): Future[Unit] = Future {
val adminApi = getAdminApi(orchInfo.accessToken.token)
try {
adminApi.adminDeleteLinkedAccount(userInfo.id, Provider.ERA_COMMONS)
} catch {
case e: HttpClientErrorException => handleError(e, "DELETE eRA Linked Account")
}
}

override def getLinkedEraAccountForUsername(username: String)(implicit orchInfo: WithAccessToken): Future[Option[LinkedEraAccount]] = Future {
val adminApi = getAdminApi(orchInfo.accessToken.token)
try {
val adminLinkInfo = adminApi.getLinkedAccountForExternalId(Provider.ERA_COMMONS, username)
Some(LinkedEraAccount(adminLinkInfo))
} catch {
case e: HttpClientErrorException => handleError(e, s"GET eRA Linked Account for username [$username]")
}
}

override def getActiveLinkedEraAccounts(implicit orchInfo: WithAccessToken): Future[Seq[LinkedEraAccount]] = Future {
val adminApi = getAdminApi(orchInfo.accessToken.token)
val adminLinkInfos = adminApi.getActiveLinkedAccounts(Provider.ERA_COMMONS)
adminLinkInfos.asScala.map(LinkedEraAccount.apply).toSeq
}

private def getApi(accessToken: String): ApiClient = {
val client = new ApiClient(restTemplate)
client.setBasePath(FireCloudConfig.ExternalCreds.baseUrl)
client.setAccessToken(accessToken)
client
}

private def getOauthApi(accessToken: String): OauthApi = {
val client = getApi(accessToken)
new OauthApi(client)
}

private def getAdminApi(accessToken: String): AdminApi = {
val client = getApi(accessToken)
new AdminApi(client)
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@ import org.broadinstitute.dsde.firecloud.model.ErrorReportExtensions.FCErrorRepo
import org.broadinstitute.dsde.firecloud.model.ManagedGroupRoles.ManagedGroupRole
import org.broadinstitute.dsde.firecloud.model.ModelJsonProtocol._
import org.broadinstitute.dsde.firecloud.model.SamResource.UserPolicy
import org.broadinstitute.dsde.firecloud.model.{AccessToken, FireCloudManagedGroupMembership, ManagedGroupRoles, RegistrationInfo, RegistrationInfoV2, SamUserAttributesRequest, SamUserRegistrationRequest, SamUserResponse, UserIdInfo, UserInfo, WithAccessToken}
import org.broadinstitute.dsde.firecloud.model.{AccessToken, FireCloudManagedGroupMembership, ManagedGroupRoles, RegistrationInfo, SamUser, SamUserAttributesRequest, SamUserRegistrationRequest, SamUserResponse, UserIdInfo, UserInfo, WithAccessToken, WorkbenchUserInfo}
import org.broadinstitute.dsde.firecloud.utils.RestJsonClient
import org.broadinstitute.dsde.rawls.model.RawlsUserEmail
import org.broadinstitute.dsde.workbench.model.WorkbenchIdentityJsonSupport._
import org.broadinstitute.dsde.workbench.model.google.GoogleProject
import org.broadinstitute.dsde.workbench.model.{WorkbenchEmail, WorkbenchGroupName}
import org.broadinstitute.dsde.workbench.model.{WorkbenchEmail, WorkbenchGroupName, WorkbenchUserId}
import org.broadinstitute.dsde.workbench.util.health.SubsystemStatus
import spray.json.DefaultJsonProtocol._
import spray.json.{JsValue, JsonFormat, RootJsonFormat}
Expand Down Expand Up @@ -50,6 +50,14 @@ class HttpSamDAO( implicit val system: ActorSystem, val materializer: Materializ
authedRequestToObject[UserIdInfo](Get(samGetUserIdsUrl.format(URLEncoder.encode(email.value, UTF_8.name))))
}

// Sam's API only allows for 1000 user to be fetched at one time
override def getUsersForIds(samUserIds: Seq[WorkbenchUserId])(implicit userInfo: WithAccessToken): Future[Seq[WorkbenchUserInfo]] = Future.sequence {
samUserIds.sliding(1000, 1000).toSeq.map { batch =>
adminAuthedRequestToObject[Seq[SamUser]](Post(samAdminGetUsersForIdsUrl, batch))
.map(_.map(user => WorkbenchUserInfo(user.id.value, user.email.value)))
}
}.map(_.flatten)

override def isGroupMember(groupName: WorkbenchGroupName, userInfo: UserInfo): Future[Boolean] = {
implicit val accessToken = userInfo
authedRequestToObject[List[String]](Get(samResourceRoles(managedGroupResourceTypeName, groupName.value)), label=Some("HttpSamDAO.isGroupMember")).map { allRoles =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ import com.typesafe.scalalogging.LazyLogging
import org.broadinstitute.dsde.firecloud.FireCloudConfig
import org.broadinstitute.dsde.firecloud.model.ManagedGroupRoles.ManagedGroupRole
import org.broadinstitute.dsde.firecloud.model.SamResource.UserPolicy
import org.broadinstitute.dsde.firecloud.model.{AccessToken, FireCloudManagedGroupMembership, RegistrationInfo, RegistrationInfoV2, SamUserResponse, UserIdInfo, UserInfo, WithAccessToken}
import org.broadinstitute.dsde.firecloud.model.{AccessToken, FireCloudManagedGroupMembership, RegistrationInfo, RegistrationInfoV2, SamUserResponse, UserIdInfo, UserInfo, WithAccessToken, WorkbenchUserInfo}
import org.broadinstitute.dsde.rawls.model.{ErrorReportSource, RawlsUserEmail}
import org.broadinstitute.dsde.workbench.model.google.GoogleProject
import org.broadinstitute.dsde.workbench.model.{WorkbenchEmail, WorkbenchGroupName}
import org.broadinstitute.dsde.workbench.model.{WorkbenchEmail, WorkbenchGroupName, WorkbenchUserId}
import org.broadinstitute.dsde.workbench.util.health.Subsystems

import scala.concurrent.Future
Expand All @@ -35,6 +35,7 @@ trait SamDAO extends LazyLogging with ReportsSubsystemStatus {
val samGetUserIdsUrl = FireCloudConfig.Sam.baseUrl + "/api/users/v1/%s"
val samArbitraryPetTokenUrl = FireCloudConfig.Sam.baseUrl + "/api/google/v1/user/petServiceAccount/token"
val samPetKeyForProject = FireCloudConfig.Sam.baseUrl + "/api/google/v1/user/petServiceAccount/%s/key"
val samAdminGetUsersForIdsUrl = FireCloudConfig.Sam.baseUrl + "/api/admin/v2/users"

val samManagedGroupsBase: String = FireCloudConfig.Sam.baseUrl + "/api/groups"
val samManagedGroupBase: String = FireCloudConfig.Sam.baseUrl + "/api/group"
Expand All @@ -61,6 +62,8 @@ trait SamDAO extends LazyLogging with ReportsSubsystemStatus {

def getUserIds(email: RawlsUserEmail)(implicit userInfo: WithAccessToken): Future[UserIdInfo]

def getUsersForIds(samUserIds: Seq[WorkbenchUserId])(implicit userInfo: WithAccessToken): Future[Seq[WorkbenchUserInfo]]

def listWorkspaceResources(implicit userInfo: WithAccessToken): Future[Seq[UserPolicy]]

def createGroup(groupName: WorkbenchGroupName)(implicit userInfo: WithAccessToken): Future[Unit]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package org.broadinstitute.dsde.firecloud.model

import bio.terra.externalcreds.model.AdminLinkInfo
import org.joda.time.{DateTime, Instant}

object LinkedEraAccount {
def apply(samUserId: String, nihLink: NihLink): LinkedEraAccount = {
LinkedEraAccount(samUserId, nihLink.linkedNihUsername, Instant.ofEpochSecond(nihLink.linkExpireTime).toDateTime)
}

def apply(adminLinkInfo: AdminLinkInfo): LinkedEraAccount = {
LinkedEraAccount(adminLinkInfo.getUserId, adminLinkInfo.getLinkedExternalId, new DateTime(adminLinkInfo.getLinkExpireTime))
}

def unapply(linkedEraAccount: LinkedEraAccount): AdminLinkInfo = {
new AdminLinkInfo()
.userId(linkedEraAccount.userId)
.linkedExternalId(linkedEraAccount.linkedExternalId)
.linkExpireTime(linkedEraAccount.linkExpireTime.toDate)
}
}

case class LinkedEraAccount(userId: String, linkedExternalId: String, linkExpireTime: DateTime)


Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import org.broadinstitute.dsde.firecloud.model.ShareLog.{Share, ShareType}
import org.broadinstitute.dsde.rawls.model.UserModelJsonSupport._
import org.broadinstitute.dsde.rawls.model.WorkspaceACLJsonSupport.WorkspaceAccessLevelFormat
import org.broadinstitute.dsde.rawls.model._
import org.broadinstitute.dsde.workbench.client.sam.model.User
import org.broadinstitute.dsde.workbench.model.ValueObjectFormat
import org.broadinstitute.dsde.workbench.model.WorkbenchIdentityJsonSupport._
import org.broadinstitute.dsde.workbench.model.google.GoogleModelJsonSupport.InstantFormat
Expand Down Expand Up @@ -233,6 +234,7 @@ object ModelJsonProtocol extends WorkspaceJsonSupport with SprayJsonSupport {
implicit val impRegistrationInfo: RootJsonFormat[RegistrationInfo] = jsonFormat3(RegistrationInfo)
implicit val impRegistrationInfoV2: RootJsonFormat[RegistrationInfoV2] = jsonFormat3(RegistrationInfoV2)
implicit val impSamUserResponse: RootJsonFormat[SamUserResponse] = jsonFormat8(SamUserResponse)
implicit val impSamUser: RootJsonFormat[SamUser] = jsonFormat8(SamUser)
implicit val impUserIdInfo: RootJsonFormat[UserIdInfo] = jsonFormat3(UserIdInfo)
implicit val impCurator: RootJsonFormat[Curator] = jsonFormat1(Curator)
implicit val impUserImportPermission: RootJsonFormat[UserImportPermission] = jsonFormat2(UserImportPermission)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@ object Profile {

}

object NihLink {
def apply(linkedEraAccount: LinkedEraAccount): NihLink = NihLink(linkedEraAccount.linkedExternalId, linkedEraAccount.linkExpireTime.getMillis / 1000)
}

case class NihLink(linkedNihUsername: String, linkExpireTime: Long) extends mappedPropVals {
require(ProfileValidator.nonEmpty(linkedNihUsername), "linkedNihUsername must be non-empty")
}
Expand Down
Loading

0 comments on commit abd3618

Please sign in to comment.