,
32 | vararg values: Z,
33 | ): List = dao().fetch(field, *values)
34 |
35 | suspend fun fetchOne(
36 | field: Field,
37 | value: Z,
38 | ): P? = dao().fetchOne(field, value)
39 |
40 | suspend fun existsById(id: T): Boolean = dao().existsById(id)
41 |
42 | suspend fun insert(pojo: P) = dao().insert(pojo)
43 |
44 | suspend fun store(record: R): Int = record.apply { attach(dao().configuration()) }.store()
45 |
46 | suspend fun delete(record: R): Int = record.apply { attach(dao().configuration()) }.delete()
47 |
48 | suspend fun deleteBatch(keys: Collection): Int {
49 | val pk = pk()
50 | requireNotNull(pk) { "Cannot batchDelete on ${dao().table.name} as primaryKey is unavailable" }
51 | return ctx().deleteFrom(dao().table).where(equal(pk, keys)).execute()
52 | }
53 |
54 | suspend fun insertBatch(
55 | queryName: String? = null,
56 | records: List,
57 | ): List {
58 | if (records.isEmpty()) return records
59 | var insertStep = ctx(queryName).insertInto(dao().table).set(records[0])
60 | for (i in 1 until records.size) {
61 | insertStep = insertStep.newRecord().set(records[i])
62 | }
63 | return insertStep.returning().fetch()
64 | }
65 |
66 | suspend fun batchStore(
67 | queryName: String? = null,
68 | records: List,
69 | ) = ctx(queryName).batchStore(records).execute()
70 |
71 | // ------------------------------------------------------------------------
72 | // XXX: Private utility methods: Repurposed from JOOQ's DAOImpl.java
73 | // ------------------------------------------------------------------------
74 |
75 | private fun equal(
76 | pk: Array>,
77 | id: T,
78 | ): Condition {
79 | @Suppress("SpreadOperator")
80 | return if (pk.size == 1) {
81 | (pk[0] as Field).equal(pk[0].dataType.convert(id))
82 | } else {
83 | DSL.row(*pk).equal(id as Record)
84 | }
85 | }
86 |
87 | private fun equal(
88 | pk: Array>,
89 | ids: Collection,
90 | ): Condition {
91 | return if (pk.size == 1) {
92 | if (ids.size == 1) {
93 | equal(pk, ids.iterator().next())
94 | } else {
95 | (pk[0]).`in`(pk[0].dataType.convert(ids))
96 | }
97 | } else {
98 | // [#2573] Composite key T types are of type Record[N]
99 | TODO("Composite keys are unsupported at the moment")
100 | // DSL.row(*pk).`in`(ids as MutableCollection)
101 | }
102 | }
103 |
104 | private suspend fun pk(): Array>? {
105 | return dao().table.primaryKey?.fieldsArray
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/hypto/iam/server/db/repositories/LinkUsersRepo.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.db.repositories
2 |
3 | import com.hypto.iam.server.db.tables.LinkUsers.LINK_USERS
4 | import com.hypto.iam.server.db.tables.pojos.LinkUsers
5 | import com.hypto.iam.server.db.tables.records.LinkUsersRecord
6 | import com.hypto.iam.server.extensions.PaginationContext
7 | import com.hypto.iam.server.extensions.paginate
8 | import org.jooq.impl.DAOImpl
9 |
10 | object LinkUsersRepo : BaseRepo() {
11 | private val idFun = fun(linkUsers: LinkUsers) = linkUsers.id
12 |
13 | override suspend fun dao(): DAOImpl =
14 | txMan.getDao(LINK_USERS, LinkUsers::class.java, idFun)
15 |
16 | suspend fun getById(id: String) =
17 | ctx("linkUsers.getById").selectFrom(LINK_USERS)
18 | .where(LINK_USERS.ID.eq(id))
19 | .fetchOne()
20 |
21 | suspend fun fetchSubordinateUsers(
22 | leaderUserHrn: String,
23 | context: PaginationContext,
24 | ): Map {
25 | return ctx("linkUsers.fetchSubordinateUsers")
26 | .selectFrom(LINK_USERS)
27 | .where(LINK_USERS.LEADER_USER_HRN.eq(leaderUserHrn))
28 | .paginate(LINK_USERS.SUBORDINATE_USER_HRN, context)
29 | .fetchMap(LINK_USERS.SUBORDINATE_USER_HRN)
30 | }
31 |
32 | suspend fun fetchLeaderUsers(
33 | subordinateUserHrn: String,
34 | context: PaginationContext,
35 | ): Map {
36 | return ctx("linkUsers.fetchLeaderUsers")
37 | .selectFrom(LINK_USERS)
38 | .where(LINK_USERS.SUBORDINATE_USER_HRN.eq(subordinateUserHrn))
39 | .paginate(LINK_USERS.LEADER_USER_HRN, context)
40 | .fetchMap(LINK_USERS.LEADER_USER_HRN)
41 | }
42 |
43 | suspend fun deleteById(id: String): Boolean {
44 | return ctx("linkUsers.deleteById").deleteFrom(LINK_USERS)
45 | .where(LINK_USERS.ID.eq(id))
46 | .execute() > 0
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/hypto/iam/server/db/repositories/OrganizationRepo.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.db.repositories
2 |
3 | import com.google.gson.Gson
4 | import com.hypto.iam.server.db.tables.Organizations.ORGANIZATIONS
5 | import com.hypto.iam.server.db.tables.pojos.Organizations
6 | import com.hypto.iam.server.db.tables.records.OrganizationsRecord
7 | import com.hypto.iam.server.idp.IdentityGroup
8 | import org.jooq.JSONB
9 | import org.jooq.impl.DAOImpl
10 | import org.koin.core.component.inject
11 | import java.time.LocalDateTime
12 |
13 | object OrganizationRepo : BaseRepo() {
14 | private val gson: Gson by inject()
15 |
16 | private val idFun = fun (organization: Organizations): String {
17 | return organization.id
18 | }
19 |
20 | override suspend fun dao(): DAOImpl =
21 | txMan.getDao(ORGANIZATIONS, Organizations::class.java, idFun)
22 |
23 | /**
24 | * Updates organization with given input values
25 | */
26 | suspend fun update(
27 | id: String,
28 | name: String?,
29 | description: String?,
30 | identityGroup: IdentityGroup?,
31 | ): OrganizationsRecord? {
32 | val updateStep =
33 | ctx("organizations.update")
34 | .update(ORGANIZATIONS)
35 | .set(ORGANIZATIONS.UPDATED_AT, LocalDateTime.now())
36 | if (!name.isNullOrEmpty()) {
37 | updateStep.set(ORGANIZATIONS.NAME, name)
38 | }
39 | if (!description.isNullOrEmpty()) {
40 | updateStep.set(ORGANIZATIONS.DESCRIPTION, description)
41 | }
42 | if (identityGroup != null) {
43 | val identityGroup = gson.toJson(identityGroup, IdentityGroup::class.java)
44 | updateStep.set(ORGANIZATIONS.METADATA, JSONB.jsonb(identityGroup))
45 | }
46 | return updateStep.where(ORGANIZATIONS.ID.eq(id)).returning().fetchOne()
47 | }
48 |
49 | suspend fun findById(id: String): Organizations? = dao().findById(id)
50 |
51 | suspend fun deleteById(id: String) = dao().deleteById(id)
52 | }
53 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/hypto/iam/server/db/repositories/PolicyTemplatesRepo.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.db.repositories
2 |
3 | import com.hypto.iam.server.db.Tables.POLICY_TEMPLATES
4 | import com.hypto.iam.server.db.tables.pojos.PolicyTemplates
5 | import com.hypto.iam.server.db.tables.records.PolicyTemplatesRecord
6 | import org.jooq.Result
7 | import org.jooq.impl.DAOImpl
8 |
9 | object PolicyTemplatesRepo : BaseRepo() {
10 | enum class Status(val value: String) {
11 | ACTIVE("ACTIVE"),
12 | ARCHIVED("ARCHIVED"),
13 | }
14 |
15 | private val idFun = fun (policyTemplates: PolicyTemplates) = policyTemplates.name
16 |
17 | override suspend fun dao(): DAOImpl =
18 | txMan.getDao(POLICY_TEMPLATES, PolicyTemplates::class.java, idFun)
19 |
20 | /**
21 | * Fetch records that have `status = 'ACTIVE'`
22 | */
23 | suspend fun fetchActivePolicyTemplatesForOrgCreation(): Result =
24 | ctx("policy_templates.fetchActive")
25 | .selectFrom(POLICY_TEMPLATES)
26 | .where(POLICY_TEMPLATES.STATUS.eq(Status.ACTIVE.value))
27 | .and(POLICY_TEMPLATES.ON_CREATE_ORG.eq(true))
28 | .fetch()
29 |
30 | suspend fun fetchActivePolicyByName(name: String): PolicyTemplatesRecord? =
31 | ctx("policy_templates.fetchActivePolicyByName")
32 | .selectFrom(POLICY_TEMPLATES)
33 | .where(
34 | POLICY_TEMPLATES.NAME.eq(name),
35 | POLICY_TEMPLATES.STATUS.eq(Status.ACTIVE.value),
36 | )
37 | .fetchOne()
38 | }
39 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/hypto/iam/server/db/repositories/PrincipalPoliciesRepo.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.db.repositories
2 |
3 | import com.hypto.iam.server.db.Tables.PRINCIPAL_POLICIES
4 | import com.hypto.iam.server.db.tables.Policies.POLICIES
5 | import com.hypto.iam.server.db.tables.pojos.PrincipalPolicies
6 | import com.hypto.iam.server.db.tables.records.PoliciesRecord
7 | import com.hypto.iam.server.db.tables.records.PrincipalPoliciesRecord
8 | import com.hypto.iam.server.extensions.PaginationContext
9 | import com.hypto.iam.server.extensions.customPaginate
10 | import com.hypto.iam.server.utils.Hrn
11 | import org.jooq.Record
12 | import org.jooq.Result
13 | import org.jooq.TableField
14 | import org.jooq.impl.DAOImpl
15 | import java.util.UUID
16 |
17 | object PrincipalPoliciesRepo : BaseRepo() {
18 | private val idFun = fun (principalPolicies: PrincipalPolicies): UUID = principalPolicies.id
19 |
20 | override suspend fun dao(): DAOImpl =
21 | txMan.getDao(PRINCIPAL_POLICIES, PrincipalPolicies::class.java, idFun)
22 |
23 | /**
24 | * Fetch records that have `principal_hrn = value`
25 | */
26 | suspend fun fetchByPrincipalHrn(value: String): Result =
27 | ctx("principalPolicies.fetchByPrincipalHrn")
28 | .selectFrom(PRINCIPAL_POLICIES)
29 | .where(PRINCIPAL_POLICIES.PRINCIPAL_HRN.equal(value))
30 | .fetch()
31 |
32 | suspend fun fetchPoliciesByUserHrnPaginated(
33 | userHrn: String,
34 | paginationContext: PaginationContext,
35 | ): Result {
36 | return customPaginate(
37 | ctx("principalPolicies.fetchByPrincipalHrnPaginated")
38 | .select(POLICIES.fields().asList())
39 | .from(
40 | PRINCIPAL_POLICIES.join(POLICIES).on(
41 | com.hypto.iam.server.db.Tables.POLICIES.HRN.eq(PRINCIPAL_POLICIES.POLICY_HRN),
42 | ),
43 | )
44 | .where(PRINCIPAL_POLICIES.PRINCIPAL_HRN.eq(userHrn)),
45 | PRINCIPAL_POLICIES.POLICY_HRN as TableField,
46 | paginationContext,
47 | ).fetchInto(POLICIES)
48 | }
49 |
50 | suspend fun insert(records: List): Result {
51 | // No way to return generated values
52 | // https://github.com/jooq/jooq/issues/3327
53 | // return ctx().batchInsert(records).execute()
54 |
55 | var insertStep = ctx("principalPolicies.insertBatch").insertInto(PRINCIPAL_POLICIES)
56 | for (i in 0 until records.size - 1) {
57 | insertStep = insertStep.set(records[i]).newRecord()
58 | }
59 | val lastRecord = records[records.size - 1]
60 |
61 | return insertStep.set(lastRecord).returning().fetch()
62 | }
63 |
64 | suspend fun delete(
65 | userHrn: Hrn,
66 | policies: List,
67 | ): Boolean =
68 | ctx("principalPolicies.delete")
69 | .deleteFrom(PRINCIPAL_POLICIES)
70 | .where(
71 | PRINCIPAL_POLICIES.PRINCIPAL_HRN.eq(userHrn.toString()),
72 | PRINCIPAL_POLICIES.POLICY_HRN.`in`(policies),
73 | )
74 | .execute() > 0
75 |
76 | suspend fun deleteByPrincipalHrn(principalHrn: String): Boolean =
77 | ctx("principalPolicies.deleteByPrincipalHrn")
78 | .deleteFrom(PRINCIPAL_POLICIES)
79 | .where(PRINCIPAL_POLICIES.PRINCIPAL_HRN.like("%$principalHrn%"))
80 | .execute() > 0
81 |
82 | suspend fun deleteByPolicyHrn(policyHrn: String): Boolean =
83 | ctx("principalPolicies.deleteByPrincipalHrn")
84 | .deleteFrom(PRINCIPAL_POLICIES)
85 | .where(PRINCIPAL_POLICIES.POLICY_HRN.eq(policyHrn))
86 | .execute() > 0
87 | }
88 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/hypto/iam/server/db/repositories/ResourceRepo.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.db.repositories
2 |
3 | import com.hypto.iam.server.db.Tables.RESOURCES
4 | import com.hypto.iam.server.db.tables.pojos.Resources
5 | import com.hypto.iam.server.db.tables.records.ResourcesRecord
6 | import com.hypto.iam.server.extensions.PaginationContext
7 | import com.hypto.iam.server.extensions.paginate
8 | import com.hypto.iam.server.utils.ResourceHrn
9 | import org.jooq.Result
10 | import org.jooq.impl.DAOImpl
11 | import java.time.LocalDateTime
12 |
13 | object ResourceRepo : BaseRepo() {
14 | private val idFun = fun (resource: Resources) = resource.hrn
15 |
16 | override suspend fun dao(): DAOImpl =
17 | txMan.getDao(RESOURCES, Resources::class.java, idFun)
18 |
19 | /**
20 | * Fetch a unique record that has `hrn = value`
21 | */
22 | suspend fun fetchByHrn(hrn: ResourceHrn): ResourcesRecord? =
23 | ctx("resources.fetchByHrn")
24 | .selectFrom(RESOURCES).where(RESOURCES.HRN.equal(hrn.toString())).fetchOne()
25 |
26 | suspend fun create(
27 | hrn: ResourceHrn,
28 | description: String,
29 | ): ResourcesRecord {
30 | val record =
31 | ResourcesRecord()
32 | .setHrn(hrn.toString())
33 | .setOrganizationId(hrn.organization)
34 | .setCreatedAt(LocalDateTime.now())
35 | .setUpdatedAt(LocalDateTime.now())
36 | .setDescription(description)
37 |
38 | record.attach(dao().configuration())
39 | record.store()
40 | return record
41 | }
42 |
43 | suspend fun update(
44 | hrn: ResourceHrn,
45 | description: String,
46 | ): ResourcesRecord? =
47 | ctx("resources.update")
48 | .update(RESOURCES)
49 | .set(RESOURCES.DESCRIPTION, description)
50 | .set(RESOURCES.UPDATED_AT, LocalDateTime.now())
51 | .where(RESOURCES.HRN.equal(hrn.toString()))
52 | .returning()
53 | .fetchOne()
54 |
55 | suspend fun delete(hrn: ResourceHrn): Boolean {
56 | val record = ResourcesRecord().setHrn(hrn.toString())
57 | record.attach(dao().configuration())
58 | return record.delete() > 0
59 | }
60 |
61 | suspend fun fetchByOrganizationIdPaginated(
62 | organizationId: String,
63 | paginationContext: PaginationContext,
64 | ): Result =
65 | ctx("resources.fetchPaginated")
66 | .selectFrom(RESOURCES)
67 | .where(RESOURCES.ORGANIZATION_ID.eq(organizationId))
68 | .paginate(RESOURCES.HRN, paginationContext)
69 | .fetch()
70 | }
71 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/hypto/iam/server/db/repositories/UserAuthProvidersRepo.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.db.repositories
2 |
3 | import com.hypto.iam.server.db.tables.pojos.UsersAuthProviders
4 | import com.hypto.iam.server.db.tables.records.UsersAuthProvidersRecord
5 | import org.jooq.impl.DAOImpl
6 |
7 | object UserAuthProvidersRepo : BaseRepo() {
8 | private val idFun = fun (usersAuthProviders: UsersAuthProviders) = usersAuthProviders.id
9 |
10 | override suspend fun dao(): DAOImpl =
11 | txMan.getDao(
12 | com.hypto.iam.server.db.tables.UsersAuthProviders.USERS_AUTH_PROVIDERS,
13 | UsersAuthProviders::class.java,
14 | idFun,
15 | )
16 | }
17 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/hypto/iam/server/db/repositories/UserAuthRepo.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.db.repositories
2 |
3 | import com.hypto.iam.server.db.Tables.USER_AUTH
4 | import com.hypto.iam.server.db.tables.pojos.UserAuth
5 | import com.hypto.iam.server.db.tables.records.UserAuthRecord
6 | import com.hypto.iam.server.utils.Hrn
7 | import org.jooq.DSLContext
8 | import org.jooq.JSONB
9 | import org.jooq.Record2
10 | import org.jooq.impl.DAOImpl
11 | import java.time.LocalDateTime
12 |
13 | typealias UserAuthPk = Record2
14 |
15 | object UserAuthRepo : BaseRepo() {
16 | @Suppress("ktlint:standard:blank-line-before-declaration")
17 | private fun getIdFun(dsl: DSLContext): (UserAuth) -> UserAuthPk {
18 | return fun (userAuth: UserAuth): UserAuthPk {
19 | return dsl.newRecord(
20 | USER_AUTH.USER_HRN,
21 | USER_AUTH.PROVIDER_NAME,
22 | )
23 | .values(userAuth.userHrn, userAuth.providerName)
24 | }
25 | }
26 |
27 | override suspend fun dao(): DAOImpl {
28 | return txMan.getDao(
29 | USER_AUTH,
30 | UserAuth::class.java,
31 | getIdFun(txMan.dsl()),
32 | )
33 | }
34 |
35 | suspend fun fetchByUserHrnAndProviderName(
36 | hrn: String,
37 | providerName: String,
38 | ): UserAuthRecord? {
39 | return UserAuthRepo
40 | .ctx("userAuth.findByUserHrnAndProviderName")
41 | .selectFrom(USER_AUTH)
42 | .where(USER_AUTH.USER_HRN.eq(hrn).and(USER_AUTH.PROVIDER_NAME.eq(providerName)))
43 | .fetchOne()
44 | }
45 |
46 | suspend fun create(
47 | hrn: String,
48 | providerName: String,
49 | authMetadata: JSONB?,
50 | ): UserAuthRecord {
51 | val logTimestamp = LocalDateTime.now()
52 | val record =
53 | UserAuthRecord()
54 | .setUserHrn(hrn)
55 | .setProviderName(providerName)
56 | .setAuthMetadata(authMetadata)
57 | .setCreatedAt(logTimestamp)
58 | .setUpdatedAt(logTimestamp)
59 |
60 | record.attach(dao().configuration())
61 | record.store()
62 | return record
63 | }
64 |
65 | suspend fun fetchUserAuth(
66 | userHrn: Hrn,
67 | ): List {
68 | return ctx("userAuth.fetchUserAuth").selectFrom(USER_AUTH)
69 | .where(USER_AUTH.USER_HRN.eq(userHrn.toString()))
70 | .fetch()
71 | }
72 |
73 | suspend fun deleteByUserHrn(userHrn: String): Boolean {
74 | val count =
75 | ctx("userAuth.deleteByUserHrn")
76 | .deleteFrom(USER_AUTH)
77 | .where(USER_AUTH.USER_HRN.eq(userHrn))
78 | .execute()
79 | return count > 0
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/hypto/iam/server/exceptions/ApplicationExceptions.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.exceptions
2 |
3 | class InternalException(s: String, ex: Exception? = null) : Exception(s, ex)
4 |
5 | class UnknownException(s: String, ex: Exception? = null) : Exception(s, ex)
6 |
7 | class EntityAlreadyExistsException(s: String, ex: Exception? = null) : Exception(s, ex)
8 |
9 | class EntityNotFoundException(s: String, ex: Exception? = null) : Exception(s, ex)
10 |
11 | class JwtExpiredException(s: String, ex: Exception? = null) : Exception(s, ex)
12 |
13 | class InvalidJwtException(s: String, ex: Exception? = null) : Exception(s, ex)
14 |
15 | class PolicyFormatException(s: String, ex: Exception? = null) : Exception(s, ex)
16 |
17 | class PublicKeyExpiredException(s: String, ex: Exception? = null) : Exception(s, ex)
18 |
19 | class PasscodeExpiredException(s: String, ex: Exception? = null) : Exception(s, ex)
20 |
21 | class PasscodeLimitExceededException(s: String, ex: Exception? = null) : Exception(s, ex)
22 |
23 | class ActionNotPermittedException(s: String, ex: Exception? = null) : Exception(s, ex)
24 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/hypto/iam/server/exceptions/DbExceptionHandler.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.exceptions
2 |
3 | import io.ktor.server.plugins.BadRequestException
4 | import org.jooq.exception.DataAccessException
5 | import kotlin.reflect.KClass
6 | import kotlin.reflect.full.primaryConstructor
7 |
8 | object DbExceptionHandler {
9 | data class DbExceptionMap(
10 | val errorMessage: String,
11 | val constraintRegex: Regex,
12 | val appExceptions: Map, String>>,
13 | )
14 |
15 | private val customExceptions: Set> =
16 | setOf(
17 | BadRequestException::class,
18 | EntityNotFoundException::class,
19 | EntityAlreadyExistsException::class,
20 | )
21 | private val duplicateConstraintRegex = "\"(.+)?\"".toRegex()
22 | private val foreignKeyConstraintRegex = "foreign key constraint \"(.+)?\"".toRegex()
23 |
24 | private val duplicateExceptions = mapOf, String>>()
25 |
26 | private val foreignKeyExceptions = mapOf, String>>()
27 |
28 | private val dbExceptionMap =
29 | listOf(
30 | DbExceptionMap(
31 | "duplicate key value violates unique constraint",
32 | duplicateConstraintRegex,
33 | duplicateExceptions,
34 | ),
35 | DbExceptionMap(
36 | "violates foreign key constraint",
37 | foreignKeyConstraintRegex,
38 | foreignKeyExceptions,
39 | ),
40 | )
41 |
42 | fun mapToApplicationException(e: DataAccessException): Exception {
43 | var finalException: Exception? = null
44 | val causeMessage = e.cause?.message ?: e.message
45 |
46 | e.cause?.let {
47 | if (customExceptions.contains(it::class)) {
48 | return it as Exception
49 | }
50 | }
51 |
52 | dbExceptionMap.forEach {
53 | if (causeMessage?.contains(it.errorMessage) == true) {
54 | val constraintKey = it.constraintRegex.find(causeMessage)?.groups?.get(1)?.value
55 | val exceptionPair = it.appExceptions[constraintKey]
56 | if (exceptionPair != null) {
57 | finalException = exceptionPair.first.primaryConstructor?.call(exceptionPair.second, e)
58 | }
59 | }
60 | }
61 |
62 | return finalException ?: InternalException("Unknown error occurred", e)
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/hypto/iam/server/extensions/Routing.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.extensions
2 |
3 | import com.hypto.iam.server.security.getAuthorizationDetails
4 | import com.hypto.iam.server.security.withPermission
5 | import io.ktor.server.application.ApplicationCall
6 | import io.ktor.server.routing.Route
7 | import io.ktor.server.routing.delete
8 | import io.ktor.server.routing.get
9 | import io.ktor.server.routing.patch
10 | import io.ktor.server.routing.post
11 | import io.ktor.util.pipeline.PipelineContext
12 |
13 | // Extension functions to support multiple paths for a single body
14 | fun Route.get(
15 | vararg paths: String,
16 | body: suspend PipelineContext.(Unit) -> Unit,
17 | ) {
18 | for (path in paths) {
19 | get(path, body)
20 | }
21 | }
22 |
23 | fun Route.patch(
24 | vararg paths: String,
25 | body: suspend PipelineContext.(Unit) -> Unit,
26 | ) {
27 | for (path in paths) {
28 | patch(path, body)
29 | }
30 | }
31 |
32 | fun Route.post(
33 | vararg paths: String,
34 | body: suspend PipelineContext.(Unit) -> Unit,
35 | ) {
36 | for (path in paths) {
37 | post(path, body)
38 | }
39 | }
40 |
41 | fun Route.delete(
42 | vararg paths: String,
43 | body: suspend PipelineContext.(Unit) -> Unit,
44 | ) {
45 | for (path in paths) {
46 | delete(path, body)
47 | }
48 | }
49 |
50 | // Functions composing withPermission and routing builder functions like get, post, etc.
51 | fun Route.getWithPermission(
52 | routeOptions: List,
53 | action: String,
54 | validateOrgIdFromPath: Boolean = true,
55 | body: suspend PipelineContext.(Unit) -> Unit,
56 | ) {
57 | for (routeOption in routeOptions) {
58 | withPermission(
59 | action,
60 | getAuthorizationDetails(routeOption),
61 | validateOrgIdFromPath,
62 | ) {
63 | get(routeOption.pathTemplate, body)
64 | }
65 | }
66 | }
67 |
68 | fun Route.patchWithPermission(
69 | routeOptions: List,
70 | action: String,
71 | validateOrgIdFromPath: Boolean = true,
72 | body: suspend PipelineContext.(Unit) -> Unit,
73 | ) {
74 | for (routeOption in routeOptions) {
75 | withPermission(
76 | action,
77 | getAuthorizationDetails(routeOption),
78 | validateOrgIdFromPath,
79 | ) {
80 | patch(routeOption.pathTemplate, body)
81 | }
82 | }
83 | }
84 |
85 | fun Route.postWithPermission(
86 | routeOptions: List,
87 | action: String,
88 | validateOrgIdFromPath: Boolean = true,
89 | body: suspend PipelineContext.(Unit) -> Unit,
90 | ) {
91 | for (routeOption in routeOptions) {
92 | withPermission(
93 | action,
94 | getAuthorizationDetails(routeOption),
95 | validateOrgIdFromPath,
96 | ) {
97 | post(routeOption.pathTemplate, body)
98 | }
99 | }
100 | }
101 |
102 | fun Route.deleteWithPermission(
103 | routeOptions: List,
104 | action: String,
105 | validateOrgIdFromPath: Boolean = true,
106 | body: suspend PipelineContext.(Unit) -> Unit,
107 | ) {
108 | for (routeOption in routeOptions) {
109 | withPermission(
110 | action,
111 | getAuthorizationDetails(routeOption),
112 | validateOrgIdFromPath,
113 | ) {
114 | delete(routeOption.pathTemplate, body)
115 | }
116 | }
117 | }
118 |
119 | data class RouteOption(
120 | val pathTemplate: String,
121 | val resourceNameIndex: Int,
122 | val resourceInstanceIndex: Int,
123 | val organizationIdIndex: Int? = null,
124 | val subOrganizationNameIndex: Int? = null,
125 | )
126 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/hypto/iam/server/extensions/SubOrganizationUtils.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.extensions
2 |
3 | import io.ktor.server.plugins.BadRequestException
4 | import java.util.Base64
5 |
6 | /**
7 | * For sub organizations, we have to encode the email address to include the org details in the email
8 | * so that users can have unique credentials across orgs.
9 | *
10 | * To support this, we are using the email local addressing scheme. This scheme allows us to add a suffix to email
11 | * address.
12 | * Ex: hello@hypto.in can be encoded as hello+@hypto.in
13 | *
14 | * With this option, same email address hello@hypto.in can configure two different passwords for orgId1 and orgId2.
15 | */
16 | fun getEncodedEmail(
17 | organizationId: String,
18 | subOrganizationName: String?,
19 | email: String,
20 | ) =
21 | if (subOrganizationName != null) {
22 | encodeSubOrgUserEmail(
23 | email,
24 | organizationId,
25 | )
26 | } else {
27 | email
28 | }
29 |
30 | private fun encodeSubOrgUserEmail(
31 | email: String,
32 | organizationId: String,
33 | ): String {
34 | val emailParts = email.split("@").takeIf { it.size == 2 } ?: throw BadRequestException("Invalid email address")
35 | val localPart = emailParts[0]
36 | val domainPart = emailParts[1]
37 | val subAddress = Base64.getEncoder().encodeToString(organizationId.toByteArray())
38 | return "$localPart+$subAddress@$domainPart"
39 | }
40 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/hypto/iam/server/extensions/Utils.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.extensions
2 |
3 | import java.time.LocalDateTime
4 | import java.time.OffsetDateTime
5 | import java.time.ZoneOffset
6 |
7 | fun LocalDateTime.toUTCOffset(): OffsetDateTime {
8 | return this.atOffset(ZoneOffset.UTC)
9 | }
10 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/hypto/iam/server/extensions/Validators.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.extensions
2 |
3 | import com.google.gson.Gson
4 | import com.hypto.iam.server.Constants.Companion.MAX_NAME_LENGTH
5 | import com.hypto.iam.server.Constants.Companion.MIN_LENGTH
6 | import com.hypto.iam.server.di.getKoinInstance
7 | import com.hypto.iam.server.utils.HrnFactory
8 | import com.hypto.iam.server.utils.HrnParseException
9 | import io.konform.validation.Invalid
10 | import io.konform.validation.Validation
11 | import io.konform.validation.ValidationBuilder
12 | import io.konform.validation.jsonschema.maxLength
13 | import io.konform.validation.jsonschema.minLength
14 | import io.konform.validation.jsonschema.pattern
15 | import java.time.LocalDateTime
16 | import java.time.format.DateTimeFormatter
17 | import java.time.format.DateTimeParseException
18 | import kotlin.reflect.KProperty1
19 |
20 | private val gson: Gson = getKoinInstance()
21 |
22 | fun ValidationBuilder.oneOf(
23 | instance: T,
24 | values: List>,
25 | ) =
26 | addConstraint("must have only one of the provided attributes") {
27 | println(values)
28 | return@addConstraint (values.mapNotNull { it.get(instance) }.size == 1)
29 | }
30 |
31 | fun ValidationBuilder.oneOrMoreOf(
32 | instance: T,
33 | values: List>,
34 | ) =
35 | addConstraint("must have at least one of the provided attributes") {
36 | return@addConstraint values.mapNotNull { it.get(instance) }.isNotEmpty()
37 | }
38 |
39 | enum class TimeNature { ANY, PAST, FUTURE }
40 |
41 | fun ValidationBuilder.dateTime(
42 | format: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME,
43 | nature: TimeNature = TimeNature.ANY,
44 | ) = addConstraint("must be a valid date time string") {
45 | try {
46 | val dateTime = LocalDateTime.parse(it, format)
47 | when (nature) {
48 | TimeNature.ANY -> {
49 | true
50 | }
51 | TimeNature.PAST -> {
52 | dateTime.isBefore(LocalDateTime.now())
53 | }
54 | TimeNature.FUTURE -> {
55 | dateTime.isAfter(LocalDateTime.now())
56 | }
57 | }
58 | } catch (e: DateTimeParseException) {
59 | false
60 | }
61 | }
62 |
63 | fun ValidationBuilder.hrn() =
64 | addConstraint("must be a valid hrn") {
65 | try {
66 | HrnFactory.getHrn(it)
67 | true
68 | } catch (e: HrnParseException) {
69 | false
70 | }
71 | }
72 |
73 | fun ValidationBuilder.noEndSpaces() =
74 | addConstraint("must not have spaces at either ends") {
75 | it.trim() == it
76 | }
77 |
78 | const val RESOURCE_NAME_REGEX = "^[a-zA-Z0-9_-]*\$"
79 | const val RESOURCE_NAME_REGEX_HINT = "Only characters A..Z, a..z, 0-9, _ and - are supported."
80 | val nameCheck =
81 | Validation {
82 | minLength(MIN_LENGTH) hint "Minimum length expected is $MIN_LENGTH"
83 | maxLength(MAX_NAME_LENGTH) hint "Maximum length supported for name is $MAX_NAME_LENGTH characters"
84 | pattern(RESOURCE_NAME_REGEX) hint RESOURCE_NAME_REGEX_HINT
85 | }
86 |
87 | /**
88 | * Extension method to throw exception when request object don't meet the constraint.
89 | */
90 | fun Validation.validateAndThrowOnFailure(value: T): T {
91 | val result = validate(value)
92 | if (result is Invalid) {
93 | throw IllegalArgumentException(gson.toJson(result.errors))
94 | }
95 | return value
96 | }
97 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/hypto/iam/server/idp/Configuration.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.idp
2 |
3 | data class Configuration(val passwordPolicy: PasswordPolicy)
4 |
5 | data class PasswordPolicy(
6 | val minLength: Int = 8,
7 | val requireUpperCase: Boolean = true,
8 | val requireLowerCase: Boolean = true,
9 | val requireNumber: Boolean = true,
10 | val requireSymbols: Boolean = true,
11 | )
12 |
13 | // TODO: Add configurations related to MFA, Email verification flows
14 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/hypto/iam/server/idp/IdentityProvider.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.idp
2 |
3 | /**
4 | * Identity provider interface to manage users
5 | */
6 | interface IdentityProvider {
7 | enum class IdentitySource { AWS_COGNITO }
8 |
9 | suspend fun createIdentityGroup(
10 | name: String,
11 | configuration: Configuration = Configuration(PasswordPolicy()),
12 | ): IdentityGroup
13 |
14 | suspend fun deleteIdentityGroup(identityGroup: IdentityGroup)
15 |
16 | suspend fun createUser(
17 | context: RequestContext,
18 | identityGroup: IdentityGroup,
19 | userCredentials: UserCredentials,
20 | ): User
21 |
22 | /**
23 | * Gets user details for the given username. If no user available with the username this will throw Exception
24 | */
25 | suspend fun getUser(
26 | identityGroup: IdentityGroup,
27 | userName: String,
28 | isAliasUsername: Boolean = false,
29 | ): User
30 |
31 | suspend fun updateUser(
32 | identityGroup: IdentityGroup,
33 | userName: String,
34 | name: String?,
35 | phone: String?,
36 | status: com.hypto.iam.server.models.User.Status?,
37 | verified: Boolean?,
38 | ): User
39 |
40 | suspend fun listUsers(
41 | identityGroup: IdentityGroup,
42 | pageToken: String?,
43 | limit: Int?,
44 | ): Pair, NextToken?>
45 |
46 | suspend fun deleteUser(
47 | identityGroup: IdentityGroup,
48 | userName: String,
49 | )
50 |
51 | suspend fun getIdentitySource(): IdentitySource
52 |
53 | suspend fun authenticate(
54 | identityGroup: IdentityGroup,
55 | userName: String,
56 | password: String,
57 | ): User
58 |
59 | suspend fun getUserByEmail(
60 | identityGroup: IdentityGroup,
61 | email: String,
62 | ): User
63 |
64 | suspend fun setUserPassword(
65 | identityGroup: IdentityGroup,
66 | userName: String,
67 | password: String,
68 | )
69 | }
70 |
71 | typealias NextToken = String
72 |
73 | class UnsupportedCredentialsException(message: String) : Exception(message)
74 |
75 | class UserNotFoundException(message: String) : Exception(message)
76 |
77 | class UserAlreadyExistException(message: String) : Exception(message)
78 |
79 | data class IdentityGroup(
80 | val id: String,
81 | val name: String,
82 | val identitySource: IdentityProvider.IdentitySource,
83 | val metadata: Map = mapOf(),
84 | )
85 |
86 | data class User(
87 | val username: String,
88 | val preferredUsername: String?,
89 | val name: String,
90 | val phoneNumber: String,
91 | val email: String,
92 | val loginAccess: Boolean,
93 | val isEnabled: Boolean,
94 | val createdBy: String,
95 | val verified: Boolean,
96 | val createdAt: String,
97 | )
98 |
99 | interface UserCredentials {
100 | val username: String
101 | }
102 |
103 | data class PasswordCredentials(
104 | override val username: String,
105 | val name: String?,
106 | val email: String,
107 | val phoneNumber: String,
108 | val password: String,
109 | val preferredUsername: String?,
110 | ) : UserCredentials
111 |
112 | data class AccessTokenCredentials(
113 | override val username: String,
114 | val email: String,
115 | val phoneNumber: String,
116 | val accessToken: String,
117 | ) : UserCredentials
118 |
119 | data class RequestContext(
120 | val organizationId: String,
121 | val subOrganizationName: String?,
122 | val requestedPrincipal: String,
123 | val verified: Boolean,
124 | )
125 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/hypto/iam/server/lambdas/AuditEventHandler.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.lambdas
2 |
3 | import com.amazonaws.services.lambda.runtime.Context
4 | import com.amazonaws.services.lambda.runtime.RequestHandler
5 | import com.amazonaws.services.lambda.runtime.events.SQSEvent
6 | import com.google.gson.Gson
7 | import com.hypto.iam.server.db.repositories.AuditEntriesRepo
8 | import com.hypto.iam.server.db.tables.pojos.AuditEntries
9 | import com.hypto.iam.server.di.getKoinInstance
10 | import kotlinx.coroutines.runBlocking
11 |
12 | class AuditEventHandler : RequestHandler {
13 | private val auditEntryRepo: AuditEntriesRepo = getKoinInstance()
14 |
15 | override fun handleRequest(
16 | input: SQSEvent,
17 | context: Context?,
18 | ) {
19 | val entries = input.records.map { auditEntriesFrom(it) }
20 |
21 | val state =
22 | runBlocking {
23 | auditEntryRepo.batchInsert(entries)
24 | }
25 |
26 | // TODO: Verify if the entry already exists in case of failed messages and ignore them.
27 | if (!state) {
28 | throw Exception("few Batch inserts failed")
29 | }
30 | }
31 |
32 | companion object {
33 | val gson: Gson = getKoinInstance()
34 |
35 | fun auditEntriesFrom(message: SQSEvent.SQSMessage): AuditEntries {
36 | return gson.fromJson(message.body, AuditEntries::class.java)
37 | }
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/hypto/iam/server/plugins/globalcalldata/CallCache.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.plugins.globalcalldata
2 |
3 | import java.util.concurrent.ConcurrentHashMap
4 | import kotlin.coroutines.CoroutineContext
5 |
6 | class CallCache {
7 | private val data = ConcurrentHashMap>()
8 |
9 | fun create(context: CoroutineContext) {
10 | data[context] = hashMapOf()
11 | }
12 |
13 | fun set(
14 | context: CoroutineContext,
15 | key: Key,
16 | value: V,
17 | ) {
18 | data[context]?.put(key, value)
19 | }
20 |
21 | fun get(
22 | context: CoroutineContext,
23 | key: Key,
24 | ): V? {
25 | return data[context]?.get(key) as V?
26 | }
27 |
28 | fun remove(context: CoroutineContext) {
29 | data.remove(context)
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/hypto/iam/server/plugins/globalcalldata/CallData.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.plugins.globalcalldata
2 |
3 | import io.ktor.server.application.ApplicationCall
4 | import kotlin.coroutines.CoroutineContext
5 | import kotlin.coroutines.coroutineContext
6 |
7 | interface Key
8 |
9 | object Call : Key
10 |
11 | open class CallData(context: CoroutineContext) {
12 | private val delegate = CallDataDelegate(context)
13 | val call: ApplicationCall by delegate.propNotNull(Call)
14 | }
15 |
16 | suspend fun callData(): CallData {
17 | if (!GlobalCallData.enabled) {
18 | throw IllegalAccessException("GlobalCallData Feature is not enabled!")
19 | }
20 |
21 | return CallData(coroutineContext)
22 | }
23 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/hypto/iam/server/plugins/globalcalldata/CallDataDelegate.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.plugins.globalcalldata
2 |
3 | import kotlin.coroutines.CoroutineContext
4 | import kotlin.reflect.KProperty
5 |
6 | class CallDataDelegate(private val context: CoroutineContext) {
7 | fun prop(key: Key): DelegateProperty {
8 | return DelegateProperty(key)
9 | }
10 |
11 | fun propNotNull(key: Key): DelegatePropertyNotNull {
12 | return DelegatePropertyNotNull(key)
13 | }
14 |
15 | inner class DelegateProperty(private val key: Key) {
16 | operator fun getValue(
17 | thisRef: Any?,
18 | property: KProperty<*>,
19 | ): V? {
20 | return GlobalCallData.callCache.get(context, key)
21 | }
22 |
23 | operator fun setValue(
24 | thisRef: Any?,
25 | property: KProperty<*>,
26 | value: V,
27 | ) {
28 | GlobalCallData.callCache.set(context, key, value)
29 | }
30 | }
31 |
32 | inner class DelegatePropertyNotNull(private val key: Key) {
33 | private val delegate = DelegateProperty(key)
34 |
35 | operator fun getValue(
36 | thisRef: Any?,
37 | property: KProperty<*>,
38 | ): V {
39 | return delegate.getValue(thisRef, property)!!
40 | }
41 |
42 | operator fun setValue(
43 | thisRef: Any?,
44 | property: KProperty<*>,
45 | value: V,
46 | ) {
47 | delegate.setValue(thisRef, property, value)
48 | }
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/hypto/iam/server/plugins/globalcalldata/GlobalCallData.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.plugins.globalcalldata
2 |
3 | import io.ktor.server.application.ApplicationCall
4 | import io.ktor.server.application.ApplicationCallPipeline
5 | import io.ktor.server.application.BaseApplicationPlugin
6 | import io.ktor.server.application.call
7 | import io.ktor.server.response.ApplicationSendPipeline
8 | import io.ktor.util.AttributeKey
9 | import io.ktor.util.pipeline.PipelineContext
10 | import io.ktor.util.pipeline.PipelinePhase
11 |
12 | class GlobalCallData(configuration: Configuration) {
13 | class Configuration
14 |
15 | private fun interceptBeforeReceive(context: PipelineContext) {
16 | callCache.create(context.coroutineContext)
17 | callCache.set(context.coroutineContext, Call, context.call)
18 | }
19 |
20 | private fun interceptAfterSend(context: PipelineContext) {
21 | callCache.remove(context.coroutineContext)
22 | }
23 |
24 | /**
25 | * Installable feature for [GlobalCallData].
26 | */
27 | companion object Feature : BaseApplicationPlugin {
28 | override val key = AttributeKey("GlobalCallData")
29 | val callCache = CallCache()
30 | var enabled = false
31 |
32 | val globalCallDataPhase = PipelinePhase("GlobalCallData")
33 | val globalCallDataCleanupPhase = PipelinePhase("GlobalCallDataCleanup")
34 |
35 | override fun install(
36 | pipeline: ApplicationCallPipeline,
37 | configure: Configuration.() -> Unit,
38 | ): GlobalCallData {
39 | val configuration = Configuration().apply(configure)
40 |
41 | return GlobalCallData(configuration).also { callDataFeature ->
42 | pipeline.insertPhaseAfter(ApplicationCallPipeline.Features, globalCallDataPhase)
43 | pipeline.intercept(globalCallDataPhase) {
44 | callDataFeature.interceptBeforeReceive(this)
45 | }
46 |
47 | pipeline.sendPipeline.insertPhaseAfter(ApplicationSendPipeline.After, globalCallDataCleanupPhase)
48 | pipeline.sendPipeline.intercept(globalCallDataCleanupPhase) {
49 | callDataFeature.interceptAfterSend(this)
50 | }
51 |
52 | enabled = true
53 | }
54 | }
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/hypto/iam/server/service/AuthProviderService.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.service
2 |
3 | import com.hypto.iam.server.db.repositories.AuthProviderRepo
4 | import com.hypto.iam.server.extensions.PaginationContext
5 | import com.hypto.iam.server.extensions.from
6 | import com.hypto.iam.server.models.AuthProvider
7 | import com.hypto.iam.server.models.AuthProviderPaginatedResponse
8 | import org.koin.core.component.KoinComponent
9 | import org.koin.core.component.inject
10 |
11 | class AuthProviderServiceImpl : KoinComponent, AuthProviderService {
12 | private val authProviderRepo: AuthProviderRepo by inject()
13 |
14 | override suspend fun listAuthProvider(context: PaginationContext): AuthProviderPaginatedResponse {
15 | val authProviders = authProviderRepo.fetchAuthProvidersPaginated(context)
16 | val newContext = PaginationContext.from(authProviders.lastOrNull()?.providerName, context)
17 | return AuthProviderPaginatedResponse(
18 | authProviders.map { AuthProvider.from(it) },
19 | newContext.nextToken,
20 | newContext.toOptions(),
21 | )
22 | }
23 | }
24 |
25 | interface AuthProviderService {
26 | suspend fun listAuthProvider(
27 | context: PaginationContext,
28 | ): AuthProviderPaginatedResponse
29 | }
30 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/hypto/iam/server/service/DatabaseFactory.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.service
2 |
3 | import com.hypto.iam.server.configs.AppConfig
4 | import com.hypto.iam.server.db.listeners.DeleteOrUpdateWithoutWhereListener
5 | import com.zaxxer.hikari.HikariConfig
6 | import com.zaxxer.hikari.HikariDataSource
7 | import org.jooq.Configuration
8 | import org.jooq.SQLDialect
9 | import org.jooq.impl.DefaultConfiguration
10 | import org.jooq.impl.DefaultExecuteListenerProvider
11 | import org.koin.core.component.KoinComponent
12 | import org.koin.core.component.inject
13 | import java.sql.Connection
14 |
15 | object DatabaseFactory : KoinComponent {
16 | private val appConfig: AppConfig by inject()
17 |
18 | val pool: HikariDataSource =
19 | HikariDataSource(
20 | HikariConfig().apply {
21 | driverClassName = "org.postgresql.Driver"
22 | jdbcUrl = appConfig.database.jdbcUrl
23 | maximumPoolSize = appConfig.database.maximumPoolSize
24 | minimumIdle = appConfig.database.minimumIdle
25 | isAutoCommit = appConfig.database.isAutoCommit
26 | transactionIsolation = appConfig.database.transactionIsolation
27 | username = appConfig.database.username
28 | password = appConfig.database.password
29 | },
30 | )
31 |
32 | private val daoConfiguration =
33 | DefaultConfiguration()
34 | .set(SQLDialect.POSTGRES)
35 | .set(pool)
36 | .deriveSettings {
37 | // https://www.jooq.org/doc/latest/manual/sql-building/dsl-context/custom-settings/settings-return-all-on-store/
38 | it.withReturnAllOnUpdatableRecord(true)
39 | }.setAppending(DefaultExecuteListenerProvider(DeleteOrUpdateWithoutWhereListener()))
40 |
41 | fun getConfiguration(): Configuration {
42 | return daoConfiguration
43 | }
44 |
45 | fun getConnection(): Connection {
46 | return pool.connection
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/hypto/iam/server/service/PolicyTemplatesService.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.service
2 |
3 | import com.hypto.iam.server.Constants.Companion.ORGANIZATION_ID_KEY
4 | import com.hypto.iam.server.Constants.Companion.USER_HRN_KEY
5 | import com.hypto.iam.server.db.repositories.PolicyTemplatesRepo
6 | import com.hypto.iam.server.db.repositories.RawPolicyPayload
7 | import com.hypto.iam.server.db.tables.records.PoliciesRecord
8 | import com.hypto.iam.server.models.User
9 | import com.hypto.iam.server.utils.IamResources
10 | import com.hypto.iam.server.utils.ResourceHrn
11 | import net.pwall.mustache.Template
12 | import net.pwall.mustache.parser.MustacheParserException
13 | import org.koin.core.component.KoinComponent
14 | import org.koin.core.component.inject
15 |
16 | class PolicyTemplatesServiceImpl : KoinComponent, PolicyTemplatesService {
17 | private val policyTemplateRepo: PolicyTemplatesRepo by inject()
18 | private val policyService: PolicyService by inject()
19 |
20 | override suspend fun createPersistAndReturnRootPolicyRecordsForOrganization(
21 | organizationId: String,
22 | user: User,
23 | ): List {
24 | val templateVariablesMap = mapOf(ORGANIZATION_ID_KEY to organizationId, USER_HRN_KEY to user.hrn)
25 | val policyTemplates = policyTemplateRepo.fetchActivePolicyTemplatesForOrgCreation()
26 | val adminPolicyNames = policyTemplates.mapNotNullTo(mutableSetOf()) { if (it.isRootPolicy) it.name else null }
27 |
28 | val rawPolicyPayloadsList =
29 | policyTemplates.map {
30 | val template =
31 | try {
32 | Template.parse(it.statements)
33 | } catch (e: MustacheParserException) {
34 | throw IllegalStateException("Invalid template ${it.name} - ${e.localizedMessage}", e)
35 | }
36 | RawPolicyPayload(
37 | hrn = ResourceHrn(organizationId, "", IamResources.POLICY, it.name),
38 | description = it.description,
39 | statements = template.processToString(templateVariablesMap),
40 | )
41 | }
42 |
43 | val policies = policyService.batchCreatePolicyRaw(organizationId, rawPolicyPayloadsList)
44 | return policies.filter {
45 | adminPolicyNames.contains(ResourceHrn(it.hrn).resourceInstance)
46 | }
47 | }
48 | }
49 |
50 | interface PolicyTemplatesService {
51 | suspend fun createPersistAndReturnRootPolicyRecordsForOrganization(
52 | organizationId: String,
53 | user: User,
54 | ): List
55 | }
56 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/hypto/iam/server/service/PrincipalPolicyService.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.service
2 |
3 | import com.hypto.iam.server.db.repositories.PoliciesRepo
4 | import com.hypto.iam.server.db.repositories.PrincipalPoliciesRepo
5 | import com.hypto.iam.server.db.tables.records.PrincipalPoliciesRecord
6 | import com.hypto.iam.server.models.BaseSuccessResponse
7 | import com.hypto.iam.server.utils.Hrn
8 | import com.hypto.iam.server.utils.ResourceHrn
9 | import com.hypto.iam.server.utils.policy.PolicyBuilder
10 | import com.hypto.iam.server.utils.policy.PolicyVariables
11 | import mu.KotlinLogging
12 | import org.koin.core.component.KoinComponent
13 | import org.koin.core.component.inject
14 | import java.time.LocalDateTime
15 |
16 | private val logger = KotlinLogging.logger {}
17 |
18 | class PrincipalPolicyServiceImpl : PrincipalPolicyService, KoinComponent {
19 | private val policiesRepo: PoliciesRepo by inject()
20 | private val principalPoliciesRepo: PrincipalPoliciesRepo by inject()
21 |
22 | override suspend fun fetchEntitlements(userHrn: String): PolicyBuilder {
23 | val principalPolicies = principalPoliciesRepo.fetchByPrincipalHrn(userHrn)
24 | val hrn = ResourceHrn(userHrn)
25 | val policyBuilder = PolicyBuilder().withPolicyVariables(PolicyVariables(organizationId = hrn.organization, userHrn = userHrn, userId = hrn.resourceInstance))
26 | principalPolicies.forEach {
27 | val policy = policiesRepo.fetchByHrn(it.policyHrn)!!
28 | logger.info { policy.statements }
29 |
30 | policyBuilder.withPolicy(policy).withPrincipalPolicy(it)
31 | }
32 |
33 | return policyBuilder
34 | }
35 |
36 | override suspend fun attachPoliciesToUser(
37 | principal: Hrn,
38 | policies: List,
39 | ): BaseSuccessResponse {
40 | require(policies.isNotEmpty()) {
41 | "No policy Hrns provided to attach"
42 | }
43 |
44 | require(policiesRepo.existsByIds(policies.map { it.toString() })) {
45 | "Invalid policies found"
46 | }
47 |
48 | principalPoliciesRepo.insert(
49 | policies.map {
50 | PrincipalPoliciesRecord()
51 | .setPrincipalHrn(principal.toString())
52 | .setPolicyHrn(it.toString())
53 | .setCreatedAt(LocalDateTime.now())
54 | },
55 | )
56 |
57 | return BaseSuccessResponse(true)
58 | }
59 |
60 | override suspend fun detachPoliciesToUser(
61 | principal: Hrn,
62 | policies: List,
63 | ): BaseSuccessResponse {
64 | principalPoliciesRepo.delete(principal, policies.map { it.toString() })
65 | return BaseSuccessResponse(true)
66 | }
67 | }
68 |
69 | interface PrincipalPolicyService {
70 | suspend fun fetchEntitlements(userHrn: String): PolicyBuilder
71 |
72 | suspend fun attachPoliciesToUser(
73 | principal: Hrn,
74 | policies: List,
75 | ): BaseSuccessResponse
76 |
77 | suspend fun detachPoliciesToUser(
78 | principal: Hrn,
79 | policies: List,
80 | ): BaseSuccessResponse
81 | }
82 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/hypto/iam/server/service/SubOrganizationService.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.service
2 |
3 | import com.hypto.iam.server.db.repositories.SubOrganizationRepo
4 | import com.hypto.iam.server.exceptions.EntityNotFoundException
5 | import com.hypto.iam.server.extensions.PaginationContext
6 | import com.hypto.iam.server.extensions.from
7 | import com.hypto.iam.server.models.BaseSuccessResponse
8 | import com.hypto.iam.server.models.SubOrganization
9 | import com.hypto.iam.server.models.SubOrganizationsPaginatedResponse
10 | import org.koin.core.component.KoinComponent
11 | import org.koin.core.component.inject
12 |
13 | class SubOrganizationServiceImpl : KoinComponent, SubOrganizationService {
14 | private val subOrganizationRepo: SubOrganizationRepo by inject()
15 |
16 | override suspend fun createSubOrganization(
17 | organizationId: String,
18 | subOrganizationName: String,
19 | description: String?,
20 | ): SubOrganization {
21 | val subOrganizationRecord = subOrganizationRepo.create(organizationId, subOrganizationName, description)
22 | return SubOrganization.from(subOrganizationRecord)
23 | }
24 |
25 | override suspend fun getSubOrganization(
26 | organizationId: String,
27 | subOrganizationName: String,
28 | ): SubOrganization {
29 | val subOrganizationRecord =
30 | subOrganizationRepo.fetchById(organizationId, subOrganizationName) ?: throw EntityNotFoundException(
31 | "SubOrganization with " +
32 | "name [$subOrganizationName] not found",
33 | )
34 | return SubOrganization.from(subOrganizationRecord)
35 | }
36 |
37 | override suspend fun listSubOrganizations(
38 | organizationId: String,
39 | context: PaginationContext,
40 | ): SubOrganizationsPaginatedResponse {
41 | val subOrganizationsRecord = subOrganizationRepo.fetchSubOrganizationsPaginated(organizationId, context)
42 | val newContext = PaginationContext.from(subOrganizationsRecord.lastOrNull()?.name, context)
43 | return SubOrganizationsPaginatedResponse(
44 | subOrganizationsRecord.map { SubOrganization.from(it) },
45 | newContext.nextToken,
46 | newContext.toOptions(),
47 | )
48 | }
49 |
50 | override suspend fun updateSubOrganization(
51 | organizationId: String,
52 | subOrganizationName: String,
53 | updatedDescription: String?,
54 | ): SubOrganization {
55 | val subOrganizationRecord =
56 | subOrganizationRepo
57 | .update(organizationId, subOrganizationName, updatedDescription)
58 | ?: throw EntityNotFoundException("SubOrganization with id [$subOrganizationName] not found")
59 | return SubOrganization.from(subOrganizationRecord)
60 | }
61 |
62 | override suspend fun deleteSubOrganization(
63 | organizationId: String,
64 | subOrganizationName: String,
65 | ): BaseSuccessResponse {
66 | subOrganizationRepo.delete(organizationId, subOrganizationName)
67 | return BaseSuccessResponse(true)
68 | }
69 | }
70 |
71 | interface SubOrganizationService {
72 | suspend fun createSubOrganization(
73 | organizationId: String,
74 | subOrganizationName: String,
75 | description: String?,
76 | ): SubOrganization
77 |
78 | suspend fun getSubOrganization(
79 | organizationId: String,
80 | subOrganizationName: String,
81 | ): SubOrganization
82 |
83 | suspend fun listSubOrganizations(
84 | organizationId: String,
85 | context: PaginationContext,
86 | ): SubOrganizationsPaginatedResponse
87 |
88 | suspend fun updateSubOrganization(
89 | organizationId: String,
90 | subOrganizationName: String,
91 | updatedDescription: String?,
92 | ): SubOrganization
93 |
94 | suspend fun deleteSubOrganization(
95 | organizationId: String,
96 | subOrganizationName: String,
97 | ): BaseSuccessResponse
98 | }
99 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/hypto/iam/server/service/UserAuthService.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.service
2 |
3 | import com.hypto.iam.server.authProviders.AuthProviderRegistry
4 | import com.hypto.iam.server.db.repositories.OrganizationRepo
5 | import com.hypto.iam.server.db.repositories.UserAuthRepo
6 | import com.hypto.iam.server.db.repositories.UserRepo
7 | import com.hypto.iam.server.exceptions.EntityNotFoundException
8 | import com.hypto.iam.server.models.BaseSuccessResponse
9 | import com.hypto.iam.server.models.UserAuthMethod
10 | import com.hypto.iam.server.models.UserAuthMethodsResponse
11 | import com.hypto.iam.server.security.AuthMetadata
12 | import com.hypto.iam.server.security.TokenCredential
13 | import com.hypto.iam.server.security.TokenType
14 | import com.hypto.iam.server.security.UserPrincipal
15 | import com.hypto.iam.server.utils.IamResources
16 | import com.hypto.iam.server.utils.ResourceHrn
17 | import io.ktor.server.plugins.BadRequestException
18 | import org.koin.core.component.KoinComponent
19 | import org.koin.core.component.inject
20 |
21 | class UserAuthServiceImpl : KoinComponent, UserAuthService {
22 | private val organizationRepo: OrganizationRepo by inject()
23 | private val userRepo: UserRepo by inject()
24 | private val userAuthRepo: UserAuthRepo by inject()
25 |
26 | override suspend fun createUserAuth(
27 | organizationId: String,
28 | userId: String,
29 | issuer: String,
30 | token: String,
31 | principal: UserPrincipal,
32 | ): BaseSuccessResponse {
33 | val authProvider =
34 | AuthProviderRegistry.getProvider(issuer) ?: throw BadRequestException(
35 | "Invalid issuer",
36 | )
37 | val oAuthUserPrincipal = authProvider.getProfileDetails(TokenCredential(token, TokenType.OAUTH))
38 | val user =
39 | userRepo.findByEmail(oAuthUserPrincipal.email)
40 | ?: throw BadRequestException("User not found")
41 | require(principal.hrnStr == user.hrn) {
42 | "Can't add auth method for another user"
43 | }
44 | userAuthRepo.fetchByUserHrnAndProviderName(user.hrn, issuer) ?: userAuthRepo.create(
45 | user.hrn,
46 | issuer,
47 | oAuthUserPrincipal.metadata?.let { AuthMetadata.toJsonB(it) },
48 | )
49 | return BaseSuccessResponse(true)
50 | }
51 |
52 | override suspend fun listUserAuth(
53 | organizationId: String,
54 | userId: String,
55 | ): UserAuthMethodsResponse {
56 | organizationRepo.findById(organizationId)
57 | ?: throw EntityNotFoundException("Invalid organization id")
58 | val userAuth =
59 | userAuthRepo.fetchUserAuth(
60 | ResourceHrn(organizationId, "", IamResources.USER, userId),
61 | ).map {
62 | UserAuthMethod(
63 | providerName = it.providerName,
64 | )
65 | }.toList()
66 |
67 | return UserAuthMethodsResponse(userAuth)
68 | }
69 | }
70 |
71 | interface UserAuthService {
72 | suspend fun createUserAuth(
73 | organizationId: String,
74 | userId: String,
75 | issuer: String,
76 | token: String,
77 | principal: UserPrincipal,
78 | ): BaseSuccessResponse
79 |
80 | suspend fun listUserAuth(
81 | organizationId: String,
82 | userHrn: String,
83 | ): UserAuthMethodsResponse
84 | }
85 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/hypto/iam/server/service/ValidationService.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.service
2 |
3 | import com.hypto.iam.server.extensions.from
4 | import com.hypto.iam.server.models.ResourceActionEffect
5 | import com.hypto.iam.server.models.ResourceActionEffect.Effect
6 | import com.hypto.iam.server.models.ValidationRequest
7 | import com.hypto.iam.server.models.ValidationResponse
8 | import com.hypto.iam.server.utils.Hrn
9 | import com.hypto.iam.server.utils.policy.PolicyRequest
10 | import com.hypto.iam.server.utils.policy.PolicyValidator
11 | import org.koin.core.component.KoinComponent
12 | import org.koin.core.component.inject
13 |
14 | class ValidationServiceImpl : ValidationService, KoinComponent {
15 | private val principalPolicyService: PrincipalPolicyService by inject()
16 | private val policyValidator: PolicyValidator by inject()
17 |
18 | override suspend fun validateIfUserHasPermissionToActions(
19 | principalHrn: Hrn,
20 | validationRequest: ValidationRequest,
21 | ): ValidationResponse {
22 | val policyBuilder = principalPolicyService.fetchEntitlements(principalHrn.toString())
23 | val validations = validationRequest.validations
24 |
25 | val results =
26 | policyValidator.batchValidate(
27 | policyBuilder,
28 | validations.map { PolicyRequest(principalHrn.toString(), it.resource, it.action) },
29 | )
30 |
31 | return ValidationResponse(
32 | results.mapIndexed { i, isValid ->
33 | ResourceActionEffect.from(
34 | validations[i],
35 | if (isValid) {
36 | Effect.allow
37 | } else {
38 | Effect.deny
39 | },
40 | )
41 | },
42 | )
43 | }
44 | }
45 |
46 | interface ValidationService {
47 | suspend fun validateIfUserHasPermissionToActions(
48 | principalHrn: Hrn,
49 | validationRequest: ValidationRequest,
50 | ): ValidationResponse
51 | }
52 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/hypto/iam/server/utils/ApplicationIdUtil.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.utils
2 |
3 | import com.hypto.iam.server.utils.IdGenerator.Charset
4 | import com.hypto.iam.server.validators.resourceNameCheck
5 | import io.konform.validation.Valid
6 | import org.koin.core.component.KoinComponent
7 | import org.koin.core.component.inject
8 |
9 | object ApplicationIdUtil : KoinComponent {
10 | private const val ORGANIZATION_ID_LENGTH = 10L
11 | private const val REFRESH_TOKEN_RANDOM_LENGTH = 30L
12 | private const val REQUEST_ID_LENGTH = 15L
13 | private const val PASSCODE_ID_LENGTH = 10L
14 |
15 | object Generator {
16 | private val idGenerator: IdGenerator by inject()
17 |
18 | fun organizationId(): String {
19 | return idGenerator.randomId(ORGANIZATION_ID_LENGTH, Charset.ALPHANUMERIC)
20 | }
21 |
22 | // First 10 chars: alphabets (upper) representing organizationId
23 | // next 20 chars: alphanumeric with upper and lower case - random
24 | fun refreshToken(organizationId: String): String {
25 | return organizationId + idGenerator.timeBasedRandomId(REFRESH_TOKEN_RANDOM_LENGTH, Charset.ALPHABETS)
26 | }
27 |
28 | fun requestId(): String {
29 | return idGenerator.timeBasedRandomId(REQUEST_ID_LENGTH, Charset.ALPHANUMERIC)
30 | }
31 |
32 | fun passcodeId(): String {
33 | return idGenerator.timeBasedRandomId(PASSCODE_ID_LENGTH, Charset.ALPHANUMERIC)
34 | }
35 |
36 | fun username(): String {
37 | return idGenerator.randomUUID()
38 | }
39 | }
40 |
41 | object Validator {
42 | fun organizationId(orgId: String): Boolean {
43 | return (orgId.length == ORGANIZATION_ID_LENGTH.toInt() && orgId.all { it.isUpperCase() })
44 | }
45 |
46 | fun name(name: String): Boolean {
47 | return resourceNameCheck(name) is Valid
48 | }
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/hypto/iam/server/utils/EncryptUtil.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.utils
2 |
3 | import com.google.gson.Gson
4 | import com.hypto.iam.server.service.MasterKeyCache
5 | import org.koin.core.component.KoinComponent
6 | import org.koin.core.component.inject
7 | import java.util.Base64
8 | import javax.crypto.Cipher
9 | import javax.crypto.spec.GCMParameterSpec
10 | import javax.crypto.spec.SecretKeySpec
11 |
12 | data class EncryptedData(
13 | val data: String,
14 | val keyId: String,
15 | )
16 |
17 | object EncryptUtil : KoinComponent {
18 | private const val ALGORITHM = "AES/GCM/NoPadding"
19 | private const val KEY_ALGORITHM = "AES"
20 | private const val TAG_LENGTH_BIT = 128
21 | private const val KEY_LENGTH = 16
22 | private val gcm: GCMParameterSpec
23 | private val masterKeyCache: MasterKeyCache by inject()
24 | private val gson: Gson by inject()
25 |
26 | init {
27 | val nonce = ByteArray(KEY_LENGTH)
28 | gcm = GCMParameterSpec(TAG_LENGTH_BIT, nonce)
29 | }
30 |
31 | suspend fun encrypt(input: String): String {
32 | val cipher = Cipher.getInstance(ALGORITHM)
33 | val (key, keyId) = getSecretKeySpec()
34 | cipher.init(Cipher.ENCRYPT_MODE, key, gcm)
35 | val cipherText = cipher.doFinal(input.toByteArray())
36 | return gson.toJson(
37 | EncryptedData(
38 | data = Base64.getEncoder().encodeToString(cipherText),
39 | keyId = keyId,
40 | ),
41 | )
42 | }
43 |
44 | suspend fun decrypt(cipherText: String): String {
45 | val cipher = Cipher.getInstance(ALGORITHM)
46 | val encryptedData = gson.fromJson(cipherText, EncryptedData::class.java)
47 | val (key, _) = getSecretKeySpec(encryptedData.keyId)
48 | cipher.init(Cipher.DECRYPT_MODE, key, gcm)
49 | val plainText = cipher.doFinal(Base64.getDecoder().decode(encryptedData.data))
50 | return String(plainText)
51 | }
52 |
53 | private suspend fun getSecretKeySpec(id: String? = null): Pair {
54 | val masterKey =
55 | if (id == null) masterKeyCache.forSigning() else masterKeyCache.getKey(id)
56 | val key = SecretKeySpec(masterKey.privateKey.toString().take(KEY_LENGTH).toByteArray(), KEY_ALGORITHM)
57 | return Pair(key, masterKey.id)
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/hypto/iam/server/utils/IamResources.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.utils
2 |
3 | object IamResources {
4 | const val ORGANIZATION = "iam-organization"
5 | const val ACCOUNT = "iam-account"
6 | const val USER = "iam-user"
7 | const val POLICY = "iam-policy"
8 | const val RESOURCE = "iam-resource"
9 | const val ROLE = "iam-role"
10 | const val ACTION = "iam-action"
11 | const val CREDENTIAL = "iam-credential"
12 | const val SUB_ORGANIZATION = "iam-sub-organization"
13 | const val USER_LINK = "iam-user-link"
14 |
15 | val resourceMap: Map =
16 | mapOf(
17 | "users" to USER,
18 | "resources" to RESOURCE,
19 | "policies" to POLICY,
20 | "actions" to ACTION,
21 | "credentials" to CREDENTIAL,
22 | "roles" to ROLE,
23 | "organizations" to ORGANIZATION,
24 | "accounts" to ACCOUNT,
25 | "sub_organizations" to SUB_ORGANIZATION,
26 | "user_links" to USER_LINK,
27 | )
28 | }
29 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/hypto/iam/server/utils/IdGenerator.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.utils
2 |
3 | import java.time.Instant
4 | import java.util.UUID
5 | import java.util.concurrent.ThreadLocalRandom
6 | import kotlin.streams.asSequence
7 |
8 | object IdGenerator {
9 | enum class Charset(val seed: String) {
10 | UPPERCASE_ALPHABETS("ABCDEFGHIJKLMNOPQRSTUVWXYZ"),
11 | LOWERCASE_ALPHABETS(UPPERCASE_ALPHABETS.seed.lowercase()),
12 | NUMERIC("0123456789"),
13 | LOWER_ALPHANUMERIC(LOWERCASE_ALPHABETS.seed + NUMERIC.seed),
14 | UPPER_ALPHANUMERIC(UPPERCASE_ALPHABETS.seed + NUMERIC.seed),
15 | ALPHABETS(UPPERCASE_ALPHABETS.seed + LOWERCASE_ALPHABETS.seed),
16 | ALPHANUMERIC(ALPHABETS.seed + NUMERIC.seed),
17 | }
18 |
19 | fun randomId(
20 | length: Long = 10,
21 | charset: Charset = Charset.UPPERCASE_ALPHABETS,
22 | ): String {
23 | return ThreadLocalRandom.current().ints(length, 0, charset.seed.length)
24 | .asSequence()
25 | .map(charset.seed::get)
26 | .joinToString("")
27 | }
28 |
29 | /**
30 | * Convert numbers to required charset.
31 | *
32 | * E.g: For number = 1645204287
33 | * - numberToId(number, IdGenerator.Charset.NUMERIC) == "1645204287"
34 | * - numberToId(number, IdGenerator.Charset.ALPHABETS) == "ERAhbz"
35 | * - numberToId(number, IdGenerator.Charset.ALPHANUMERIC) == "BxVGxB"
36 | * - numberToId(number, IdGenerator.Charset.UPPERCASE_ALPHABETS) == "FIMFEDZ"
37 | * - numberToId(number, IdGenerator.Charset.LOWERCASE_ALPHABETS) == "fimfedz"
38 | * - numberToId(number, IdGenerator.Charset.UPPER_ALPHANUMERIC) == "1HSP1D"
39 | * - numberToId(number, IdGenerator.Charset.LOWER_ALPHANUMERIC) == "1hsp1d"
40 | */
41 | fun numberToId(
42 | number: Long,
43 | charset: Charset = Charset.UPPERCASE_ALPHABETS,
44 | ): String {
45 | return if (number < 0L) {
46 | "-" + numberToId(-number - 1)
47 | } else if (number == 0L) {
48 | charset.seed[number.toInt()].toString()
49 | } else {
50 | val seed = charset.seed
51 | var quot = number
52 | val builder = StringBuilder()
53 |
54 | while (quot != 0L) {
55 | builder.append(seed[(quot % seed.length).toInt()])
56 | quot /= seed.length
57 | }
58 | builder.reverse().toString()
59 | }
60 | }
61 |
62 | private const val MIN_TIME_BASED_RANDOM_ID_SIZE = 10
63 |
64 | fun timeBasedRandomId(
65 | length: Long = 10,
66 | charset: Charset = Charset.UPPERCASE_ALPHABETS,
67 | ): String {
68 | require(length >= MIN_TIME_BASED_RANDOM_ID_SIZE) {
69 | "Cannot generate a timestamp based random id with less than $MIN_TIME_BASED_RANDOM_ID_SIZE characters"
70 | }
71 | val timeId = numberToId(Instant.now().toEpochMilli(), charset)
72 |
73 | return timeId + randomId(length - timeId.length, charset)
74 | }
75 |
76 | fun randomUUID(): String {
77 | return UUID.randomUUID().toString()
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/hypto/iam/server/utils/MasterKeyUtil.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.utils
2 |
3 | import java.io.BufferedReader
4 | import java.io.File
5 | import java.io.IOException
6 | import java.io.InputStreamReader
7 | import java.nio.file.Files
8 |
9 | object MasterKeyUtil {
10 | private const val PRIVATE_KEY = "/tmp/private_key"
11 | private const val PUBLIC_KEY = "/tmp/public_key"
12 |
13 | /**
14 | * Generates new EC public & private key pair (both pem and der) in `/tmp`
15 | */
16 | fun generateKeyPair() {
17 | try {
18 | val scriptStream = this::class.java.getResourceAsStream("/generate_key_pair.sh")
19 | requireNotNull(scriptStream) {
20 | "Script not found"
21 | }
22 | val file = File("/tmp/generate_key_pair.sh")
23 | Files.copy(scriptStream, file.toPath())
24 |
25 | val process = Runtime.getRuntime().exec("/bin/bash ${file.absolutePath}", null, null)
26 | val output = StringBuilder()
27 | val reader = BufferedReader(InputStreamReader(process.inputStream))
28 |
29 | var line: String? = reader.readLine()
30 | while (line != null) {
31 | output.append(line.trimIndent())
32 | line = reader.readLine()
33 | }
34 |
35 | if (process.waitFor() == 0) {
36 | println(output)
37 | } else {
38 | throw IOException()
39 | }
40 | } catch (e: IOException) {
41 | println(e.message)
42 | } catch (e: InterruptedException) {
43 | println(e.message)
44 | }
45 | }
46 |
47 | fun loadPrivateKeyDer(): ByteArray {
48 | return loadKeyDer("$PRIVATE_KEY.der")
49 | }
50 |
51 | fun loadPublicKeyDer(): ByteArray {
52 | return loadKeyDer("$PUBLIC_KEY.der")
53 | }
54 |
55 | fun loadPublicKeyPem(): ByteArray {
56 | return loadKeyPem("$PUBLIC_KEY.pem")
57 | }
58 |
59 | fun loadPrivateKeyPem(): ByteArray {
60 | return loadKeyPem("$PRIVATE_KEY.pem")
61 | }
62 |
63 | private fun loadKeyDer(path: String): ByteArray {
64 | return File(path).readBytes()
65 | }
66 |
67 | private fun loadKeyPem(path: String): ByteArray {
68 | return File(path).readBytes()
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/hypto/iam/server/utils/Timing.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.utils
2 |
3 | import com.hypto.iam.server.di.getKoinInstance
4 | import io.micrometer.core.instrument.MeterRegistry
5 | import mu.KLogger
6 | import kotlin.time.ExperimentalTime
7 | import kotlin.time.measureTimedValue
8 | import kotlin.time.toJavaDuration
9 |
10 | val microMeterRegistry: MeterRegistry = getKoinInstance()
11 |
12 | inline fun measureTime(
13 | name: String,
14 | logger: KLogger,
15 | block: () -> Unit,
16 | ) = measureTimedValue(name, logger, block)
17 |
18 | @OptIn(ExperimentalTime::class)
19 | inline fun measureTimedValue(
20 | name: String,
21 | logger: KLogger,
22 | block: () -> T,
23 | ): T {
24 | val timedValue = measureTimedValue(block)
25 | microMeterRegistry.timer(name).record(timedValue.duration.toJavaDuration())
26 | logger.info { "[Timing] Operation=[$name] | Time=[${timedValue.duration.inWholeNanoseconds} ns]" }
27 | return timedValue.value
28 | }
29 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/hypto/iam/server/utils/policy/PolicyRequest.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.utils.policy
2 |
3 | data class PolicyRequest(val principal: String, val resource: String, val action: String)
4 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/hypto/iam/server/utils/policy/PolicyStatement.kt:
--------------------------------------------------------------------------------
1 | @file:Suppress("MagicNumber")
2 |
3 | package com.hypto.iam.server.utils.policy
4 |
5 | open class PolicyStatement(private val statement: String) {
6 | companion object {
7 | fun p(
8 | principal: String,
9 | resource: String,
10 | action: String,
11 | effect: String,
12 | ): PolicyStatement {
13 | return PolicyStatement("p, $principal, $resource, $action, $effect")
14 | }
15 |
16 | fun g(
17 | principal: String,
18 | policy: String,
19 | ): PolicyStatement {
20 | return PolicyStatement("g, $principal, $policy")
21 | }
22 |
23 | fun of(
24 | principal: String,
25 | statement: com.hypto.iam.server.models.PolicyStatement,
26 | ): PolicyStatement {
27 | return this.p(principal, statement.resource, statement.action, statement.effect.value)
28 | }
29 | }
30 |
31 | override fun toString(): String {
32 | return statement
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/hypto/iam/server/utils/policy/PolicyValidator.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.utils.policy
2 |
3 | import org.casbin.jcasbin.main.CoreEnforcer.newModel
4 | import org.casbin.jcasbin.main.Enforcer
5 | import org.casbin.jcasbin.persist.file_adapter.FileAdapter
6 | import java.io.InputStream
7 |
8 | object PolicyValidator {
9 | private val modelStream = this::class.java.getResourceAsStream("/casbin_model.conf")
10 | private val model = modelStream?.let { newModel(String(it.readAllBytes(), Charsets.UTF_8)) }
11 |
12 | init {
13 | requireNotNull(modelStream) { "casbin_model.conf not found in resources" }
14 | }
15 |
16 | fun validate(
17 | policyBuilder: PolicyBuilder,
18 | policyRequest: PolicyRequest,
19 | ): Boolean {
20 | return validate(policyBuilder.stream(), policyRequest)
21 | }
22 |
23 | fun validate(
24 | inputStream: InputStream,
25 | policyRequest: PolicyRequest,
26 | ): Boolean {
27 | return validate(inputStream, listOf(policyRequest))
28 | }
29 |
30 | fun validate(
31 | inputStream: InputStream,
32 | policyRequests: List,
33 | ): Boolean {
34 | return policyRequests
35 | .all {
36 | Enforcer(model, FileAdapter(inputStream))
37 | .enforce(it.principal, it.resource, it.action)
38 | }
39 | }
40 |
41 | fun validate(
42 | enforcer: Enforcer,
43 | policyRequest: PolicyRequest,
44 | ): Boolean {
45 | return enforcer
46 | .enforce(policyRequest.principal, policyRequest.resource, policyRequest.action)
47 | }
48 |
49 | fun validateAny(
50 | inputStream: InputStream,
51 | policyRequests: List,
52 | ): Boolean {
53 | return policyRequests
54 | .any {
55 | Enforcer(model, FileAdapter(inputStream))
56 | .enforce(it.principal, it.resource, it.action)
57 | }
58 | }
59 |
60 | fun validateNone(
61 | inputStream: InputStream,
62 | policyRequests: List,
63 | ): Boolean {
64 | return policyRequests
65 | .none {
66 | Enforcer(model, FileAdapter(inputStream))
67 | .enforce(it.principal, it.resource, it.action)
68 | }
69 | }
70 |
71 | fun batchValidate(
72 | policyBuilder: PolicyBuilder,
73 | policyRequests: List,
74 | ): List {
75 | val enforcer = Enforcer(model, FileAdapter(policyBuilder.stream()))
76 | return policyRequests.map { validate(enforcer, it) }.toList()
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/hypto/iam/server/validators/MetadataValidator.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.validators
2 |
3 | import com.google.gson.Gson
4 | import com.hypto.iam.server.di.getKoinInstance
5 | import com.hypto.iam.server.extensions.hrn
6 | import com.hypto.iam.server.extensions.validateAndThrowOnFailure
7 | import io.konform.validation.Validation
8 |
9 | // This file contains extension functions to validate provided metadata
10 | private val gson: Gson = getKoinInstance()
11 |
12 | data class SignUpMetadata(
13 | val name: String,
14 | val description: String?,
15 | val rootUserPassword: String,
16 | val rootUserName: String?,
17 | val rootUserPreferredUsername: String?,
18 | val rootUserPhone: String?,
19 | )
20 |
21 | data class InviteMetadata(val map: Map) {
22 | val inviterUserHrn: String by map
23 | val policies: List by map
24 | }
25 |
26 | data class RequestAccessMetadata(val map: Map) {
27 | val inviterUserHrn: String by map
28 | }
29 |
30 | data class VerifyEmailSignUpMetadata(
31 | val metadata: SignUpMetadata,
32 | )
33 |
34 | data class VerifyEmailInviteMetadata(
35 | val metadata: InviteMetadata,
36 | )
37 |
38 | val signUpMetadataValidation =
39 | Validation {
40 | VerifyEmailSignUpMetadata::metadata {
41 | SignUpMetadata::name required {
42 | run(orgNameCheck)
43 | }
44 | SignUpMetadata::rootUserPassword required {
45 | run(passwordCheck)
46 | }
47 | SignUpMetadata::rootUserName ifPresent {
48 | run(nameOfUserCheck)
49 | }
50 | SignUpMetadata::rootUserPreferredUsername ifPresent {
51 | run(preferredUserNameCheck)
52 | }
53 | SignUpMetadata::rootUserPhone ifPresent {
54 | run(phoneNumberCheck)
55 | }
56 | }
57 | }
58 |
59 | val inviteMetadataValidation =
60 | Validation {
61 | InviteMetadata::inviterUserHrn required {
62 | run(hrnCheck)
63 | }
64 | InviteMetadata::policies onEach { hrn() }
65 | }
66 |
67 | fun validateSignupMetadata(metadata: Map) {
68 | val metadataObject = gson.fromJson(gson.toJsonTree(metadata), SignUpMetadata::class.java)
69 | signUpMetadataValidation.validateAndThrowOnFailure(VerifyEmailSignUpMetadata(metadataObject))
70 | }
71 |
72 | fun validateInviteMetadata(metadata: Map) {
73 | val metadataObject = InviteMetadata(metadata)
74 | inviteMetadataValidation.validateAndThrowOnFailure(metadataObject)
75 | }
76 |
--------------------------------------------------------------------------------
/src/main/resources/casbin_model.conf:
--------------------------------------------------------------------------------
1 | [request_definition]
2 | r = principal, resource, operation
3 |
4 | [policy_definition]
5 | p = principal, resource, operation, eft
6 |
7 | [role_definition]
8 | g = _, _
9 |
10 | [policy_effect]
11 | e = some(where (p.eft == allow)) && !some(where (p.eft == deny))
12 |
13 | [matchers]
14 | m = g(r.principal, p.principal) && regexMatch(r.resource, p.resource) && regexMatch(r.operation, p.operation)
15 |
--------------------------------------------------------------------------------
/src/main/resources/db/migration/V10__add_metadata_to_passcode.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE passcodes
2 | ADD COLUMN metadata text DEFAULT NULL;
--------------------------------------------------------------------------------
/src/main/resources/db/migration/V11__add_detail_columns_to_users.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE users
2 | ADD COLUMN name VARCHAR(50),
3 | ADD COLUMN created_by VARCHAR(200),
4 | ADD COLUMN login_access BOOLEAN DEFAULT NULL;
5 | ALTER TABLE users
6 | ALTER COLUMN email DROP NOT NULL;
--------------------------------------------------------------------------------
/src/main/resources/db/migration/V12__add_policy_template_table.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE policy_templates (
2 | name VARCHAR(512) PRIMARY KEY,
3 | status VARCHAR(512) NOT NULL, -- ACTIVE, ARCHIVED
4 | is_root_policy BOOLEAN NOT NULL,
5 | statements text NOT NULL,
6 |
7 | created_at timestamp NOT NULL,
8 | updated_at timestamp NOT NULL
9 | );
10 |
11 |
12 |
13 | INSERT INTO policy_templates values (
14 | 'admin',
15 | 'ACTIVE',
16 | TRUE,
17 | 'p, hrn:{{organization_id}}::iam-policy/admin, ^hrn:{{organization_id}}$, hrn:{{organization_id}}:*, allow
18 | p, hrn:{{organization_id}}::iam-policy/admin, ^hrn:{{organization_id}}::*, hrn:{{organization_id}}::*, allow
19 | ',
20 | 'NOW()',
21 | 'NOW()'
22 | );
23 |
--------------------------------------------------------------------------------
/src/main/resources/db/migration/V13__add_description_to_policy_and_templates.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE policy_templates ADD COLUMN description VARCHAR(512) NULL;
2 |
3 | ALTER TABLE policies ADD COLUMN description VARCHAR(512) NULL;
4 |
--------------------------------------------------------------------------------
/src/main/resources/db/migration/V14__create_auth_provider_table.sql:
--------------------------------------------------------------------------------
1 | -- OAuth Grant Type Enum
2 | CREATE TYPE grant_type AS ENUM (
3 | 'CODE_AUTHORIZATION',
4 | 'CUSTOM',
5 | 'IMPLICIT'
6 | );
7 |
8 | -- OAuth Provider Table
9 | CREATE TABLE auth_provider (
10 | provider_name VARCHAR(512) NOT NULL PRIMARY KEY,
11 | auth_url VARCHAR(512) NOT NULL,
12 | client_id VARCHAR(512) NOT NULL,
13 | client_secret VARCHAR(512) NOT NULL,
14 | created_at timestamp NOT NULL,
15 | updated_at timestamp NOT NULL
16 | );
17 |
18 | COMMENT ON TABLE auth_provider
19 | IS 'OAuth Provider Table';
20 | COMMENT ON COLUMN auth_provider.provider_name
21 | IS 'OAuth Provider Name (e.g. Google, Microsoft)';
22 | COMMENT ON COLUMN auth_provider.auth_url
23 | IS 'OAuth Authorization URL that frontend consumes to redirect user';
24 | COMMENT ON COLUMN auth_provider.client_id
25 | IS 'OAuth Client ID generated at OAuth Provider to include in request';
26 | COMMENT ON COLUMN auth_provider.client_secret
27 | IS 'OAuth Client Secret generated at OAuth Provider to obtain refresh token';
--------------------------------------------------------------------------------
/src/main/resources/db/migration/V15__create_user_auth_table.sql:
--------------------------------------------------------------------------------
1 | -- Authorization methods of User Table
2 | CREATE TABLE user_auth (
3 | user_hrn VARCHAR(512) NOT NULL,
4 | provider_name VARCHAR(512) NOT NULL,
5 | auth_metadata JSONB,
6 | created_at timestamp NOT NULL,
7 | updated_at timestamp NOT NULL,
8 | PRIMARY KEY(user_hrn, provider_name)
9 | );
10 |
11 | COMMENT ON TABLE user_auth
12 | IS 'Authorization methods of User Table';
13 | COMMENT ON COLUMN user_auth.user_hrn
14 | IS 'User HRN';
15 | COMMENT ON COLUMN user_auth.provider_name
16 | IS 'OAuth Provider Name (e.g. Google, Microsoft)';
17 | COMMENT ON COLUMN user_auth.auth_metadata
18 | IS 'OAuth Authorization Metadata (e.g. access_token, refresh_token, token_type, expires_in, scope)';
19 |
--------------------------------------------------------------------------------
/src/main/resources/db/migration/V16__create_sub_org_table.sql:
--------------------------------------------------------------------------------
1 | -- Sub organizations table
2 | CREATE TABLE sub_organizations (
3 | name VARCHAR(1200) NOT NULL,
4 | organization_id VARCHAR(512) NOT NULL,
5 | description text,
6 | created_at timestamp NOT NULL,
7 | updated_at timestamp NOT NULL,
8 |
9 | PRIMARY KEY (name, organization_id),
10 | FOREIGN KEY (organization_id) REFERENCES organizations (id)
11 | );
12 |
13 | COMMENT ON TABLE sub_organizations
14 | IS 'Table to store all sub organization details';
15 | COMMENT ON COLUMN sub_organizations.name
16 | IS 'name of the sub organization';
17 | COMMENT ON COLUMN sub_organizations.description
18 | IS 'description of the sub organization';
19 |
20 |
21 | -- Add sub organization id to the users table
22 | ALTER TABLE users ADD COLUMN sub_organization_name VARCHAR(1200);
23 |
24 | -- Add sub organization id to the passcodes table
25 | ALTER TABLE passcodes
26 | ADD COLUMN sub_organization_name text DEFAULT NULL;
27 |
28 | -- Update varchar limit for in user table for hrn and created_by fields
29 | ALTER TABLE users
30 | ALTER COLUMN hrn TYPE VARCHAR(1300),
31 | ALTER COLUMN created_by TYPE VARCHAR(1300);
--------------------------------------------------------------------------------
/src/main/resources/db/migration/V17__add_required_variables_in_poly_templates.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE policy_templates
2 | ADD COLUMN required_variables text[] not null default '{}';
--------------------------------------------------------------------------------
/src/main/resources/db/migration/V18__policy_template_on_create_org_flag.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE policy_templates
2 | ADD COLUMN on_create_org BOOLEAN DEFAULT true NOT NULL;
3 |
4 | ALTER TABLE policy_templates
5 | ALTER COLUMN on_create_org DROP DEFAULT;
--------------------------------------------------------------------------------
/src/main/resources/db/migration/V19__add_last_sent_column_in_passcode_table.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE passcodes
2 | ADD COLUMN last_sent TIMESTAMP;
3 |
4 | UPDATE passcodes
5 | SET last_sent = created_at;
6 |
7 | ALTER TABLE passcodes
8 | ALTER COLUMN last_sent SET NOT NULL;
--------------------------------------------------------------------------------
/src/main/resources/db/migration/V20__add_link_user_table.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE link_users (
2 | id VARCHAR(512) NOT NULL,
3 | leader_user_hrn VARCHAR(1200) NOT NULL,
4 | subordinate_user_hrn VARCHAR(1200) NOT NULL,
5 | created_at timestamp NOT NULL,
6 | updated_at timestamp NOT NULL,
7 |
8 | PRIMARY KEY (id)
9 | );
10 |
11 | CREATE UNIQUE INDEX leader_subordinate_user_idx ON link_users (leader_user_hrn, subordinate_user_hrn);
--------------------------------------------------------------------------------
/src/main/resources/db/migration/V21__change_passcode_purpose_lenght.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE passcodes
2 | ALTER COLUMN purpose TYPE VARCHAR (20);
--------------------------------------------------------------------------------
/src/main/resources/db/migration/V2__create_audit_entries_table_and_partitions.sql:
--------------------------------------------------------------------------------
1 | create table audit_entries (
2 | id uuid DEFAULT gen_random_uuid(),
3 | request_id VARCHAR(200) NOT NULL,
4 | event_time timestamp NOT NULL,
5 | principal VARCHAR(200) NOT NULL,
6 | principal_organization VARCHAR(10) NOT NULL,
7 | resource VARCHAR(200) NOT NULL,
8 | operation VARCHAR(200) NOT NULL,
9 | meta json DEFAULT NULL,
10 | PRIMARY KEY (id, event_time)
11 | ) PARTITION BY RANGE (event_time);
12 |
13 | CREATE INDEX audit_entries_idx_principal_organization_principal_event_time ON audit_entries(principal_organization, principal, event_time);
14 | CREATE INDEX audit_entries_idx_resource_event_time ON audit_entries(resource, event_time);
15 |
16 | CREATE TABLE audit_entries_y2022m02 PARTITION OF audit_entries
17 | FOR VALUES FROM ('2022-02-01') TO ('2022-03-01');
18 |
19 | CREATE TABLE audit_entries_y2022m03 PARTITION OF audit_entries
20 | FOR VALUES FROM ('2022-03-01') TO ('2022-04-01');
21 |
22 | CREATE TABLE audit_entries_y2022m04 PARTITION OF audit_entries
23 | FOR VALUES FROM ('2022-04-01') TO ('2022-05-01');
24 |
25 | CREATE TABLE audit_entries_y2022m05 PARTITION OF audit_entries
26 | FOR VALUES FROM ('2022-05-01') TO ('2022-06-01');
27 |
28 | CREATE TABLE audit_entries_y2022m06 PARTITION OF audit_entries
29 | FOR VALUES FROM ('2022-06-01') TO ('2022-07-01');
30 |
31 | CREATE TABLE audit_entries_y2022m07 PARTITION OF audit_entries
32 | FOR VALUES FROM ('2022-07-01') TO ('2022-08-01');
33 |
34 | CREATE TABLE audit_entries_y2022m08 PARTITION OF audit_entries
35 | FOR VALUES FROM ('2022-08-01') TO ('2022-09-01');
36 |
37 | CREATE TABLE audit_entries_y2022m09 PARTITION OF audit_entries
38 | FOR VALUES FROM ('2022-09-01') TO ('2022-10-01');
39 |
40 | CREATE TABLE audit_entries_y2022m10 PARTITION OF audit_entries
41 | FOR VALUES FROM ('2022-10-01') TO ('2022-11-01');
42 |
43 | CREATE TABLE audit_entries_y2022m11 PARTITION OF audit_entries
44 | FOR VALUES FROM ('2022-11-01') TO ('2022-12-01');
45 |
46 | CREATE TABLE audit_entries_y2022m12 PARTITION OF audit_entries
47 | FOR VALUES FROM ('2022-12-01') TO ('2023-01-01');
48 |
49 | CREATE TABLE audit_entries_y2023m01 PARTITION OF audit_entries
50 | FOR VALUES FROM ('2023-01-01') TO ('2023-02-01');
51 |
--------------------------------------------------------------------------------
/src/main/resources/db/migration/V3__add_keys_pem_file_to_db.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE master_keys
2 | RENAME private_key TO private_key_der;
3 |
4 | ALTER TABLE master_keys
5 | RENAME public_key TO public_key_der;
6 |
7 | ALTER TABLE master_keys
8 | ADD COLUMN private_key_pem BYTEA,
9 | ADD COLUMN public_key_pem BYTEA;
--------------------------------------------------------------------------------
/src/main/resources/db/migration/V4__modify_users_table.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE users
2 | DROP COLUMN password_hash,
3 | DROP COLUMN phone,
4 | DROP COLUMN login_access,
5 | DROP COLUMN created_by,
6 | ADD COLUMN verified BOOLEAN DEFAULT FALSE,
7 | ADD COLUMN deleted BOOLEAN DEFAULT FALSE;
8 |
9 | DROP INDEX IF EXISTS users_idx_organization_id_name;
10 | CREATE INDEX users_email_organization_index ON users(email, organization_id);
11 |
12 | ALTER TABLE users
13 | DROP CONSTRAINT users_organization_id_fkey,
14 | ADD CONSTRAINT users_organization_id_fkey
15 | FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE;
16 |
--------------------------------------------------------------------------------
/src/main/resources/db/migration/V5__org_rename_admin_to_root.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE organizations
2 | RENAME COLUMN admin_user_hrn TO root_user_hrn;
--------------------------------------------------------------------------------
/src/main/resources/db/migration/V6__create_passcode_table.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE passcodes (
2 | id VARCHAR(10) PRIMARY KEY,
3 | valid_until timestamp NOT NULL,
4 | email VARCHAR(50) NOT NULL,
5 | organization_id VARCHAR(10),
6 | purpose VARCHAR(10) NOT NULL, -- RESET, SIGNUP
7 |
8 | created_at timestamp NOT NULL,
9 |
10 | FOREIGN KEY (organization_id) REFERENCES organizations (id)
11 | );
--------------------------------------------------------------------------------
/src/main/resources/db/migration/V7__add_preferred_username.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE users
2 | ADD COLUMN preferred_username VARCHAR(50);
--------------------------------------------------------------------------------
/src/main/resources/db/migration/V8__rename_user_policies_to_principal_policies.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE user_policies RENAME TO principal_policies;
--------------------------------------------------------------------------------
/src/main/resources/db/migration/V9__create_username_index_on_users.sql:
--------------------------------------------------------------------------------
1 | CREATE INDEX users_idx_username ON users(preferred_username);
2 |
--------------------------------------------------------------------------------
/src/main/resources/default_config.json:
--------------------------------------------------------------------------------
1 | {
2 | "database": {
3 | "host": "localhost",
4 | "port": "4921",
5 | "username": "root",
6 | "password": "password",
7 | "maximum_pool_size": 32,
8 | "minimum_idle": 5,
9 | "is_auto_commit": true,
10 | "transaction_isolation": "TRANSACTION_REPEATABLE_READ"
11 | },
12 | "newrelic": {
13 | "api_key": "test_key",
14 | "publish_interval": 10
15 | },
16 | "server": {
17 | "port": 8080,
18 | "connectionGroupSize": 32,
19 | "workerGroupSize": 64,
20 | "callGroupSize": 256,
21 | "requestQueueLimit": 16,
22 | "runningLimit": 10,
23 | "shareWorkGroup": false,
24 | "responseWriteTimeoutSeconds": 10,
25 | "requestReadTimeoutSeconds": 10,
26 | "tcpKeepAlive": false
27 | },
28 | "app": {
29 | "env": "Development",
30 | "name": "iam",
31 | "stack": "local",
32 | "jwt_token_validity": 300,
33 | "old_key_ttl": 600,
34 | "secret_key": "iam-secret-key",
35 | "sign_key_fetch_interval": 300,
36 | "cache_refresh_interval": 300,
37 | "passcode_validity_seconds": 86400,
38 | "passcode_count_limit": 5,
39 | "resend_invite_wait_time_seconds": 900,
40 | "base_url": "localhost",
41 | "sender_email_address": "",
42 | "sign_up_email_template": "",
43 | "reset_password_email_template": "",
44 | "invite_user_email_template": "",
45 | "link_user_email_template": "",
46 | "unique_users_across_organizations": false
47 | },
48 | "aws": {
49 | "region": "us-east-1",
50 | "accessKey": "",
51 | "secretKey": ""
52 | },
53 | "postHook": {
54 | "signup": "http://posthook.com/signup"
55 | },
56 | "onboardRoutes": {
57 | "signup": "/signup",
58 | "reset": "/organizations/users/resetPassword",
59 | "invite": "/organizations/users/verifyUser",
60 | "linkUser": "/user_links"
61 | },
62 | "sub_org_config": {
63 | "base_url": "",
64 | "invite_user_email_template": "",
65 | "reset_password_email_template": "",
66 | "link_user_email_template": "",
67 | "onboardRoutes": {
68 | "signup": "/signup",
69 | "reset": "/reset",
70 | "invite": "/invite",
71 | "linkUser": "/user_links"
72 | }
73 | },
74 | "cognito": {
75 | "id": "",
76 | "name": "",
77 | "metadata": {
78 | "iam_client_id": ""
79 | },
80 | "identitySource": "AWS_COGNITO"
81 | }
82 | }
--------------------------------------------------------------------------------
/src/main/resources/generate_key_pair.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Navigate to work dir
4 | cd /tmp || exit 1
5 |
6 | # Clean up
7 | rm private_key.pem public_key.pem private_key.der public_key.der
8 |
9 | #- Create a ES256 private key pem:
10 | openssl ecparam -name prime256v1 -genkey -noout -out private_key.pem
11 |
12 | #- Generate corresponding public key pem:
13 | openssl ec -in private_key.pem -pubout -out public_key.pem
14 |
15 | #- Generate private_key.der corresponding to private_key.pem
16 | openssl pkcs8 -topk8 -inform PEM -outform DER -in private_key.pem -out private_key.der -nocrypt
17 |
18 | #- Generate public_key.der corresponding to private_key.pem
19 | openssl ec -in private_key.pem -pubout -outform DER -out public_key.der
--------------------------------------------------------------------------------
/src/main/resources/logback.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %X{call-id} %-5level %logger{36} - %msg%n
6 |
7 |
8 |
9 |
10 | ${LOG_DEST:-build/output/logs}/iam_application.log
11 |
12 |
13 | ${LOG_DEST:-build/output/logs}/iam_application.%d{yyyy-MM-dd}.log
14 |
15 |
16 | ${LOG_MAX_HISTORY:-90}
17 | 3GB
18 |
19 |
20 |
21 |
22 | %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
23 |
24 |
25 |
26 |
27 | ${LOG_DEST:-build/output/logs}/iam_audit.log
28 |
29 |
30 | ${LOG_DEST:-build/output/logs}/iam_audit.%d{yyyy-MM-dd}.log
31 |
32 |
33 | ${LOG_MAX_HISTORY:-90}
34 | 3GB
35 |
36 |
37 |
38 |
39 | %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %X{call-id} %-5level %logger{36} | %marker | - %msg%n
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
--------------------------------------------------------------------------------
/src/test/kotlin/com/hypto/iam/server/ExceptionHandlerTest.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server
2 |
3 | import com.google.gson.Gson
4 | import com.hypto.iam.server.helpers.AbstractContainerBaseTest
5 | import com.hypto.iam.server.models.CreateOrganizationRequest
6 | import com.hypto.iam.server.models.RootUser
7 | import com.hypto.iam.server.service.OrganizationsService
8 | import com.hypto.iam.server.service.TokenServiceImpl
9 | import com.hypto.iam.server.utils.IdGenerator
10 | import io.ktor.client.request.header
11 | import io.ktor.client.request.post
12 | import io.ktor.client.request.setBody
13 | import io.ktor.client.statement.bodyAsText
14 | import io.ktor.http.ContentType
15 | import io.ktor.http.HttpHeaders
16 | import io.ktor.http.HttpStatusCode
17 | import io.ktor.server.config.ApplicationConfig
18 | import io.ktor.server.testing.testApplication
19 | import io.mockk.coEvery
20 | import org.junit.jupiter.api.Assertions
21 | import org.junit.jupiter.api.Test
22 | import org.koin.test.inject
23 | import org.koin.test.mock.declareMock
24 |
25 | class ExceptionHandlerTest : AbstractContainerBaseTest() {
26 | private val gson: Gson by inject()
27 |
28 | @Test
29 | fun `StatusPage - Respond to server side errors with custom error message`() {
30 | declareMock {
31 | coEvery { this@declareMock.createOrganization(any(), TokenServiceImpl.ISSUER) } coAnswers {
32 | @Suppress("TooGenericExceptionThrown")
33 | throw RuntimeException()
34 | }
35 | }
36 |
37 | testApplication {
38 | environment {
39 | config = ApplicationConfig("application-custom.conf")
40 | }
41 | val orgName = "test-org" + IdGenerator.randomId()
42 | val preferredUsername = "user" + IdGenerator.randomId()
43 | val name = "test name"
44 | val testEmail = "test-user-email" + IdGenerator.randomId() + "@hypto.in"
45 | val testPhone = "+919626012778"
46 | val testPassword = "testPassword@Hash1"
47 |
48 | val requestBody =
49 | CreateOrganizationRequest(
50 | orgName,
51 | RootUser(
52 | preferredUsername = preferredUsername,
53 | name = name,
54 | password = testPassword,
55 | email = testEmail,
56 | phone = testPhone,
57 | ),
58 | )
59 | val response =
60 | client.post("/organizations") {
61 | header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
62 | header("X-Api-Key", rootToken)
63 | setBody(gson.toJson(requestBody))
64 | }
65 | Assertions.assertEquals("{\"message\":\"Internal Server Error Occurred\"}", response.bodyAsText())
66 | Assertions.assertEquals(HttpStatusCode.InternalServerError, response.status)
67 | }
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/test/kotlin/com/hypto/iam/server/apis/KeyApiTest.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.apis
2 |
3 | import com.hypto.iam.server.helpers.BaseSingleAppTest
4 | import com.hypto.iam.server.helpers.DataSetupHelperV3.createOrganization
5 | import com.hypto.iam.server.helpers.DataSetupHelperV3.deleteOrganization
6 | import com.hypto.iam.server.models.KeyResponse
7 | import com.hypto.iam.server.models.TokenResponse
8 | import com.hypto.iam.server.service.TokenServiceImpl
9 | import io.jsonwebtoken.Jwts
10 | import io.ktor.client.request.get
11 | import io.ktor.client.request.header
12 | import io.ktor.client.request.post
13 | import io.ktor.client.statement.bodyAsText
14 | import io.ktor.http.ContentType.Application.Json
15 | import io.ktor.http.HttpHeaders
16 | import io.ktor.http.HttpStatusCode
17 | import io.ktor.http.contentType
18 | import io.ktor.test.dispatcher.testSuspend
19 | import org.junit.jupiter.api.Assertions.assertEquals
20 | import org.junit.jupiter.api.Test
21 | import org.junit.jupiter.api.assertDoesNotThrow
22 | import java.security.KeyFactory
23 | import java.security.PublicKey
24 | import java.security.spec.X509EncodedKeySpec
25 | import java.util.Base64
26 |
27 | class KeyApiTest : BaseSingleAppTest() {
28 | @Test
29 | fun `validate token using public key`() {
30 | testSuspend {
31 | val (organizationResponse, _) = testApp.createOrganization()
32 |
33 | val createTokenCall =
34 | testApp.client.post("/organizations/${organizationResponse.organization.id}/token") {
35 | header(HttpHeaders.ContentType, Json.toString())
36 | header(HttpHeaders.Authorization, "Bearer ${organizationResponse.rootUserToken}")
37 | }
38 | val token =
39 | gson
40 | .fromJson(createTokenCall.bodyAsText(), TokenResponse::class.java).token
41 |
42 | println(token)
43 |
44 | val splitToken: Array = token.split(".").toTypedArray()
45 | val unsignedToken = splitToken[0] + "." + splitToken[1] + "."
46 | val jwt = Jwts.parserBuilder().build().parseClaimsJwt(unsignedToken)
47 |
48 | val kid = jwt.header.getValue(TokenServiceImpl.KEY_ID) as String
49 |
50 | val response =
51 | testApp.client.get(
52 | "/keys/$kid?format=der",
53 | ) {
54 | header(HttpHeaders.Authorization, "Bearer ${organizationResponse.rootUserToken}")
55 | }
56 | assertEquals(HttpStatusCode.OK, response.status)
57 | assertEquals(
58 | Json,
59 | response.contentType(),
60 | )
61 | val publicKeyResponse = gson.fromJson(response.bodyAsText(), KeyResponse::class.java)
62 |
63 | assertEquals(KeyResponse.Format.der, publicKeyResponse.format)
64 |
65 | val encodedPublicKey = Base64.getDecoder().decode(publicKeyResponse.key)
66 | val keyFactory = KeyFactory.getInstance("EC")
67 | val keySpec = X509EncodedKeySpec(encodedPublicKey)
68 | val publicKey = keyFactory.generatePublic(keySpec) as PublicKey
69 |
70 | assertEquals(kid, publicKeyResponse.kid)
71 | assertDoesNotThrow {
72 | Jwts.parserBuilder().setSigningKey(publicKey).build().parseClaimsJws(token)
73 | }
74 |
75 | testApp.deleteOrganization(organizationResponse.organization.id)
76 | }
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/src/test/kotlin/com/hypto/iam/server/helpers/AbstractContainerBaseTest.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.helpers
2 |
3 | import com.hypto.iam.server.Constants
4 | import com.hypto.iam.server.configs.AppConfig
5 | import com.hypto.iam.server.db.repositories.PasscodeRepo
6 | import com.hypto.iam.server.di.applicationModule
7 | import com.hypto.iam.server.di.controllerModule
8 | import com.hypto.iam.server.di.getKoinInstance
9 | import com.hypto.iam.server.di.repositoryModule
10 | import io.mockk.mockkClass
11 | import okhttp3.OkHttpClient
12 | import org.junit.jupiter.api.BeforeEach
13 | import org.junit.jupiter.api.extension.RegisterExtension
14 | import org.koin.test.junit5.AutoCloseKoinTest
15 | import org.koin.test.junit5.KoinTestExtension
16 | import org.koin.test.junit5.mock.MockProviderExtension
17 | import software.amazon.awssdk.services.cognitoidentityprovider.CognitoIdentityProviderClient
18 | import software.amazon.awssdk.services.ses.SesClient
19 |
20 | abstract class AbstractContainerBaseTest : AutoCloseKoinTest() {
21 | protected var rootToken: String = ""
22 | protected lateinit var cognitoClient: CognitoIdentityProviderClient
23 | protected lateinit var passcodeRepo: PasscodeRepo
24 | protected lateinit var sesClient: SesClient
25 | protected lateinit var okHttpClient: OkHttpClient
26 |
27 | @JvmField
28 | @RegisterExtension
29 | val koinTestExtension =
30 | KoinTestExtension.create {
31 | modules(repositoryModule, controllerModule, applicationModule)
32 | }
33 |
34 | @JvmField
35 | @RegisterExtension
36 | val koinMockProvider = MockProviderExtension.create { mockkClass(it) }
37 |
38 | @BeforeEach
39 | fun setup() {
40 | rootToken = Constants.SECRET_PREFIX + getKoinInstance().app.secretKey
41 | passcodeRepo = mockPasscodeRepo()
42 | cognitoClient = mockCognitoClient()
43 | sesClient = mockSesClient()
44 | okHttpClient = mockOkHttpClient()
45 | }
46 |
47 | init {
48 | PostgresInit
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/test/kotlin/com/hypto/iam/server/helpers/BaseSingleAppTest.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.helpers
2 |
3 | import com.google.gson.Gson
4 | import io.ktor.server.config.ApplicationConfig
5 | import io.ktor.server.testing.TestApplication
6 | import org.junit.jupiter.api.AfterAll
7 | import org.junit.jupiter.api.BeforeAll
8 | import org.koin.test.inject
9 |
10 | abstract class BaseSingleAppTest : AbstractContainerBaseTest() {
11 | protected val gson: Gson by inject()
12 |
13 | companion object {
14 | lateinit var testApp: TestApplication
15 |
16 | @JvmStatic
17 | @BeforeAll
18 | fun setupTest() {
19 | testApp =
20 | TestApplication {
21 | environment {
22 | config = ApplicationConfig("application-custom.conf")
23 | }
24 | }
25 | }
26 |
27 | @JvmStatic
28 | @AfterAll
29 | fun teardownTest() {
30 | testApp.stop()
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/test/kotlin/com/hypto/iam/server/helpers/MockOkHttpClient.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.helpers
2 |
3 | import io.mockk.coEvery
4 | import io.mockk.mockk
5 | import okhttp3.OkHttpClient
6 | import org.koin.core.qualifier.named
7 | import org.koin.test.KoinTest
8 | import org.koin.test.mock.declareMock
9 |
10 | fun KoinTest.mockOkHttpClient(): OkHttpClient =
11 | declareMock(named("AuthProvider")) {
12 | coEvery { newCall(any()) } returns
13 | mockk {
14 | coEvery { execute() } returns
15 | mockk {
16 | coEvery { isSuccessful } returns true
17 | }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/test/kotlin/com/hypto/iam/server/helpers/MockPasscodeRepo.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.helpers
2 |
3 | import com.hypto.iam.server.db.repositories.PasscodeRepo
4 | import com.hypto.iam.server.db.tables.records.PasscodesRecord
5 | import com.hypto.iam.server.models.VerifyEmailRequest
6 | import io.mockk.coEvery
7 | import org.koin.test.KoinTest
8 | import org.koin.test.mock.declareMock
9 |
10 | fun KoinTest.mockPasscodeRepo(): PasscodeRepo {
11 | return declareMock {
12 | coEvery {
13 | createPasscode(any())
14 | } coAnswers {
15 | firstArg()
16 | }
17 | coEvery {
18 | getValidPasscodeCount(any(), any())
19 | } returns 0
20 | coEvery {
21 | getValidPasscodeCount(any(), any(), any(), any())
22 | } returns 0
23 | coEvery {
24 | getValidPasscodeById(
25 | any(),
26 | any(),
27 | any(),
28 | )
29 | } coAnswers {
30 | PasscodesRecord().setId(firstArg()).setPurpose(secondArg().toString())
31 | .setEmail(thirdArg())
32 | }
33 | coEvery { deleteByEmailAndPurpose(any(), any()) } returns true
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/test/kotlin/com/hypto/iam/server/helpers/MockSes.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.helpers
2 |
3 | import io.mockk.coEvery
4 | import org.koin.test.KoinTest
5 | import org.koin.test.mock.declareMock
6 | import software.amazon.awssdk.services.ses.SesClient
7 | import software.amazon.awssdk.services.ses.model.SendTemplatedEmailRequest
8 | import software.amazon.awssdk.services.ses.model.SendTemplatedEmailResponse
9 |
10 | fun KoinTest.mockSesClient(): SesClient {
11 | return declareMock {
12 | coEvery {
13 | this@declareMock.sendTemplatedEmail(any())
14 | } coAnswers {
15 | SendTemplatedEmailResponse.builder().messageId("1234-5678-3421").build()
16 | }
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/test/kotlin/com/hypto/iam/server/helpers/PostgresInit.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.helpers
2 |
3 | import org.flywaydb.core.Flyway
4 | import org.flywaydb.core.api.Location
5 | import org.flywaydb.core.api.configuration.ClassicConfiguration
6 | import org.testcontainers.containers.PostgreSQLContainer
7 |
8 | /**
9 | * This object is used to initialize the testcontainers only once for all the tests.
10 | */
11 | object PostgresInit {
12 | init {
13 | val testContainer =
14 | PostgreSQLContainer("postgres:14.1-alpine")
15 | .withDatabaseName("iam")
16 | .withUsername("root")
17 | .withPassword("password")
18 |
19 | testContainer.start()
20 |
21 | val configuration = ClassicConfiguration()
22 | configuration.setDataSource(
23 | testContainer.jdbcUrl,
24 | testContainer.username,
25 | testContainer.password,
26 | )
27 | configuration.setLocations(Location("filesystem:src/main/resources/db/migration"))
28 | val flyway = Flyway(configuration)
29 | flyway.migrate()
30 |
31 | System.setProperty("config.override.database.name", "iam")
32 | System.setProperty("config.override.database.user", "root")
33 | System.setProperty("config.override.database.password", "password")
34 | System.setProperty("config.override.database.host", testContainer.host)
35 | System.setProperty(
36 | "config.override.database.port",
37 | testContainer.firstMappedPort.toString(),
38 | )
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/test/kotlin/com/hypto/iam/server/utils/HrnTest.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.utils
2 |
3 | import org.junit.jupiter.api.Assertions
4 | import org.junit.jupiter.api.Assertions.assertEquals
5 | import org.junit.jupiter.api.Test
6 |
7 | internal class HrnTest {
8 | @Test
9 | fun `Test hrn factory`() {
10 | // Invalid Hrn formats
11 | Assertions.assertThrows(HrnParseException::class.java) { HrnFactory.getHrn("") }
12 | Assertions.assertThrows(HrnParseException::class.java) { HrnFactory.getHrn("hrn:iam:::user1") }
13 | Assertions.assertThrows(HrnParseException::class.java) { HrnFactory.getHrn("hrn:iam:user") }
14 | Assertions.assertThrows(HrnParseException::class.java) { HrnFactory.getHrn("hrn::id1:user") }
15 | Assertions.assertThrows(HrnParseException::class.java) { HrnFactory.getHrn("hrn:id1:user") }
16 | Assertions.assertThrows(HrnParseException::class.java) { HrnFactory.getHrn(":iam:id1:user") }
17 | Assertions.assertThrows(HrnParseException::class.java) { HrnFactory.getHrn("iam:id1:user") }
18 | Assertions.assertThrows(HrnParseException::class.java) { HrnFactory.getHrn("hrn:id1:\$user") }
19 |
20 | val resourceInstanceHrn = HrnFactory.getHrn("hrn:hypto::iam-resource/12345")
21 | assert(resourceInstanceHrn is ResourceHrn) { "Resource instance hrn must be of type ResourceHrn" }
22 |
23 | val userInstanceHrn = HrnFactory.getHrn("hrn:hypto::iam-user/12345")
24 | assert(userInstanceHrn is ResourceHrn) { "User instance hrn must be of type ResourceHrn" }
25 |
26 | val resourceHrn = HrnFactory.getHrn("hrn:hypto::ledger")
27 | assert(resourceHrn is ResourceHrn) { "Global resources are still Resources, so type should be ResourceHrn" }
28 | assertEquals("", (resourceHrn as ResourceHrn).resourceInstance)
29 |
30 | val actionHrn = HrnFactory.getHrn("hrn:hypto::ledger\$addTransaction")
31 | assert(actionHrn is ActionHrn) { "Operations are global, so type should be of GlobalHrn" }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/test/kotlin/com/hypto/iam/server/utils/IdGeneratorTest.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.utils
2 |
3 | import org.junit.jupiter.api.Assertions
4 | import org.junit.jupiter.api.Test
5 | import java.lang.Thread.sleep
6 |
7 | class IdGeneratorTest {
8 | private val subject = IdGenerator
9 |
10 | @Test
11 | fun `Test length based ID generation`() {
12 | Assertions.assertEquals(10, subject.randomId().length) // Default length
13 | Assertions.assertEquals(3, subject.randomId(length = 3).length)
14 | Assertions.assertEquals(12, subject.randomId(length = 12).length)
15 | Assertions.assertEquals(55, subject.randomId(length = 55).length)
16 | Assertions.assertEquals(100, subject.randomId(length = 100).length)
17 | }
18 |
19 | @Test
20 | fun `Test Charset based ID generation`() {
21 | val rand1 = subject.randomId(charset = IdGenerator.Charset.UPPERCASE_ALPHABETS)
22 | Assertions.assertEquals(rand1.uppercase(), rand1)
23 | Assertions.assertTrue(rand1.all { it.isLetter() })
24 |
25 | val rand2 = subject.randomId(charset = IdGenerator.Charset.LOWERCASE_ALPHABETS)
26 | Assertions.assertEquals(rand2.lowercase(), rand2)
27 | Assertions.assertTrue(rand2.all { it.isLetter() })
28 |
29 | val rand3 = subject.randomId(charset = IdGenerator.Charset.NUMERIC)
30 | Assertions.assertTrue(rand3.all { it.isDigit() })
31 |
32 | val rand4 = subject.randomId(charset = IdGenerator.Charset.LOWER_ALPHANUMERIC)
33 | Assertions.assertTrue(rand4.all { it.isDigit() || it.isLowerCase() })
34 |
35 | val rand5 = subject.randomId(charset = IdGenerator.Charset.UPPER_ALPHANUMERIC)
36 | Assertions.assertTrue(rand5.all { it.isDigit() || it.isUpperCase() })
37 |
38 | val rand6 = subject.randomId(charset = IdGenerator.Charset.ALPHABETS)
39 | Assertions.assertTrue(rand6.all { it.isLetter() })
40 |
41 | val rand7 = subject.randomId(charset = IdGenerator.Charset.ALPHANUMERIC)
42 | Assertions.assertTrue(rand7.all { it.isDigit() || it.isLetter() })
43 | }
44 |
45 | @Test
46 | fun `Test numberToId`() {
47 | for (charSet in IdGenerator.Charset.values()) {
48 | for (i in 0 until charSet.seed.length) {
49 | Assertions.assertEquals(
50 | subject.numberToId(i.toLong(), charSet),
51 | charSet.seed[i].toString(),
52 | )
53 | }
54 | }
55 |
56 | val number = 1645204287L
57 |
58 | Assertions.assertEquals(subject.numberToId(number, IdGenerator.Charset.NUMERIC), "1645204287")
59 | Assertions.assertEquals(subject.numberToId(number, IdGenerator.Charset.ALPHABETS), "ERAhbz")
60 | Assertions.assertEquals(subject.numberToId(number, IdGenerator.Charset.ALPHANUMERIC), "BxVGxB")
61 | Assertions.assertEquals(subject.numberToId(number, IdGenerator.Charset.UPPERCASE_ALPHABETS), "FIMFEDZ")
62 | Assertions.assertEquals(subject.numberToId(number, IdGenerator.Charset.LOWERCASE_ALPHABETS), "fimfedz")
63 | Assertions.assertEquals(subject.numberToId(number, IdGenerator.Charset.UPPER_ALPHANUMERIC), "1HSP1D")
64 | Assertions.assertEquals(subject.numberToId(number, IdGenerator.Charset.LOWER_ALPHANUMERIC), "1hsp1d")
65 | }
66 |
67 | @Test
68 | fun `Test timeBasedRandomId`() {
69 | Assertions.assertThrows(IllegalArgumentException::class.java) { subject.timeBasedRandomId(length = 5) }
70 |
71 | Assertions.assertEquals(subject.timeBasedRandomId(10).length, 10)
72 |
73 | val id1 = subject.timeBasedRandomId()
74 | sleep(1)
75 | val id2 = subject.timeBasedRandomId()
76 | Assertions.assertTrue(id2 > id1)
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/src/test/kotlin/com/hypto/iam/server/validators/RequestModelValidatorTest.kt:
--------------------------------------------------------------------------------
1 | package com.hypto.iam.server.validators
2 |
3 | import com.hypto.iam.server.models.CreateCredentialRequest
4 | import com.hypto.iam.server.models.UpdateCredentialRequest
5 | import org.junit.jupiter.api.Assertions
6 | import org.junit.jupiter.api.Test
7 | import java.time.LocalDateTime
8 | import java.time.format.DateTimeFormatter
9 |
10 | internal class RequestModelValidatorTest {
11 | // CreateCredentialRequest validations
12 | @Test
13 | fun `CreateCredentialRequest - valid - without validity `() {
14 | val req = CreateCredentialRequest()
15 | Assertions.assertInstanceOf(CreateCredentialRequest::class.java, req.validate())
16 | }
17 |
18 | @Test
19 | fun `CreateCredentialRequest - valid - with validity `() {
20 | val req = CreateCredentialRequest(LocalDateTime.MAX.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME))
21 | Assertions.assertInstanceOf(CreateCredentialRequest::class.java, req.validate())
22 | }
23 |
24 | @Test
25 | fun `CreateCredentialRequest - invalid - validity in the past`() {
26 | val req = CreateCredentialRequest(LocalDateTime.MIN.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME))
27 | Assertions.assertThrows(IllegalArgumentException::class.java) {
28 | req.validate()
29 | }
30 | }
31 |
32 | @Test
33 | fun `CreateCredentialRequest - invalid - invalid validity string format`() {
34 | val req = CreateCredentialRequest("not a valid datetime")
35 | Assertions.assertThrows(IllegalArgumentException::class.java) {
36 | req.validate()
37 | }
38 | }
39 |
40 | // UpdateCredentialRequest validations
41 |
42 | @Test
43 | fun `UpdateCredentialRequest - valid - with only status `() {
44 | val req = UpdateCredentialRequest(status = UpdateCredentialRequest.Status.active)
45 | Assertions.assertInstanceOf(UpdateCredentialRequest::class.java, req.validate())
46 | }
47 |
48 | @Test
49 | fun `UpdateCredentialRequest - valid - with only validity `() {
50 | val req = UpdateCredentialRequest(LocalDateTime.MAX.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME))
51 | Assertions.assertInstanceOf(UpdateCredentialRequest::class.java, req.validate())
52 | }
53 |
54 | @Test
55 | fun `UpdateCredentialRequest - valid - with both status and validity `() {
56 | val req =
57 | UpdateCredentialRequest(
58 | LocalDateTime.MAX.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME),
59 | UpdateCredentialRequest.Status.inactive,
60 | )
61 | Assertions.assertInstanceOf(UpdateCredentialRequest::class.java, req.validate())
62 | }
63 |
64 | @Test
65 | fun `UpdateCredentialRequest - invalid - without status and validity `() {
66 | val req = UpdateCredentialRequest()
67 | Assertions.assertThrows(IllegalArgumentException::class.java) {
68 | Assertions.assertInstanceOf(UpdateCredentialRequest::class.java, req.validate())
69 | }
70 | }
71 |
72 | @Test
73 | fun `UpdateCredentialRequest - invalid - validity in the past`() {
74 | val req = UpdateCredentialRequest(LocalDateTime.MIN.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME))
75 | Assertions.assertThrows(IllegalArgumentException::class.java) {
76 | req.validate()
77 | }
78 | }
79 |
80 | @Test
81 | fun `UpdateCredentialRequest - invalid - invalid validity string format`() {
82 | val req = UpdateCredentialRequest("not a valid datetime")
83 | Assertions.assertThrows(IllegalArgumentException::class.java) {
84 | req.validate()
85 | }
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/src/test/kotlin/org/jooq/impl/ResultImplTest.kt:
--------------------------------------------------------------------------------
1 | /**
2 | * We need this package because ResultImpl is package-private and cannot be directly used in tests.
3 | */
4 | package org.jooq.impl
5 |
6 | import io.mockk.mockk
7 | import org.jooq.Field
8 | import org.jooq.Record
9 | import org.jooq.Result
10 |
11 | fun getResultImpl(records: List): Result {
12 | val result = ResultImpl(mockk(), mockk>())
13 | result.addAll(records)
14 | return result
15 | }
16 |
--------------------------------------------------------------------------------
/src/test/resources/application-custom.conf:
--------------------------------------------------------------------------------
1 | ktor {
2 | application {
3 | modules = [ com.hypto.iam.server.ApplicationKt.handleRequest ]
4 | }
5 | }
--------------------------------------------------------------------------------
/src/test/resources/logback-test.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
5 |
6 |
7 |
8 | ${TEST_LOG_DEST:-build/output/logs}/iam_application.log
9 |
10 |
11 | ${TEST_LOG_DEST:-build/test/output/logs}/iam_application.%d{yyyy-MM-dd}.log
12 |
13 |
14 | ${TEST_LOG_MAX_HISTORY:-1}
15 | 100MB
16 |
17 |
18 |
19 |
20 | %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------