Add complimentary OOBE polish and configurable admin mTLS
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled

Allow one server-audited onboarding polish request without credits and make the certificate gate temporarily reversible while preserving application authentication.
This commit is contained in:
Rocky
2026-08-20 17:05:35 +08:00
parent 0b4acb5978
commit 034a3e8745
25 changed files with 698 additions and 64 deletions
@@ -17,6 +17,7 @@ class AppConfigTest : FunSpec({
config.credits.signupTrial shouldBe 1_000
config.credits.referralInviter shouldBe 1_000
config.credits.referralInvitee shouldBe 1_000
config.admin.mtlsRequired shouldBe true
}
test("production rejects placeholder secrets") {
@@ -68,6 +69,14 @@ class AppConfigTest : FunSpec({
admin.bootstrapTotpSecretBase32 shouldBe null
}
test("administrator mTLS can be disabled explicitly") {
val config = validConfig("test").apply {
put("app.admin.mtlsRequired", "false")
}
AppConfig.from(config).admin.mtlsRequired shouldBe false
}
test("admin bootstrap cannot be enabled while admin routes are disabled") {
val config = validProductionConfig().apply {
put("app.admin.enabled", "false")
@@ -169,6 +169,7 @@ class DeploymentConsistencyTest : FunSpec({
val smokePrivileges = root.read("deploy/smoke/runtime-grants.sql")
compose shouldContain "ADMIN_BOOTSTRAP_ENABLED: \${ADMIN_BOOTSTRAP_ENABLED:-false}"
compose shouldContain "ADMIN_MTLS_REQUIRED: \${ADMIN_MTLS_REQUIRED:-true}"
privileges shouldContain "GRANT SELECT ON osg_account.admin_operators"
privileges shouldContain "GRANT INSERT, UPDATE ON osg_account.admin_operators"
privileges shouldContain "GRANT SELECT ON osg_account.admin_sessions"
@@ -177,6 +178,9 @@ class DeploymentConsistencyTest : FunSpec({
privileges shouldContain "GRANT INSERT ON osg_account.gateway_grant_scopes"
privileges shouldContain "GRANT SELECT ON osg_account.gateway_refresh_tokens"
privileges shouldContain "GRANT INSERT, UPDATE ON osg_account.gateway_refresh_tokens"
privileges shouldContain "GRANT SELECT ON osg_account.gateway_complimentary_requests"
privileges shouldContain
"GRANT INSERT, UPDATE, DELETE ON osg_account.gateway_complimentary_requests"
privileges shouldContain "GRANT SELECT ON osg_account.account_profiles"
privileges shouldContain "GRANT INSERT, UPDATE ON osg_account.account_profiles"
privileges shouldContain "GRANT INSERT ON osg_account.admin_audit_log"
@@ -206,6 +210,8 @@ class DeploymentConsistencyTest : FunSpec({
test("OpenResty proxies HTTP WebSocket invitations and both AASA paths safely") {
val openResty = root.read("deploy/openresty-account.conf")
openResty shouldContain "proxy_set_header X-OSG-mTLS-Verified \$ssl_client_verify;"
openResty shouldNotContain "proxy_set_header X-OSG-mTLS-Verified \"SUCCESS\";"
openResty shouldContain "proxy_set_header Upgrade \$http_upgrade;"
openResty shouldContain "proxy_set_header Connection \$connection_upgrade;"
openResty shouldContain "location = /.well-known/apple-app-site-association"
@@ -37,7 +37,7 @@ class SmokeDeploymentTest : FunSpec({
runner shouldContain "APPLE_JWKS_URL=http://127.0.0.1:9/"
runner shouldContain "VOLCENGINE_ASR_ENDPOINT=ws://127.0.0.1:9/"
runner shouldContain "DEEPSEEK_ENDPOINT=http://127.0.0.1:9/"
runner shouldContain "Flyway history was not exactly successful V1-V12"
runner shouldContain "Flyway history was not exactly successful V1-V17"
runner shouldContain "default referral rewards were not 1000 credits for both accounts"
runner shouldContain "active smaller credit rates did not match the V10 contract"
runner shouldContain "first ledger page omitted nextCursor"
@@ -51,7 +51,7 @@ class SmokeDeploymentTest : FunSpec({
test("runtime grants cover every migrated table without mutable history privileges") {
val grants = root.read("deploy/smoke/runtime-grants.sql")
val migrationTables = (1..16)
val migrationTables = (1..17)
.flatMap { version ->
val migration = Files.list(root.resolve("src/main/resources/db/migration")).use { paths ->
paths.filter { it.fileName.toString().startsWith("V${version}__") }
@@ -69,6 +69,8 @@ class SmokeDeploymentTest : FunSpec({
grantedTables.sorted() shouldContainExactly migrationTables.sorted()
grants shouldContain "GRANT INSERT, UPDATE, DELETE ON osg_account_smoke.admin_sessions"
grants shouldContain
"GRANT INSERT, UPDATE, DELETE ON osg_account_smoke.gateway_complimentary_requests"
grants shouldNotContain "UPDATE ON osg_account_smoke.credit_ledger"
grants shouldNotContain "DELETE ON osg_account_smoke.credit_ledger"
grants shouldNotContain "UPDATE ON osg_account_smoke.admin_audit_log"
@@ -68,6 +68,16 @@ class AdminRoutesTest {
response.bodyAsText() shouldContain """"authenticated":false"""
}
@Test
fun `disabled mTLS allows anonymous session check without edge header`() = testApplication {
application { installAdminTestRoutes(mtlsRequired = false) }
val response = client.get("/v1/admin/auth/session")
assertEquals(HttpStatusCode.OK, response.status)
response.bodyAsText() shouldContain """"authenticated":false"""
}
@Test
fun `authenticated session exposes role for client-side capability navigation`() = testApplication {
application {
@@ -103,9 +113,20 @@ class AdminRoutesTest {
}
@Test
fun `admin web resources are embedded`() = testApplication {
fun `admin web resources are hidden when mTLS is required`() = testApplication {
application {
routing { adminWebRoutes() }
routing { adminWebRoutes(adminTestConfig()) }
}
val response = client.get("/admin/")
assertEquals(HttpStatusCode.NotFound, response.status)
}
@Test
fun `admin web resources are embedded when mTLS is disabled`() = testApplication {
application {
routing { adminWebRoutes(adminTestConfig(mtlsRequired = false)) }
}
val response = client.get("/admin/")
@@ -296,6 +317,7 @@ private fun io.ktor.server.application.Application.installAdminTestRoutes(
operatorService: AdminOperatorService = mockk(relaxed = true),
auditService: AdminAuditService = mockk(relaxed = true),
usersService: AdminUsersService = mockk(relaxed = true),
mtlsRequired: Boolean = true,
) {
install(ContentNegotiation) {
json(Json { explicitNulls = false })
@@ -305,11 +327,7 @@ private fun io.ktor.server.application.Application.installAdminTestRoutes(
rateLimiter(limit = 20, refillPeriod = 1.minutes)
}
}
val config = mockk<AppConfig> {
every { publicBaseUrl } returns "https://account.osglab.com"
every { isProduction } returns false
every { admin } returns AdminConfig()
}
val config = adminTestConfig(mtlsRequired)
routing {
adminApiRoutes(
config = config,
@@ -325,6 +343,12 @@ private fun io.ktor.server.application.Application.installAdminTestRoutes(
}
}
private fun adminTestConfig(mtlsRequired: Boolean = true) = mockk<AppConfig> {
every { publicBaseUrl } returns "https://account.osglab.com"
every { isProduction } returns false
every { admin } returns AdminConfig(mtlsRequired = mtlsRequired)
}
private fun grantRouteFixture(
failure: RuntimeException,
): Pair<AdminSessionService, AdminGrantService> {
@@ -0,0 +1,135 @@
package com.osglab.account.features.gateway.repositories
import com.osglab.account.config.DatabaseConfig
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayRequestPurpose
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import java.sql.DriverManager
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.util.UUID
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import org.opentest4j.TestAbortedException
import org.testcontainers.DockerClientFactory
import org.testcontainers.containers.MySQLContainer
class GatewayComplimentaryRepositoryIntegrationTest : FunSpec({
test("complimentary claims are atomic consumed once and releasable before success") {
withGatewayDatabase { config, databaseFactory ->
val now = Instant.parse("2026-08-20T08:00:00Z")
val consumedAccount = UUID.randomUUID()
val releasedAccount = UUID.randomUUID()
insertAccounts(config, listOf(consumedAccount, releasedAccount), now)
val repository = ExposedGatewayRepository(
databaseFactory,
Clock.fixed(now, ZoneOffset.UTC),
)
val concurrentClaims = coroutineScope {
(1..8).map { index ->
async(Dispatchers.Default) {
repository.claim(
accountId = consumedAccount.toString(),
purpose = GatewayRequestPurpose.OOBE,
capability = GatewayCapability.POLISH,
requestId = "concurrent-oobe-$index",
)
}
}.awaitAll()
}
val winningClaim = concurrentClaims.filterNotNull().single()
repository.consume(winningClaim)
repository.release(winningClaim)
repository.claim(
accountId = consumedAccount.toString(),
purpose = GatewayRequestPurpose.OOBE,
capability = GatewayCapability.POLISH,
requestId = "consumed-replay",
) shouldBe null
val releasedClaim = repository.claim(
accountId = releasedAccount.toString(),
purpose = GatewayRequestPurpose.OOBE,
capability = GatewayCapability.POLISH,
requestId = "released-first-attempt",
)
releasedClaim shouldNotBe null
repository.release(requireNotNull(releasedClaim))
repository.claim(
accountId = releasedAccount.toString(),
purpose = GatewayRequestPurpose.OOBE,
capability = GatewayCapability.POLISH,
requestId = "released-retry",
) shouldNotBe null
}
}
})
private suspend fun withGatewayDatabase(
block: suspend (DatabaseConfig, DatabaseFactory) -> Unit,
) {
val externalJdbcUrl = System.getenv("TEST_MYSQL_JDBC_URL")?.takeIf(String::isNotBlank)
if (externalJdbcUrl == null && !DockerClientFactory.instance().isDockerAvailable) {
throw TestAbortedException("Docker is unavailable; MySQL integration test skipped")
}
val mysql = if (externalJdbcUrl == null) {
GatewayMySqlContainer("mysql:8.4")
.withDatabaseName("osg_gateway_complimentary_test")
.withUsername("test")
.withPassword("test")
.also(GatewayMySqlContainer::start)
} else {
null
}
val config = DatabaseConfig(
jdbcUrl = externalJdbcUrl ?: requireNotNull(mysql).jdbcUrl,
username = System.getenv("TEST_MYSQL_USER")?.takeIf(String::isNotBlank)
?: mysql?.username
?: "root",
password = System.getenv("TEST_MYSQL_PASSWORD") ?: mysql?.password ?: "",
maximumPoolSize = 8,
)
val databaseFactory = DatabaseFactory(config)
try {
databaseFactory.database
block(config, databaseFactory)
} finally {
databaseFactory.close()
mysql?.stop()
}
}
private fun insertAccounts(
config: DatabaseConfig,
accountIds: List<UUID>,
now: Instant,
) {
DriverManager.getConnection(config.jdbcUrl, config.username, config.password).use { connection ->
connection.prepareStatement(
"""
INSERT INTO accounts (id, apple_sub, created_at, updated_at)
VALUES (?, ?, ?, ?)
""".trimIndent(),
).use { statement ->
accountIds.forEach { accountId ->
statement.setString(1, accountId.toString())
statement.setString(2, "gateway-test-$accountId")
statement.setTimestamp(3, java.sql.Timestamp.from(now))
statement.setTimestamp(4, java.sql.Timestamp.from(now))
statement.addBatch()
}
statement.executeBatch()
}
}
}
private class GatewayMySqlContainer(image: String) :
MySQLContainer<GatewayMySqlContainer>(image)
@@ -2,6 +2,7 @@ package com.osglab.account.features.gateway.services
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayPrincipal
import com.osglab.account.features.gateway.models.GatewayRequestPurpose
import com.osglab.account.features.gateway.models.GatewayRequestSource
import com.osglab.account.features.gateway.models.ProviderDescriptor
import com.osglab.account.features.gateway.models.ProviderOutput
@@ -11,9 +12,13 @@ import com.osglab.account.features.gateway.models.TextProviderRequest
import com.osglab.account.features.gateway.models.UsageMeter
import com.osglab.account.features.gateway.ports.CreditMeterPort
import com.osglab.account.features.gateway.ports.CreditReservation
import com.osglab.account.features.gateway.ports.ComplimentaryRequestClaim
import com.osglab.account.features.gateway.ports.ComplimentaryRequestPort
import com.osglab.account.features.gateway.ports.GatewayRequestAlreadyClaimedException
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.ports.ProviderRequestState
import com.osglab.account.features.gateway.ports.ProviderUsageEstimate
import com.osglab.account.features.gateway.providers.GatewayProvider
import com.osglab.account.features.gateway.providers.ProviderCatalog
@@ -80,6 +85,75 @@ class GatewayServiceBillingTest : StringSpec({
usageRecords.lastClaim?.requestSource shouldBe GatewayRequestSource.HOTWORD
}
"executes one OOBE dictation polish without reserving or settling credits" {
val credits = FakeCredits()
val complimentary = FakeComplimentaryRequests()
val usageRecords = FakeUsageRecords()
val service = service(
credits = credits,
provider = FakeProvider(capability = GatewayCapability.POLISH),
usageRecords = usageRecords,
complimentaryRequests = complimentary,
)
service.execute(
PRINCIPAL.copy(scopes = setOf(GatewayCapability.POLISH)),
oobeRequest(),
DISCARD_OUTPUT,
)
credits.reserveCalls shouldBe 0
credits.settled shouldBe emptyList()
credits.released shouldBe emptyList()
complimentary.consumed.size shouldBe 1
complimentary.released shouldBe emptyList()
usageRecords.lastClaim?.reservationId shouldBe null
usageRecords.lastClaim?.requestPurpose shouldBe GatewayRequestPurpose.OOBE
}
"rejects a second OOBE polish without falling through to paid billing" {
val credits = FakeCredits()
val complimentary = FakeComplimentaryRequests(available = false)
val service = service(
credits = credits,
provider = FakeProvider(capability = GatewayCapability.POLISH),
complimentaryRequests = complimentary,
)
shouldThrow<ComplimentaryRequestUnavailableException> {
service.execute(
PRINCIPAL.copy(scopes = setOf(GatewayCapability.POLISH)),
oobeRequest(),
DISCARD_OUTPUT,
)
}
credits.reserveCalls shouldBe 0
}
"releases a newly acquired OOBE claim when the request id is a replay" {
val complimentary = FakeComplimentaryRequests()
val service = service(
credits = FakeCredits(),
provider = FakeProvider(capability = GatewayCapability.POLISH),
usageRecords = FakeUsageRecords(
claimFailure = GatewayRequestAlreadyClaimedException(ProviderRequestState.RELEASED),
),
complimentaryRequests = complimentary,
)
shouldThrow<GatewayRequestAlreadyClaimedException> {
service.execute(
PRINCIPAL.copy(scopes = setOf(GatewayCapability.POLISH)),
oobeRequest(),
DISCARD_OUTPUT,
)
}
complimentary.consumed shouldBe emptyList()
complimentary.released.size shouldBe 1
}
"uses one reservation when a buffered DeepSeek empty result succeeds on retry" {
val credits = FakeCredits()
var attempts = 0
@@ -210,11 +284,13 @@ private fun service(
credits: CreditMeterPort,
provider: GatewayProvider,
usageRecords: GatewayUsagePort = FakeUsageRecords(),
complimentaryRequests: ComplimentaryRequestPort = FakeComplimentaryRequests(available = false),
): GatewayService = GatewayService(
catalog = ProviderCatalog(listOf(provider)),
credits = credits,
grants = { _, _ -> true },
usageRecords = usageRecords,
complimentaryRequests = complimentaryRequests,
)
private fun request(requestSource: GatewayRequestSource? = null): TextProviderRequest {
@@ -236,6 +312,25 @@ private fun request(requestSource: GatewayRequestSource? = null): TextProviderRe
)
}
private fun oobeRequest(): TextProviderRequest {
val executionPolicy = GatewayTaskPolicyResolver().resolve(
GatewayCapability.POLISH,
requestedTaskKind = null,
requestedMaxOutputTokens = 32,
)
return TextProviderRequest(
requestId = "oobe-request-123",
capability = GatewayCapability.POLISH,
executionPolicy = executionPolicy,
input = "hello",
context = null,
maxOutputTokens = executionPolicy.maxOutputTokens,
temperature = 0.2,
stream = false,
requestPurpose = GatewayRequestPurpose.OOBE,
)
}
private class FakeCredits(
private val failSettle: Boolean = false,
private val idempotent: Boolean = false,
@@ -286,10 +381,11 @@ private class FakeCredits(
private class FakeProvider(
private val fail: Boolean = false,
capability: GatewayCapability = GatewayCapability.AI,
) : GatewayProvider {
override val descriptor = ProviderDescriptor(
id = "mock-deepseek",
capabilities = setOf(GatewayCapability.AI),
capabilities = setOf(capability),
streaming = true,
usageMeter = UsageMeter.LLM_TOKEN,
)
@@ -300,6 +396,29 @@ private class FakeProvider(
}
}
private class FakeComplimentaryRequests(
private val available: Boolean = true,
) : ComplimentaryRequestPort {
val consumed = mutableListOf<ComplimentaryRequestClaim>()
val released = mutableListOf<ComplimentaryRequestClaim>()
override suspend fun claim(
accountId: String,
purpose: GatewayRequestPurpose,
capability: GatewayCapability,
requestId: String,
): ComplimentaryRequestClaim? =
if (available) ComplimentaryRequestClaim(accountId, purpose, capability, requestId) else null
override suspend fun consume(claim: ComplimentaryRequestClaim) {
consumed += claim
}
override suspend fun release(claim: ComplimentaryRequestClaim) {
released += claim
}
}
private class EmptyResultProvider : GatewayProvider {
override val descriptor = ProviderDescriptor(
id = "empty-provider",
@@ -315,10 +434,12 @@ private class EmptyResultProvider : GatewayProvider {
private class FakeUsageRecords(
private val pending: MutableList<PendingSettlement> = mutableListOf(),
private val claimFailure: RuntimeException? = null,
) : GatewayUsagePort {
var lastClaim: ProviderRequestMetadata? = null
override suspend fun claim(metadata: ProviderRequestMetadata) {
claimFailure?.let { throw it }
lastClaim = metadata
}
override suspend fun markStarted(accountId: String, requestId: String) = Unit