Add anonymous OOBE gateway grants
Provide App Attest-bound, one-time onboarding AI access without creating accounts, with durable replay protection and production deployment safeguards.
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
package com.osglab.account.features.oobe
|
||||
|
||||
import com.osglab.account.features.gateway.models.GatewayCapability
|
||||
import com.osglab.account.features.gateway.models.GatewayModelProfile
|
||||
import com.osglab.account.features.gateway.models.GatewayPrincipal
|
||||
import com.osglab.account.features.gateway.models.GatewayReasoningEffort
|
||||
import com.osglab.account.features.gateway.models.GatewayRequestPurpose
|
||||
import com.osglab.account.features.gateway.models.GatewaySubjectType
|
||||
import com.osglab.account.features.gateway.models.GatewayTaskExecutionPolicy
|
||||
import com.osglab.account.features.gateway.models.GatewayTaskKind
|
||||
import com.osglab.account.features.gateway.models.GatewayThinkingMode
|
||||
import com.osglab.account.features.gateway.models.GatewayToolsMode
|
||||
import com.osglab.account.features.gateway.models.GatewayWebSearchMode
|
||||
import com.osglab.account.features.gateway.models.OobeFeature
|
||||
import com.osglab.account.features.gateway.models.ProviderDescriptor
|
||||
import com.osglab.account.features.gateway.models.ProviderOutput
|
||||
import com.osglab.account.features.gateway.models.ProviderRequest
|
||||
import com.osglab.account.features.gateway.models.ProviderUsage
|
||||
import com.osglab.account.features.gateway.models.TextProviderRequest
|
||||
import com.osglab.account.features.gateway.models.UsageMeter
|
||||
import com.osglab.account.features.gateway.ports.CreditReservation
|
||||
import com.osglab.account.features.gateway.ports.CreditReservationPort
|
||||
import com.osglab.account.features.gateway.ports.GatewayGrantPort
|
||||
import com.osglab.account.features.gateway.ports.GatewayUsagePort
|
||||
import com.osglab.account.features.gateway.ports.PendingSettlement
|
||||
import com.osglab.account.features.gateway.ports.ProviderRequestMetadata
|
||||
import com.osglab.account.features.gateway.providers.GatewayProvider
|
||||
import com.osglab.account.features.gateway.providers.ProviderCatalog
|
||||
import com.osglab.account.features.gateway.services.GatewayAccessDeniedException
|
||||
import com.osglab.account.features.gateway.services.GatewayService
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.core.spec.style.StringSpec
|
||||
import io.kotest.matchers.shouldBe
|
||||
import java.time.Instant
|
||||
|
||||
class OobeGatewayServiceTest : StringSpec({
|
||||
"executes each fixed OOBE feature once without touching credits or account audit" {
|
||||
val credits = CountingCredits()
|
||||
val oobe = FakeOobeExecutionRepository()
|
||||
val service = service(credits, oobe)
|
||||
|
||||
OobeFeature.entries.forEachIndexed { index, feature ->
|
||||
service.execute(OOBE_PRINCIPAL, request(feature, "oobe-feature-$index"), DISCARD)
|
||||
}
|
||||
|
||||
credits.calls shouldBe 0
|
||||
oobe.consumed.map(OobeRequestClaim::feature).toSet() shouldBe OobeFeature.entries.toSet()
|
||||
}
|
||||
|
||||
"rejects a fifth call and a repeated feature without paid fallback" {
|
||||
val credits = CountingCredits()
|
||||
val oobe = FakeOobeExecutionRepository()
|
||||
val service = service(credits, oobe)
|
||||
OobeFeature.entries.forEachIndexed { index, feature ->
|
||||
service.execute(OOBE_PRINCIPAL, request(feature, "oobe-once-$index"), DISCARD)
|
||||
}
|
||||
|
||||
shouldThrow<OobeFeatureAlreadyUsedException> {
|
||||
service.execute(OOBE_PRINCIPAL, request(OobeFeature.ASK_AI, "oobe-fifth-call"), DISCARD)
|
||||
}
|
||||
credits.calls shouldBe 0
|
||||
}
|
||||
|
||||
"releases the feature claim when the provider fails" {
|
||||
val credits = CountingCredits()
|
||||
val oobe = FakeOobeExecutionRepository()
|
||||
val service = service(credits, oobe, fail = true)
|
||||
|
||||
shouldThrow<ProviderFailure> {
|
||||
service.execute(OOBE_PRINCIPAL, request(OobeFeature.VOICE_INPUT, "oobe-provider-fail"), DISCARD)
|
||||
}
|
||||
|
||||
oobe.released.map(OobeRequestClaim::feature) shouldBe listOf(OobeFeature.VOICE_INPUT)
|
||||
credits.calls shouldBe 0
|
||||
}
|
||||
|
||||
"enforces token boundary and exact feature mapping" {
|
||||
val credits = CountingCredits()
|
||||
val service = service(credits, FakeOobeExecutionRepository())
|
||||
|
||||
shouldThrow<IllegalArgumentException> {
|
||||
service.execute(
|
||||
OOBE_PRINCIPAL,
|
||||
request(OobeFeature.ASK_AI, "oobe-wrong-map").copy(
|
||||
executionPolicy = policy(GatewayTaskKind.CLIPBOARD_TRANSFORM),
|
||||
),
|
||||
DISCARD,
|
||||
)
|
||||
}
|
||||
shouldThrow<GatewayAccessDeniedException> {
|
||||
service.execute(
|
||||
ACCOUNT_PRINCIPAL,
|
||||
request(OobeFeature.ASK_AI, "account-oobe-feature"),
|
||||
DISCARD,
|
||||
)
|
||||
}
|
||||
credits.calls shouldBe 0
|
||||
}
|
||||
})
|
||||
|
||||
private fun service(
|
||||
credits: CountingCredits,
|
||||
oobe: OobeRepository,
|
||||
fail: Boolean = false,
|
||||
): GatewayService = GatewayService(
|
||||
catalog = ProviderCatalog(listOf(FakeOobeProvider(fail))),
|
||||
credits = credits,
|
||||
grants = GatewayGrantPort { _, _ -> error("account grant lookup must not run for OOBE") },
|
||||
usageRecords = NoAccountUsage,
|
||||
oobeRequests = oobe,
|
||||
)
|
||||
|
||||
private class FakeOobeProvider(private val fail: Boolean) : GatewayProvider {
|
||||
override val descriptor = ProviderDescriptor(
|
||||
id = "oobe-test-provider",
|
||||
capabilities = setOf(GatewayCapability.POLISH, GatewayCapability.AI),
|
||||
streaming = false,
|
||||
usageMeter = UsageMeter.LLM_TOKEN,
|
||||
)
|
||||
|
||||
override suspend fun execute(request: ProviderRequest, output: ProviderOutput): ProviderUsage {
|
||||
if (fail) throw ProviderFailure()
|
||||
return ProviderUsage(
|
||||
meter = UsageMeter.LLM_TOKEN,
|
||||
units = 2,
|
||||
inputUnits = 1,
|
||||
outputUnits = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class CountingCredits : CreditReservationPort {
|
||||
var calls = 0
|
||||
|
||||
override suspend fun reserve(
|
||||
accountId: String,
|
||||
meter: UsageMeter,
|
||||
estimatedUnits: Long,
|
||||
requestId: String,
|
||||
): CreditReservation {
|
||||
calls += 1
|
||||
error("credits must not be called")
|
||||
}
|
||||
|
||||
override suspend fun settle(reservationId: String, actualUnits: Long) {
|
||||
calls += 1
|
||||
error("credits must not be called")
|
||||
}
|
||||
|
||||
override suspend fun release(reservationId: String) {
|
||||
calls += 1
|
||||
error("credits must not be called")
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeOobeExecutionRepository : OobeRepository {
|
||||
private val claimedFeatures = mutableSetOf<OobeFeature>()
|
||||
val consumed = mutableListOf<OobeRequestClaim>()
|
||||
val released = mutableListOf<OobeRequestClaim>()
|
||||
|
||||
override suspend fun claim(
|
||||
request: OobeProviderRequest,
|
||||
expiresAt: Instant,
|
||||
now: Instant,
|
||||
): OobeRequestClaim? {
|
||||
if (!claimedFeatures.add(request.feature)) return null
|
||||
return OobeRequestClaim(request.subjectId, request.feature, request.requestId)
|
||||
}
|
||||
|
||||
override suspend fun markStarted(claim: OobeRequestClaim) = Unit
|
||||
|
||||
override suspend fun consume(claim: OobeRequestClaim, usage: ProviderUsage) {
|
||||
consumed += claim
|
||||
}
|
||||
|
||||
override suspend fun release(claim: OobeRequestClaim, errorCode: String) {
|
||||
claimedFeatures -= claim.feature
|
||||
released += claim
|
||||
}
|
||||
|
||||
override suspend fun markManualReview(claim: OobeRequestClaim, errorCode: String) = Unit
|
||||
|
||||
override suspend fun findOrCreateSubject(
|
||||
keyId: String,
|
||||
installationHash: String,
|
||||
subjectId: String,
|
||||
now: Instant,
|
||||
): OobeSubject = error("not used")
|
||||
|
||||
override suspend fun createGrant(grant: NewOobeGrant, now: Instant): StoredOobeRefresh = error("not used")
|
||||
|
||||
override suspend fun rotateRefresh(
|
||||
currentTokenHash: String,
|
||||
rotationIdempotencyKey: String,
|
||||
newTokenId: String,
|
||||
newTokenHash: String,
|
||||
newExpiresAt: Instant,
|
||||
now: Instant,
|
||||
): OobeRefreshRotationResult = error("not used")
|
||||
|
||||
override suspend fun findActiveGrant(grantId: String, subjectId: String, now: Instant): OobeGrant? =
|
||||
error("not used")
|
||||
}
|
||||
|
||||
private object NoAccountUsage : GatewayUsagePort {
|
||||
override suspend fun claim(metadata: ProviderRequestMetadata) = error("account audit must not be called")
|
||||
override suspend fun markStarted(accountId: String, requestId: String) = error("account audit must not be called")
|
||||
override suspend fun markSettlementPending(
|
||||
accountId: String,
|
||||
requestId: String,
|
||||
usage: ProviderUsage,
|
||||
) = error("account audit must not be called")
|
||||
|
||||
override suspend fun markSucceeded(accountId: String, requestId: String, usage: ProviderUsage) =
|
||||
error("account audit must not be called")
|
||||
|
||||
override suspend fun markReleased(accountId: String, requestId: String, errorCode: String) =
|
||||
error("account audit must not be called")
|
||||
|
||||
override suspend fun markManualReview(accountId: String, requestId: String, errorCode: String) =
|
||||
error("account audit must not be called")
|
||||
|
||||
override suspend fun findSettlementPending(limit: Int): List<PendingSettlement> = emptyList()
|
||||
}
|
||||
|
||||
private fun request(feature: OobeFeature, requestId: String): TextProviderRequest {
|
||||
val mapping = OobeContract.policy(feature)
|
||||
return TextProviderRequest(
|
||||
requestId = requestId,
|
||||
capability = mapping.capability,
|
||||
executionPolicy = policy(mapping.taskKind),
|
||||
input = "hello",
|
||||
context = null,
|
||||
maxOutputTokens = 1,
|
||||
temperature = 0.0,
|
||||
stream = false,
|
||||
requestPurpose = GatewayRequestPurpose.OOBE,
|
||||
oobeFeature = feature,
|
||||
)
|
||||
}
|
||||
|
||||
private fun policy(taskKind: GatewayTaskKind) = GatewayTaskExecutionPolicy(
|
||||
taskKind = taskKind,
|
||||
modelProfile = GatewayModelProfile.LOW_LATENCY,
|
||||
thinking = GatewayThinkingMode.DISABLED,
|
||||
reasoningEffort = null as GatewayReasoningEffort?,
|
||||
webSearch = GatewayWebSearchMode.DISABLED,
|
||||
tools = GatewayToolsMode.DISABLED,
|
||||
allowEmptyContentRetry = false,
|
||||
maxOutputTokens = 1,
|
||||
)
|
||||
|
||||
private val OOBE_PRINCIPAL = GatewayPrincipal(
|
||||
userId = "20000000-0000-0000-0000-000000000001",
|
||||
grantId = "30000000-0000-0000-0000-000000000001",
|
||||
scopes = OobeContract.scopes,
|
||||
subjectType = GatewaySubjectType.OOBE,
|
||||
)
|
||||
private val ACCOUNT_PRINCIPAL = GatewayPrincipal(
|
||||
userId = "40000000-0000-0000-0000-000000000001",
|
||||
scopes = OobeContract.scopes,
|
||||
)
|
||||
private val DISCARD = ProviderOutput {}
|
||||
private class ProviderFailure : RuntimeException()
|
||||
Reference in New Issue
Block a user