Harden admin deployment and local acceptance

Enforce mTLS and least-privilege runtime boundaries while adding repeatable MySQL 8.4 and Docker smoke checks that require no production secrets.
This commit is contained in:
Rocky
2026-08-17 15:20:46 +08:00
parent 1a9c518f96
commit 405a2cfc0f
17 changed files with 1480 additions and 37 deletions
+2 -31
View File
@@ -1,37 +1,8 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>{"time":"%date{ISO8601}","level":"%level","logger":"%logger{36}","message":"%replace(%msg){'[\r\n]+',' '}"}%n</pattern>
</encoder>
</appender>
<logger name="io.netty" level="WARN"/>
<logger name="org.jetbrains.exposed" level="WARN"/>
<logger name="com.zaxxer.hikari" level="INFO"/>
<root level="${LOG_LEVEL:-INFO}">
<appender-ref ref="STDOUT"/>
</root>
</configuration>
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%date{ISO8601} %-5level [%thread] %logger{24} - %msg%n</pattern>
</encoder>
</appender>
<logger name="io.netty" level="WARN"/>
<logger name="org.jetbrains.exposed" level="WARN"/>
<logger name="com.zaxxer.hikari" level="INFO"/>
<root level="INFO">
<appender-ref ref="STDOUT"/>
</root>
</configuration>
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd'T'HH:mm:ss.SSSXXX} %-5level [%thread] %logger{36} requestId=%X{requestId:-} - %msg%n</pattern>
<!-- Keep each event single-line, omit throwable details, and redact messages that mention sensitive data. -->
<pattern>time=%d{yyyy-MM-dd'T'HH:mm:ss.SSSXXX} level=%level logger=%logger{36} requestId=%X{requestId:-none} message=%replace(%replace(%msg){'(?i)^.*(?:authorization|cookie|credential|password|secret|token|api[-_ ]?key|apple[-_ ]?subject|audio|prompt|transcript|model[-_ ]?output).*$','[REDACTED]'}){'[\r\n\t ]+','_'}%nopex%n</pattern>
</encoder>
</appender>
@@ -10,7 +10,7 @@ import java.nio.file.Path
class DeploymentConsistencyTest : FunSpec({
val root = Path.of(System.getProperty("user.dir"))
test("OpenAPI documents every mounted public route") {
test("OpenAPI documents every mounted API route") {
val openApi = root.read("docs/openapi.yaml")
val documentedPaths = Regex("""(?m)^ (/[^:]+):\s*$""")
.findAll(openApi)
@@ -20,6 +20,25 @@ class DeploymentConsistencyTest : FunSpec({
documentedPaths shouldBe EXPECTED_PUBLIC_PATHS
}
test("OpenAPI defines admin pagination and response contracts") {
val openApi = root.read("docs/openapi.yaml")
val sessionSchema = openApi
.substringAfter(" AdminSessionState:")
.substringBefore(" AdminLoginResponse:")
sessionSchema shouldNotContain "csrfToken"
openApi shouldContain "schema: { \$ref: \"#/components/schemas/AdminOverview\" }"
openApi shouldContain "schema: { \$ref: \"#/components/schemas/AdminReferralOverview\" }"
openApi shouldContain "schema: { \$ref: \"#/components/schemas/AdminUserPage\" }"
openApi shouldContain "schema: { \$ref: \"#/components/schemas/AdminUserDetail\" }"
openApi shouldContain "schema: { \$ref: \"#/components/schemas/AdminLedgerPage\" }"
openApi shouldContain "schema: { \$ref: \"#/components/schemas/AdminAuditPage\" }"
openApi shouldContain "pendingBindings"
openApi shouldContain "ineligibleBindings"
openApi shouldContain "chargedCredits"
openApi shouldContain "referralCode"
}
test("production Compose reuses private MySQL and hardens the application container") {
val compose = root.read("compose.yaml")
@@ -37,12 +56,35 @@ class DeploymentConsistencyTest : FunSpec({
compose shouldNotContain "0.0.0.0:"
}
test("admin bootstrap is one-time and runtime database grants stay explicit") {
val compose = root.read("compose.yaml")
val privileges = root.read("docs/mysql-minimum-privileges.sql")
compose shouldContain "ADMIN_BOOTSTRAP_ENABLED: \${ADMIN_BOOTSTRAP_ENABLED:-false}"
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"
privileges shouldContain "GRANT INSERT, UPDATE, DELETE ON osg_account.admin_sessions"
privileges shouldContain "GRANT SELECT ON osg_account.gateway_grant_scopes"
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 INSERT ON osg_account.admin_audit_log"
privileges shouldContain "GRANT INSERT ON osg_account.admin_credit_grants"
privileges shouldNotContain "UPDATE ON osg_account.admin_audit_log"
privileges shouldNotContain "DELETE ON osg_account.admin_credit_grants"
}
test("container image remains non-root and read-only compatible") {
val dockerfile = root.read("Dockerfile")
val build = root.read("build.gradle.kts")
dockerfile shouldContain "USER 10001:10001"
dockerfile shouldContain "ENV HOME=/tmp"
dockerfile shouldContain "http://127.0.0.1:8080/health/ready"
dockerfile shouldNotContain "ENTRYPOINT [\"sh\""
dockerfile shouldNotContain "jansi.tmpdir"
build shouldContain "exclude(group = \"org.fusesource.jansi\", module = \"jansi\")"
}
test("OpenResty proxies HTTP WebSocket invitations and both AASA paths safely") {
@@ -55,6 +97,7 @@ class DeploymentConsistencyTest : FunSpec({
openResty shouldContain "location ^~ /i/"
Regex("""location \^~ /i/ \{\s+access_log off;""").containsMatchIn(openResty) shouldBe true
openResty shouldNotContain "alias /www/wwwroot/osglab.com/apple-app-site-association"
openResty shouldNotContain "unsafe-inline"
}
test("CI definition is singular and leaves MySQL lifecycle to Testcontainers") {
@@ -112,6 +155,23 @@ private val EXPECTED_PUBLIC_PATHS = setOf(
"/v1/gateway/asr",
"/v1/gateway/asr/sessions",
"/v1/gateway/asr/sessions/{sessionId}/stream",
"/v1/admin/auth/session",
"/v1/admin/auth/login",
"/v1/admin/auth/logout",
"/v1/admin/overview",
"/v1/admin/referrals",
"/v1/admin/users",
"/v1/admin/users/{userId}",
"/v1/admin/users/{userId}/ledger",
"/v1/admin/credits/grants",
"/v1/admin/operators/summary",
"/v1/admin/operators",
"/v1/admin/operators/{operatorId}/enable",
"/v1/admin/operators/{operatorId}/disable",
"/v1/admin/operators/{operatorId}/unlock",
"/v1/admin/operators/{operatorId}/credentials/reset",
"/v1/admin/operators/{operatorId}/sessions/revoke",
"/v1/admin/audit",
"/.well-known/apple-app-site-association",
"/apple-app-site-association",
"/i/{code}",
@@ -0,0 +1,73 @@
package com.osglab.account.config
import ch.qos.logback.classic.LoggerContext
import ch.qos.logback.classic.joran.JoranConfigurator
import ch.qos.logback.core.status.Status
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import java.nio.file.Path
import javax.xml.XMLConstants
import javax.xml.parsers.DocumentBuilderFactory
import org.w3c.dom.Element
import org.w3c.dom.Node
class LogbackConfigurationTest : FunSpec({
val logbackPath = Path.of(System.getProperty("user.dir"), "src/main/resources/logback.xml")
test("logback configuration is parseable and has one root") {
val document = secureDocumentBuilderFactory()
.newDocumentBuilder()
.parse(logbackPath.toFile())
document.documentElement.tagName shouldBe "configuration"
document.getElementsByTagName("configuration").length shouldBe 1
document.childElements().map(Element::getTagName) shouldContainExactly listOf("configuration")
}
test("console pattern is single-line and contains required structured fields") {
val document = secureDocumentBuilderFactory()
.newDocumentBuilder()
.parse(logbackPath.toFile())
val patterns = document.getElementsByTagName("pattern")
patterns.length shouldBe 1
val pattern = patterns.item(0).textContent.trim()
pattern.lines().size shouldBe 1
listOf("time=", "level=", "logger=", "requestId=", "message=").forEach(pattern::shouldContain)
pattern shouldContain "[REDACTED]"
pattern shouldContain "%nopex"
}
test("logback accepts the structured pattern without configuration errors") {
val context = LoggerContext()
try {
JoranConfigurator().apply { this.context = context }.doConfigure(logbackPath.toFile())
context.statusManager.copyOfStatusList
.filter { it.level == Status.ERROR }
.shouldBeEmpty()
} finally {
context.stop()
}
}
})
private fun secureDocumentBuilderFactory(): DocumentBuilderFactory =
DocumentBuilderFactory.newInstance().apply {
setFeature("http://apache.org/xml/features/disallow-doctype-decl", true)
setFeature("http://xml.org/sax/features/external-general-entities", false)
setFeature("http://xml.org/sax/features/external-parameter-entities", false)
setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "")
setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "")
isXIncludeAware = false
isExpandEntityReferences = false
}
private fun Node.childElements(): List<Element> =
(0 until childNodes.length)
.map(childNodes::item)
.filterIsInstance<Element>()
@@ -0,0 +1,89 @@
package com.osglab.account.config
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldNotContain
import java.nio.file.Files
import java.nio.file.Path
class SmokeDeploymentTest : FunSpec({
val root = Path.of(System.getProperty("user.dir"))
test("smoke Compose builds locally and publishes only loopback ports") {
val compose = root.read("compose.smoke.yaml")
compose shouldContain "image: mysql:8.4"
compose shouldContain "build:"
compose shouldContain "dockerfile: Dockerfile"
compose shouldContain "127.0.0.1:\${SMOKE_APP_PORT"
compose shouldContain "internal: true"
compose shouldContain "network_mode: none"
compose shouldContain "read_only: true"
compose shouldContain "no-new-privileges:true"
compose shouldNotContain "SMOKE_MYSQL_PORT"
compose shouldNotContain "0.0.0.0:"
compose shouldNotContain "ghcr.io/"
}
test("smoke runner isolates secrets providers and cleanup") {
val runner = root.read("deploy/smoke-local.sh")
runner shouldContain "set -euo pipefail"
runner shouldContain "mktemp -d"
runner shouldContain "trap cleanup EXIT INT TERM"
runner shouldContain "down --volumes --remove-orphans --rmi local"
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-V8"
runner shouldContain "first ledger page omitted nextCursor"
runner shouldContain "DELETE FROM admin_sessions WHERE expires_at < UTC_TIMESTAMP()"
runner shouldNotContain "appleid.apple.com"
runner shouldNotContain "api.deepseek.com"
runner shouldNotContain "openspeech.bytedance.com"
}
test("runtime grants cover every migrated table without mutable history privileges") {
val grants = root.read("deploy/smoke/runtime-grants.sql")
val migrationTables = (1..8)
.flatMap { version ->
val migration = Files.list(root.resolve("src/main/resources/db/migration")).use { paths ->
paths.filter { it.fileName.toString().startsWith("V${version}__") }
.findFirst()
.orElseThrow()
}
CREATE_TABLE.findAll(Files.readString(migration))
.map { it.groupValues[1] }
.toList()
}
.toSet()
val grantedTables = GRANTED_TABLE.findAll(grants)
.map { it.groupValues[1] }
.toSet()
grantedTables.sorted() shouldContainExactly migrationTables.sorted()
grants shouldContain "GRANT INSERT, UPDATE, DELETE ON osg_account_smoke.admin_sessions"
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"
grants shouldNotContain "DELETE ON osg_account_smoke.admin_audit_log"
grants shouldNotContain "UPDATE ON osg_account_smoke.admin_credit_grants"
grants shouldNotContain "DELETE ON osg_account_smoke.admin_credit_grants"
}
test("fixture forces the ledger cursor boundary") {
val fixture = root.read("deploy/smoke/fixture.sql")
fixture shouldContain "WHERE value < 101"
fixture shouldContain "INSERT INTO credit_ledger"
fixture shouldNotContain "osg_account"
}
})
private fun Path.read(relativePath: String): String =
Files.readString(resolve(relativePath))
private val CREATE_TABLE = Regex("""CREATE TABLE\s+([a-z0-9_]+)""", RegexOption.IGNORE_CASE)
private val GRANTED_TABLE = Regex("""ON osg_account_smoke\.([a-z0-9_]+)""")