Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions stytch/src/main/kotlin/com/stytch/java/common/BasePolicyCache.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package com.stytch.java.common

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.time.Duration
import java.time.Instant

public class PermissionException(
// Accepts either the B2B or consumer AuthorizationCheck; rendered via toString for the message.
authorizationCheck: Any,
) : RuntimeException("Permission denied for request $authorizationCheck")

/**
* A normalized view of an RBAC Role used for local authorization checks. Both the B2B and consumer
* policy caches map their (distinct) generated Role types into this shape so the matching logic can
* be shared.
*/
internal data class RoleView(
val roleId: String,
val permissions: List<PermissionView>,
)

internal data class PermissionView(
val resourceId: String,
val actions: List<String>,
)

/**
* Returns true if any of the subject's roles grants the given action on the given resource.
* A permission matches when its resource matches and its actions contain the requested action or
* the "*" wildcard.
*/
internal fun hasRolePermission(
subjectRoles: List<String>,
roles: List<RoleView>,
resourceId: String,
action: String,
): Boolean =
roles
.filter { it.roleId in subjectRoles }
.flatMap { it.permissions }
.any {
val hasMatchingAction = it.actions.contains("*") || it.actions.contains(action)
val hasMatchingResource = it.resourceId == resourceId
hasMatchingAction && hasMatchingResource
}

/**
* Shared caching machinery for a project-level RBAC policy. Subclasses supply how to fetch the
* policy and may refresh additional caches (e.g. org policies) during the background refresh loop.
*
* The policy is fetched on first use and refreshed both lazily (when stale) and by a background
* coroutine on a fixed interval.
*/
internal abstract class BasePolicyCache<P>(
coroutineScope: CoroutineScope,
) {
private val job = SupervisorJob(coroutineScope.coroutineContext[Job])
protected val scope: CoroutineScope = CoroutineScope(coroutineScope.coroutineContext + job)
private var cachedPolicy: P? = null
private var policyLastUpdate: Instant? = null
private var backgroundRefreshStarted = false

companion object {
private const val CACHE_TTL_SECONDS = 3600L // 1 hour
private const val REFRESH_INTERVAL_MS = 3600000L // 1 hour in milliseconds
}

/** Fetch the latest policy from the API, or null if the request failed. */
protected abstract fun fetchPolicy(): P?

/** Hook for subclasses to refresh any additional caches during the background refresh loop. */
protected open fun refreshAdditionalCaches() {}

protected fun getPolicy(invalidate: Boolean = false): P {
val isMissing = cachedPolicy == null || policyLastUpdate == null
val isStale = policyLastUpdate == null || Duration.between(policyLastUpdate, Instant.now()).seconds > CACHE_TTL_SECONDS
if (invalidate || isMissing || isStale) {
refreshPolicy()
}

// Start background refresh after first successful fetch
if (!backgroundRefreshStarted && cachedPolicy != null) {
startBackgroundRefresh()
backgroundRefreshStarted = true
}

return cachedPolicy ?: throw Exception("Error fetching the policy")
}

private fun refreshPolicy() {
fetchPolicy()?.let {
cachedPolicy = it
policyLastUpdate = Instant.now()
}
}

private fun startBackgroundRefresh() {
scope.launch {
while (isActive) {
delay(REFRESH_INTERVAL_MS)
refreshPolicy()
refreshAdditionalCaches()
}
}
}

/**
* Cancels the background refresh job.
* This allows the refresh job to be stopped independently of the parent scope.
*/
fun cancelBackgroundRefresh() {
job.cancel()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.stytch.java.common

import com.stytch.java.consumer.api.rbac.RBAC
import com.stytch.java.consumer.models.rbac.Policy
import com.stytch.java.consumer.models.rbac.PolicyRequest
import com.stytch.java.consumer.models.sessions.AuthorizationCheck
import kotlinx.coroutines.CoroutineScope

/**
* Consumer (B2C) counterpart to [PolicyCache]. Unlike the B2B cache, consumer RBAC is not tenanted,
* so there is no org-specific policy and no tenancy check — authorization is evaluated purely
* against the project-level policy's roles.
*/
internal class ConsumerPolicyCache(
private val client: RBAC,
coroutineScope: CoroutineScope,
) : BasePolicyCache<Policy>(coroutineScope) {
override fun fetchPolicy(): Policy? =
when (val result = client.policyCompletable(PolicyRequest()).get()) {
is StytchResult.Success -> result.value.policy
else -> null
}

fun performAuthorizationCheck(
subjectRoles: List<String>,
authorizationCheck: AuthorizationCheck,
) {
val policy = getPolicy()
val roleViews =
policy.roles.map { role ->
RoleView(
roleId = role.roleId,
permissions = role.permissions.map { PermissionView(it.resourceId, it.actions) },
)
}

if (hasRolePermission(subjectRoles, roleViews, authorizationCheck.resourceId, authorizationCheck.action)) {
return
}
throw PermissionException(authorizationCheck)
}
}
89 changes: 20 additions & 69 deletions stytch/src/main/kotlin/com/stytch/java/common/PolicyCache.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,6 @@ import com.stytch.java.b2b.models.rbac.PolicyRole
import com.stytch.java.b2b.models.rbacorganizations.GetOrgPolicyRequest
import com.stytch.java.b2b.models.sessions.AuthorizationCheck
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.time.Duration
import java.time.Instant

Expand All @@ -22,10 +17,6 @@ public class TenancyException(
authCheckOrgId: String,
) : RuntimeException("Subject organizationId $subjectOrgId does not match authZ request organizationId $authCheckOrgId")

public class PermissionException(
authorizationCheck: AuthorizationCheck,
) : RuntimeException("Permission denied for request $authorizationCheck")

private data class CachedOrgPolicy(
val orgPolicy: OrgPolicy,
val lastUpdate: Instant,
Expand All @@ -35,33 +26,24 @@ internal class PolicyCache(
private val client: RBAC,
coroutineScope: CoroutineScope,
private val organizations: Organizations? = null,
) {
private val job = SupervisorJob(coroutineScope.coroutineContext[Job])
private val scope = CoroutineScope(coroutineScope.coroutineContext + job)
private var cachedPolicy: Policy? = null
) : BasePolicyCache<Policy>(coroutineScope) {
private val cachedOrgPolicies: MutableMap<String, CachedOrgPolicy> = mutableMapOf()
private var policyLastUpdate: Instant? = null
private var backgroundRefreshStarted = false

companion object {
private const val CACHE_TTL_SECONDS = 3600L // 1 hour
private const val REFRESH_INTERVAL_MS = 3600000L // 1 hour in milliseconds
}

private fun getPolicy(invalidate: Boolean = false): Policy {
val isMissing = cachedPolicy == null || policyLastUpdate == null
val isStale = policyLastUpdate == null || Duration.between(policyLastUpdate, Instant.now()).seconds > CACHE_TTL_SECONDS
if (invalidate || isMissing || isStale) {
refreshPolicy()
override fun fetchPolicy(): Policy? =
when (val result = client.policyCompletable(PolicyRequest()).get()) {
is StytchResult.Success -> result.value.policy
else -> null
}

// Start background refresh after first successful fetch
if (!backgroundRefreshStarted && cachedPolicy != null) {
startBackgroundRefresh()
backgroundRefreshStarted = true
override fun refreshAdditionalCaches() {
// Refresh all cached org policies
cachedOrgPolicies.keys.toList().forEach { orgId ->
refreshOrgPolicy(orgId)
}

return cachedPolicy ?: throw Exception("Error fetching the policy")
}

private fun getOrgPolicy(
Expand Down Expand Up @@ -92,38 +74,6 @@ internal class PolicyCache(
}
}

private fun refreshPolicy() {
when (val result = client.policyCompletable(PolicyRequest()).get()) {
is StytchResult.Success -> {
cachedPolicy = result.value.policy
policyLastUpdate = Instant.now()
}

else -> {}
}
}

private fun startBackgroundRefresh() {
scope.launch {
while (isActive) {
delay(REFRESH_INTERVAL_MS)
refreshPolicy()
// Refresh all cached org policies
cachedOrgPolicies.keys.toList().forEach { orgId ->
refreshOrgPolicy(orgId)
}
}
}
}

/**
* Cancels the background refresh job.
* This allows the refresh job to be stopped independently of the parent scope.
*/
fun cancelBackgroundRefresh() {
job.cancel()
}

fun performAuthorizationCheck(
subjectRoles: List<String>,
subjectOrgId: String,
Expand All @@ -142,16 +92,17 @@ internal class PolicyCache(
orgPolicy?.roles?.let { addAll(it) }
}

val hasMatchingActionAndResource =
allRoles
.filter { it.roleId in subjectRoles }
.flatMap { it.permissions }
.filter {
val hasMatchingAction = it.actions.contains("*") || it.actions.contains(authorizationCheck.action)
val hasMatchingResource = it.resourceId == authorizationCheck.resourceId
return@filter hasMatchingAction && hasMatchingResource
}.isNotEmpty()
hasMatchingActionAndResource && return
val roleViews =
allRoles.map { role ->
RoleView(
roleId = role.roleId,
permissions = role.permissions.map { PermissionView(it.resourceId, it.actions) },
)
}

if (hasRolePermission(subjectRoles, roleViews, authorizationCheck.resourceId, authorizationCheck.action)) {
return
}
throw PermissionException(authorizationCheck)
}
}
2 changes: 1 addition & 1 deletion stytch/src/main/kotlin/com/stytch/java/common/Version.kt
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
package com.stytch.java.common

internal const val VERSION = "11.2.0"
internal const val VERSION = "11.3.0"
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package com.stytch.java.consumer
// !!!
import com.stytch.java.common.BASE_LIVE_URL
import com.stytch.java.common.BASE_TEST_URL
import com.stytch.java.common.ConsumerPolicyCache
import com.stytch.java.common.JwksCache
import com.stytch.java.common.JwtOptions
import com.stytch.java.common.OptionalClientConfig
Expand Down Expand Up @@ -74,12 +75,13 @@ public class StytchClient
issuers = listOf("stytch.com/$projectId", baseUrl),
type = "JWT",
)
private val policyCache: ConsumerPolicyCache = ConsumerPolicyCache(RBACImpl(httpClient, coroutineScope), coroutineScope)

public val connectedApp: ConnectedApp = ConnectedAppImpl(httpClient, coroutineScope)
public val cryptoWallets: CryptoWallets = CryptoWalletsImpl(httpClient, coroutineScope)
public val debug: Debug = DebugImpl(httpClient, coroutineScope)
public val fraud: Fraud = FraudImpl(fraudHttpClient, coroutineScope)
public val idp: IDP = IDPImpl(httpClient, coroutineScope, httpsJwks, jwtOptions)
public val idp: IDP = IDPImpl(httpClient, coroutineScope, httpsJwks, jwtOptions, policyCache)
public val impersonation: Impersonation = ImpersonationImpl(httpClient, coroutineScope)
public val m2m: M2M = M2MImpl(httpClient, coroutineScope, httpsJwks, jwtOptions)
public val magicLinks: MagicLinks = MagicLinksImpl(httpClient, coroutineScope)
Expand All @@ -88,7 +90,7 @@ public class StytchClient
public val passwords: Passwords = PasswordsImpl(httpClient, coroutineScope)
public val project: Project = ProjectImpl(httpClient, coroutineScope)
public val rbac: RBAC = RBACImpl(httpClient, coroutineScope)
public val sessions: Sessions = SessionsImpl(httpClient, coroutineScope, httpsJwks, jwtOptions)
public val sessions: Sessions = SessionsImpl(httpClient, coroutineScope, httpsJwks, jwtOptions, policyCache)
public val totps: TOTPs = TOTPsImpl(httpClient, coroutineScope)
public val users: Users = UsersImpl(httpClient, coroutineScope)
public val webauthn: WebAuthn = WebAuthnImpl(httpClient, coroutineScope)
Expand All @@ -98,6 +100,7 @@ public class StytchClient
// cancelBackgroundRefresh() is redundant once the scope is cancelled (child jobs
// are parented off this scope), but called first for explicit intent.
jwksCache.cancelBackgroundRefresh()
policyCache.cancelBackgroundRefresh()
coroutineScope.cancel()
httpClient.close()
fraudHttpClient.close()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package com.stytch.java.consumer.api.idp
// !!!

import com.squareup.moshi.Moshi
import com.stytch.java.common.ConsumerPolicyCache
import com.stytch.java.common.InstantAdapter
import com.stytch.java.common.JwtOptions
import com.stytch.java.consumer.api.idpoauth.OAuth
Expand All @@ -24,6 +25,7 @@ internal class IDPImpl(
private val coroutineScope: CoroutineScope,
private val jwksClient: HttpsJwks,
private val jwtOptions: JwtOptions,
private val policyCache: ConsumerPolicyCache,
) : IDP {
private val moshi = Moshi.Builder().add(InstantAdapter()).build()

Expand Down
Loading
Loading