Add secure administrator operations console

Provide TOTP-authenticated, role-controlled user and credit workflows with paginated audit data and SQL-backed statistics so operations can manage growth safely.
This commit is contained in:
Rocky
2026-08-17 15:20:34 +08:00
parent 676bfd2451
commit 1a9c518f96
76 changed files with 14602 additions and 3 deletions
@@ -7,6 +7,27 @@ import com.osglab.account.common.security.SessionJwt
import com.osglab.account.common.security.installSessionAuthentication
import com.osglab.account.config.AppConfig
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.admin.grants.services.AdminGrantService
import com.osglab.account.features.admin.repositories.AdminRepository
import com.osglab.account.features.admin.repositories.ExposedAdminRepository
import com.osglab.account.features.admin.routes.adminApiRoutes
import com.osglab.account.features.admin.routes.adminWebRoutes
import com.osglab.account.features.admin.security.AdminPasswordHasher
import com.osglab.account.features.admin.security.AdminTotpVerifier
import com.osglab.account.features.admin.security.BouncyCastleArgon2idPasswordHasher
import com.osglab.account.features.admin.security.HmacTotpVerifier
import com.osglab.account.features.admin.services.AdminAuthService
import com.osglab.account.features.admin.services.AdminAuditService
import com.osglab.account.features.admin.services.AdminBootstrapConfig
import com.osglab.account.features.admin.services.AdminBootstrapService
import com.osglab.account.features.admin.services.AdminOperatorService
import com.osglab.account.features.admin.services.AdminSessionService
import com.osglab.account.features.admin.stats.repositories.AdminStatsRepository
import com.osglab.account.features.admin.stats.repositories.ExposedAdminStatsRepository
import com.osglab.account.features.admin.stats.services.AdminStatsService
import com.osglab.account.features.admin.users.repositories.AdminUsersRepository
import com.osglab.account.features.admin.users.repositories.ExposedAdminUsersRepository
import com.osglab.account.features.admin.users.services.AdminUsersService
import com.osglab.account.features.account.AccountRepository
import com.osglab.account.features.account.AccountReauthenticator
import com.osglab.account.features.account.AccountService
@@ -120,6 +141,7 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.koin.core.module.Module
import org.koin.dsl.module
import org.koin.ktor.ext.getKoin
@@ -180,6 +202,12 @@ fun Application.module() {
register(PUBLIC_RATE_LIMIT) {
rateLimiter(limit = 120, refillPeriod = 1.minutes)
}
register(ADMIN_AUTH_RATE_LIMIT) {
rateLimiter(limit = 5, refillPeriod = 1.minutes)
}
register(ADMIN_API_RATE_LIMIT) {
rateLimiter(limit = 60, refillPeriod = 1.minutes)
}
}
installApiStatusPages()
@@ -191,6 +219,19 @@ fun Application.module() {
val koin = getKoin()
// Fail startup before accepting traffic if migrations or database connectivity fail.
koin.get<DatabaseFactory>().database
if (appConfig.admin.bootstrapEnabled) {
runBlocking {
koin.get<AdminBootstrapService>().initialize(
AdminBootstrapConfig(
enabled = true,
operatorId = appConfig.admin.bootstrapOperatorId,
username = appConfig.admin.bootstrapUsername,
passwordHash = appConfig.admin.bootstrapPasswordHash,
totpSecretBase32 = appConfig.admin.bootstrapTotpSecretBase32,
),
)
}
}
val sessionAuthenticator = koin.get<SessionAccessAuthenticator>()
installSessionAuthentication(sessionAuthenticator::authenticate)
val asrStreaming = if (appConfig.providers.volcengine.credentialsAvailable) {
@@ -220,6 +261,15 @@ fun Application.module() {
} catch (_: Exception) {
// Durable settlement state is retried without logging provider data.
}
if (appConfig.admin.enabled) {
try {
koin.get<AdminSessionService>().cleanupInactive()
} catch (exception: CancellationException) {
throw exception
} catch (_: Exception) {
// Expired sessions are retried in bounded batches on the next cycle.
}
}
delay(60_000)
}
}
@@ -253,6 +303,21 @@ fun Application.module() {
configureInviteWebRoutes(koin.get(), koin.get())
integrityRoutes(koin.get())
}
if (appConfig.admin.enabled) {
adminWebRoutes()
rateLimit(ADMIN_API_RATE_LIMIT) {
adminApiRoutes(
config = appConfig,
authService = koin.get(),
sessionService = koin.get(),
statsService = koin.get(),
usersService = koin.get(),
grantService = koin.get(),
operatorService = koin.get(),
auditService = koin.get(),
)
}
}
}
}
@@ -296,6 +361,33 @@ fun accountServerModule(config: AppConfig): Module = module {
single { SessionJwt(config.session) }
single { FieldEncryptor(config.encryption.key) }
single { IdentityFingerprint(config.antiAbuse.identityHmacKey) }
single<AdminRepository> { ExposedAdminRepository(get()) }
single<AdminPasswordHasher> { BouncyCastleArgon2idPasswordHasher() }
single<AdminTotpVerifier> { HmacTotpVerifier() }
single {
val dummyPassword = "invalid-admin-password-constant-work".toCharArray()
try {
AdminAuthService(
repository = get(),
passwordHasher = get(),
dummyPasswordHash = get<AdminPasswordHasher>().hash(dummyPassword),
totpVerifier = get(),
fieldEncryptor = get(),
sessionTtl = Duration.ofHours(config.admin.sessionHours),
)
} finally {
dummyPassword.fill('\u0000')
}
}
single { AdminSessionService(get()) }
single { AdminBootstrapService(get(), get()) }
single { AdminOperatorService(get(), get(), get()) }
single { AdminAuditService(get()) }
single<AdminStatsRepository> { ExposedAdminStatsRepository(get()) }
single { AdminStatsService(get()) }
single<AdminUsersRepository> { ExposedAdminUsersRepository(get()) }
single { AdminUsersService(get()) }
single { AdminGrantService(get()) }
single<AppleJwksProvider> {
RemoteAppleJwksProvider(get(), config.apple.jwksUrl)
}
@@ -513,3 +605,5 @@ private val AUTH_RATE_LIMIT = RateLimitName("auth")
private val ACCOUNT_RATE_LIMIT = RateLimitName("account")
private val GATEWAY_RATE_LIMIT = RateLimitName("gateway")
private val PUBLIC_RATE_LIMIT = RateLimitName("public")
private val ADMIN_AUTH_RATE_LIMIT = RateLimitName("admin-auth")
private val ADMIN_API_RATE_LIMIT = RateLimitName("admin-api")
@@ -3,6 +3,7 @@ package com.osglab.account.config
import io.ktor.server.config.ApplicationConfig
import java.net.URI
import java.util.Base64
import java.util.UUID
data class AppConfig(
val environment: Environment,
@@ -17,6 +18,7 @@ data class AppConfig(
val credits: CreditsConfig,
val providers: ProvidersConfig,
val integrity: IntegrityConfig,
val admin: AdminConfig = AdminConfig(),
) {
val isProduction: Boolean = environment == Environment.PRODUCTION
@@ -119,6 +121,41 @@ data class AppConfig(
300,
),
)
val adminEnabled = config.booleanOrDefault("app.admin.enabled", false)
val adminBootstrapEnabled = config.booleanOrDefault(
"app.admin.bootstrapEnabled",
false,
)
require(!adminBootstrapEnabled || adminEnabled) {
"app.admin.bootstrapEnabled requires app.admin.enabled"
}
val admin = AdminConfig(
enabled = adminEnabled,
bootstrapEnabled = adminBootstrapEnabled,
bootstrapOperatorId = config.optionalValue("app.admin.bootstrapOperatorId")
?.let {
runCatching { UUID.fromString(it) }.getOrElse { cause ->
throw ConfigValidationException(
"app.admin.bootstrapOperatorId must be a UUID",
cause,
)
}
},
bootstrapUsername = config.optionalValue("app.admin.bootstrapUsername"),
bootstrapPasswordHash = config.optionalLiteralSecret(
"app.admin.bootstrapPasswordHash",
production && adminBootstrapEnabled,
),
bootstrapTotpSecretBase32 = config.optionalSecret(
"app.admin.bootstrapTotpSecretBase32",
production && adminBootstrapEnabled,
),
sessionHours = config.positiveLong("app.admin.sessionHours", 8),
maximumManualGrant = config.positiveLong(
"app.admin.maximumManualGrant",
100_000,
),
)
require(session.hmacSecret.size >= MIN_HMAC_SECRET_BYTES) {
"app.session.secret must contain at least $MIN_HMAC_SECRET_BYTES bytes"
@@ -199,6 +236,26 @@ data class AppConfig(
require(!production || apple.clientId == APP_ATTEST_BUNDLE_ID) {
"Production Apple client ID must be $APP_ATTEST_BUNDLE_ID"
}
require(admin.sessionHours in 1..24) {
"app.admin.sessionHours must be between 1 and 24"
}
require(admin.maximumManualGrant in 1..100_000_000) {
"app.admin.maximumManualGrant must be between 1 and 100000000"
}
if (admin.bootstrapEnabled) {
requireNotNull(admin.bootstrapOperatorId) {
"app.admin.bootstrapOperatorId is required when admin bootstrap is enabled"
}
require(!admin.bootstrapUsername.isNullOrBlank()) {
"app.admin.bootstrapUsername is required when admin bootstrap is enabled"
}
require(!admin.bootstrapPasswordHash.isNullOrBlank()) {
"app.admin.bootstrapPasswordHash is required when admin bootstrap is enabled"
}
require(!admin.bootstrapTotpSecretBase32.isNullOrBlank()) {
"app.admin.bootstrapTotpSecretBase32 is required when admin bootstrap is enabled"
}
}
if (production) {
requireExactAppleEndpoint(apple.jwksUrl, "/auth/keys", "JWKS")
requireExactAppleEndpoint(apple.tokenUrl, "/auth/token", "token")
@@ -239,6 +296,7 @@ data class AppConfig(
credits = credits,
providers = providers,
integrity = integrity,
admin = admin,
)
}
}
@@ -335,6 +393,17 @@ data class IntegrityConfig(
val appAttestBundleId: String = APP_ATTEST_BUNDLE_ID,
)
data class AdminConfig(
val enabled: Boolean = false,
val bootstrapEnabled: Boolean = false,
val bootstrapOperatorId: UUID? = null,
val bootstrapUsername: String? = null,
val bootstrapPasswordHash: String? = null,
val bootstrapTotpSecretBase32: String? = null,
val sessionHours: Long = 8,
val maximumManualGrant: Long = 100_000,
)
enum class IntegrityPolicy {
MONITOR,
ENFORCE;
@@ -393,6 +462,19 @@ private fun ApplicationConfig.optionalSecret(path: String, production: Boolean):
return value?.takeUnless(String::isPlaceholder)
}
private fun ApplicationConfig.optionalLiteralSecret(path: String, production: Boolean): String? {
val value = propertyOrNull(path)?.getString()?.trim()?.takeIf(String::isNotEmpty)
val placeholder = value?.let {
it.contains("replace-with", ignoreCase = true) ||
it.contains("change-me", ignoreCase = true) ||
it.contains("\${")
} == true
if (production && (value == null || placeholder)) {
throw ConfigValidationException("Production secret is missing or uses a placeholder: $path")
}
return value?.takeUnless { placeholder }
}
private fun String.isPlaceholder(): Boolean =
PLACEHOLDER_MARKERS.any { marker -> contains(marker, ignoreCase = true) }
@@ -419,6 +501,15 @@ private fun ApplicationConfig.boolean(path: String): Boolean =
}
}
private fun ApplicationConfig.booleanOrDefault(path: String, default: Boolean): Boolean =
propertyOrNull(path)?.getString()?.trim()?.takeIf(String::isNotEmpty)?.let {
when (it.lowercase()) {
"true" -> true
"false" -> false
else -> throw ConfigValidationException("$path must be true or false")
}
} ?: default
private fun ApplicationConfig.base64Key(path: String, production: Boolean): ByteArray {
val encoded = secret(path, production)
return try {
@@ -0,0 +1,27 @@
package com.osglab.account.features.admin.grants.models
import kotlinx.serialization.Serializable
import java.util.UUID
data class ManualGrantCommand(
val operatorId: UUID,
val userId: UUID,
val credits: Long,
val reason: String,
val requestId: String? = null,
val idempotencyKey: String,
)
@Serializable
data class AdminCreditGrantDto(
val id: String,
val operatorId: String,
val userId: String,
val credits: Long,
val reason: String,
val ledgerEntryId: String,
val auditLogId: String,
val balanceAfter: Long,
val createdAt: String,
val replayed: Boolean,
)
@@ -0,0 +1,84 @@
package com.osglab.account.features.admin.grants.repositories
import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.admin.repositories.AdminAuditLogTable
import com.osglab.account.features.credits.domain.ManualCreditGrant
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.javatime.timestamp
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.selectAll
import java.util.UUID
interface AdminCreditGrantRepository {
fun findByIdempotencyKey(idempotencyKey: String): ManualCreditGrant?
fun insertAudit(event: NewAdminAuditEvent)
fun insert(grant: ManualCreditGrant)
}
internal object AdminCreditGrantsTable : Table("admin_credit_grants") {
val id = varchar("id", 36)
val operatorId = varchar("operator_id", 36)
val accountId = varchar("account_id", 36)
val amount = long("amount")
val reason = varchar("reason", 500)
val idempotencyKey = varchar("idempotency_key", 128)
val ledgerEntryId = varchar("ledger_entry_id", 36)
val auditLogId = varchar("audit_log_id", 36)
val createdAt = timestamp("created_at")
override val primaryKey = PrimaryKey(id)
}
internal object ExposedAdminCreditGrantRepository : AdminCreditGrantRepository {
override fun findByIdempotencyKey(
idempotencyKey: String,
): ManualCreditGrant? =
AdminCreditGrantsTable
.selectAll()
.where { AdminCreditGrantsTable.idempotencyKey eq idempotencyKey }
.singleOrNull()
?.toManualCreditGrant()
override fun insertAudit(event: NewAdminAuditEvent) {
AdminAuditLogTable.insert {
it[id] = event.id.toString()
it[actorOperatorId] = event.actorOperatorId?.toString()
it[action] = event.action.name
it[outcome] = event.outcome.name
it[targetType] = event.targetType
it[targetId] = event.targetId
it[requestId] = event.requestId
it[occurredAt] = event.occurredAt
}
}
override fun insert(grant: ManualCreditGrant) {
AdminCreditGrantsTable.insert {
it[id] = grant.id.toString()
it[operatorId] = grant.operatorId.toString()
it[accountId] = grant.userId.toString()
it[amount] = grant.amount
it[reason] = grant.reason
it[idempotencyKey] = grant.idempotencyKey
it[ledgerEntryId] = grant.ledgerEntryId.toString()
it[auditLogId] = grant.auditLogId.toString()
it[createdAt] = grant.createdAt
}
}
}
private fun ResultRow.toManualCreditGrant() = ManualCreditGrant(
id = UUID.fromString(this[AdminCreditGrantsTable.id]),
operatorId = UUID.fromString(this[AdminCreditGrantsTable.operatorId]),
userId = UUID.fromString(this[AdminCreditGrantsTable.accountId]),
amount = this[AdminCreditGrantsTable.amount],
reason = this[AdminCreditGrantsTable.reason],
idempotencyKey = this[AdminCreditGrantsTable.idempotencyKey],
ledgerEntryId = UUID.fromString(this[AdminCreditGrantsTable.ledgerEntryId]),
auditLogId = UUID.fromString(this[AdminCreditGrantsTable.auditLogId]),
createdAt = this[AdminCreditGrantsTable.createdAt],
)
@@ -0,0 +1,32 @@
package com.osglab.account.features.admin.grants.services
import com.osglab.account.features.admin.grants.models.AdminCreditGrantDto
import com.osglab.account.features.admin.grants.models.ManualGrantCommand
import com.osglab.account.features.credits.services.CreditOperations
class AdminGrantService(
private val credits: CreditOperations,
) {
suspend fun grant(command: ManualGrantCommand): AdminCreditGrantDto {
val result = credits.grantManual(
operatorId = command.operatorId,
userId = command.userId,
credits = command.credits,
reason = command.reason,
requestId = command.requestId,
idempotencyKey = command.idempotencyKey,
)
return AdminCreditGrantDto(
id = result.grant.id.toString(),
operatorId = result.grant.operatorId.toString(),
userId = result.grant.userId.toString(),
credits = result.grant.amount,
reason = result.grant.reason,
ledgerEntryId = result.grant.ledgerEntryId.toString(),
auditLogId = result.grant.auditLogId.toString(),
balanceAfter = result.balanceAfter,
createdAt = result.grant.createdAt.toString(),
replayed = result.replayed,
)
}
}
@@ -0,0 +1,189 @@
package com.osglab.account.features.admin.models
import java.time.Instant
import java.util.UUID
enum class AdminRole {
SUPER_ADMIN,
SUPPORT,
ANALYST,
}
data class AdminOperatorRecord(
val id: UUID,
val normalizedUsername: String,
val role: AdminRole,
val lockState: AdminLockState,
val disabledAt: Instant?,
val lastLoginAt: Instant?,
val createdAt: Instant,
val updatedAt: Instant,
)
data class AdminOperatorCursor(
val createdAt: Instant,
val id: UUID,
)
data class NewAdminOperator(
val id: UUID,
val normalizedUsername: String,
val passwordHash: String,
val encryptedTotpSecret: String,
val role: AdminRole,
val createdAt: Instant,
) {
init {
require(normalizedUsername.isNotBlank())
require(passwordHash.isNotBlank())
require(encryptedTotpSecret.isNotBlank())
}
}
data class AdminLockState(
val failedLoginCount: Int,
val lockedUntil: Instant?,
) {
init {
require(failedLoginCount >= 0)
}
fun isLockedAt(now: Instant): Boolean = lockedUntil?.isAfter(now) == true
}
/**
* Authentication-only record. Its string representation deliberately excludes
* password and TOTP material.
*/
class AdminOperatorAuthRecord(
val id: UUID,
val normalizedUsername: String,
val passwordHash: String,
val encryptedTotpSecret: String,
val role: AdminRole,
val lockState: AdminLockState,
val disabledAt: Instant?,
) {
override fun toString(): String =
"AdminOperatorAuthRecord(id=$id, normalizedUsername=$normalizedUsername, " +
"role=$role, lockState=$lockState, disabled=${disabledAt != null})"
}
data class NewAdminSession(
val id: UUID,
val operatorId: UUID,
val tokenHash: String,
val csrfTokenHash: String,
val createdAt: Instant,
val expiresAt: Instant,
) {
init {
require(expiresAt.isAfter(createdAt))
}
}
/**
* Repository session record. Token digests are omitted from logs and errors.
*/
class AdminSessionRecord(
val id: UUID,
val operatorId: UUID,
val normalizedUsername: String,
val role: AdminRole,
val csrfTokenHash: String,
val expiresAt: Instant,
) {
override fun toString(): String =
"AdminSessionRecord(id=$id, operatorId=$operatorId, " +
"normalizedUsername=$normalizedUsername, role=$role, expiresAt=$expiresAt)"
}
data class AdminPrincipal(
val operatorId: UUID,
val sessionId: UUID,
val normalizedUsername: String,
val role: AdminRole,
)
enum class AdminAuditAction {
LOGIN_SUCCEEDED,
LOGIN_FAILED,
SESSION_REVOKED,
OPERATOR_CREATED,
OPERATOR_ENABLED,
OPERATOR_DISABLED,
OPERATOR_UNLOCKED,
OPERATOR_CREDENTIALS_RESET,
OPERATOR_SESSIONS_REVOKED,
MANUAL_CREDIT_GRANTED,
}
enum class AdminAuditOutcome {
SUCCESS,
DENIED,
}
data class NewAdminAuditEvent(
val id: UUID = UUID.randomUUID(),
val actorOperatorId: UUID?,
val action: AdminAuditAction,
val outcome: AdminAuditOutcome,
val targetType: String? = null,
val targetId: String? = null,
val requestId: String? = null,
val occurredAt: Instant,
) {
init {
require((targetType == null) == (targetId == null))
require(targetType == null || targetType.isNotBlank())
require(targetId == null || targetId.isNotBlank())
require(requestId == null || requestId.isNotBlank())
}
}
data class AdminAuditRecord(
val id: UUID,
val actorOperatorId: UUID?,
val action: AdminAuditAction,
val outcome: AdminAuditOutcome,
val targetType: String?,
val targetId: String?,
val requestId: String?,
val occurredAt: Instant,
)
data class AdminAuditCursor(
val occurredAt: Instant,
val id: UUID,
)
/**
* Raw credentials are returned once and must only be transported in secure,
* HttpOnly/Secure cookies. They are never persisted by this service.
*/
class AdminSessionCredentials(
val sessionToken: String,
val csrfToken: String,
val expiresAt: Instant,
) {
override fun toString(): String =
"AdminSessionCredentials(sessionToken=[REDACTED], csrfToken=[REDACTED], expiresAt=$expiresAt)"
}
sealed interface AdminLoginResult {
data class Authenticated(
val principal: AdminPrincipal,
val credentials: AdminSessionCredentials,
) : AdminLoginResult
data class Locked(val retryAt: Instant) : AdminLoginResult
data object InvalidCredentials : AdminLoginResult
}
enum class AdminOperatorMutationResult {
SUCCESS,
NOT_FOUND,
USERNAME_CONFLICT,
LAST_SUPER_ADMIN,
}
@@ -0,0 +1,641 @@
package com.osglab.account.features.admin.repositories
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.admin.models.AdminAuditCursor
import com.osglab.account.features.admin.models.AdminLockState
import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminAuditOutcome
import com.osglab.account.features.admin.models.AdminAuditRecord
import com.osglab.account.features.admin.models.AdminOperatorAuthRecord
import com.osglab.account.features.admin.models.AdminOperatorCursor
import com.osglab.account.features.admin.models.AdminOperatorMutationResult
import com.osglab.account.features.admin.models.AdminOperatorRecord
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.models.AdminSessionRecord
import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.admin.models.NewAdminOperator
import com.osglab.account.features.admin.models.NewAdminSession
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.greater
import org.jetbrains.exposed.v1.core.inList
import org.jetbrains.exposed.v1.core.isNotNull
import org.jetbrains.exposed.v1.core.isNull
import org.jetbrains.exposed.v1.core.less
import org.jetbrains.exposed.v1.core.lessEq
import org.jetbrains.exposed.v1.core.or
import org.jetbrains.exposed.v1.javatime.timestamp
import org.jetbrains.exposed.v1.jdbc.andWhere
import org.jetbrains.exposed.v1.jdbc.deleteWhere
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.insertIgnore
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import java.time.Instant
import java.util.UUID
internal object AdminOperatorsTable : Table("admin_operators") {
val id = varchar("id", 36)
val username = varchar("username", 64).uniqueIndex()
val passwordHash = varchar("password_hash", 255)
val encryptedTotpSecret = text("encrypted_totp_secret")
val role = varchar("role", 32)
val failedLoginCount = integer("failed_login_count")
val lockedUntil = timestamp("locked_until").nullable()
val lastTotpCounter = long("last_totp_counter").nullable()
val lastLoginAt = timestamp("last_login_at").nullable()
val disabledAt = timestamp("disabled_at").nullable()
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
override val primaryKey = PrimaryKey(id)
}
internal object AdminSessionsTable : Table("admin_sessions") {
val id = varchar("id", 36)
val operatorId = varchar("operator_id", 36).index()
val tokenHash = char("token_hash", 64).uniqueIndex()
val csrfTokenHash = char("csrf_token_hash", 64)
val createdAt = timestamp("created_at")
val expiresAt = timestamp("expires_at").index()
val revokedAt = timestamp("revoked_at").nullable()
override val primaryKey = PrimaryKey(id)
}
internal object AdminAuditLogTable : Table("admin_audit_log") {
val id = varchar("id", 36)
val actorOperatorId = varchar("actor_operator_id", 36).nullable().index()
val action = varchar("action", 64)
val outcome = varchar("outcome", 32)
val targetType = varchar("target_type", 64).nullable()
val targetId = varchar("target_id", 128).nullable()
val requestId = varchar("request_id", 128).nullable()
val occurredAt = timestamp("occurred_at").index()
override val primaryKey = PrimaryKey(id)
}
interface AdminRepository {
suspend fun createOperatorIfAbsent(operator: NewAdminOperator): Boolean
suspend fun createOperator(
operator: NewAdminOperator,
auditEvent: NewAdminAuditEvent,
): AdminOperatorMutationResult
suspend fun listOperators(): List<AdminOperatorRecord>
suspend fun listOperatorsPage(
limit: Int,
before: AdminOperatorCursor? = null,
): List<AdminOperatorRecord>
suspend fun countActiveSessions(now: Instant): Long
suspend fun findOperator(operatorId: UUID): AdminOperatorRecord?
suspend fun setOperatorEnabled(
operatorId: UUID,
enabled: Boolean,
now: Instant,
auditEvent: NewAdminAuditEvent,
): AdminOperatorMutationResult
suspend fun unlockOperator(
operatorId: UUID,
now: Instant,
auditEvent: NewAdminAuditEvent,
): AdminOperatorMutationResult
suspend fun resetOperatorCredentials(
operatorId: UUID,
passwordHash: String,
encryptedTotpSecret: String,
now: Instant,
auditEvent: NewAdminAuditEvent,
): AdminOperatorMutationResult
suspend fun revokeOperatorSessions(
operatorId: UUID,
now: Instant,
auditEvent: NewAdminAuditEvent,
): AdminOperatorMutationResult
suspend fun findOperatorForAuthentication(normalizedUsername: String): AdminOperatorAuthRecord?
/**
* Executes the transformation while holding the operator row lock.
*/
suspend fun updateLockState(
operatorId: UUID,
now: Instant,
auditEvent: NewAdminAuditEvent,
transform: (AdminLockState) -> AdminLockState,
): AdminLockState?
/**
* Atomically consumes a newer TOTP counter, clears the lock state, and
* creates the session. A null result means the operator became unavailable
* or the counter was already consumed.
*/
suspend fun createSessionIfTotpCounterFresh(
session: NewAdminSession,
totpCounter: Long,
now: Instant,
auditEvent: NewAdminAuditEvent,
): AdminSessionRecord?
suspend fun findActiveSessionByTokenHash(tokenHash: String, now: Instant): AdminSessionRecord?
suspend fun revokeSessionByTokenHash(
tokenHash: String,
now: Instant,
auditEvent: NewAdminAuditEvent,
): AdminSessionRecord?
suspend fun purgeInactiveSessions(cutoff: Instant, limit: Int): Int
suspend fun appendAudit(event: NewAdminAuditEvent)
suspend fun listAudit(
limit: Int,
before: AdminAuditCursor? = null,
): List<AdminAuditRecord>
}
class ExposedAdminRepository(
private val databaseFactory: DatabaseFactory,
) : AdminRepository {
override suspend fun createOperatorIfAbsent(operator: NewAdminOperator): Boolean =
databaseFactory.query {
insertOperatorIgnoringConflict(operator)
}
override suspend fun createOperator(
operator: NewAdminOperator,
auditEvent: NewAdminAuditEvent,
): AdminOperatorMutationResult = databaseFactory.query {
val result = if (insertOperatorIgnoringConflict(operator)) {
AdminOperatorMutationResult.SUCCESS
} else {
AdminOperatorMutationResult.USERNAME_CONFLICT
}
insertAudit(auditEvent.withResult(result))
result
}
override suspend fun listOperators(): List<AdminOperatorRecord> =
databaseFactory.query {
AdminOperatorsTable.selectAll()
.orderBy(
AdminOperatorsTable.createdAt to SortOrder.ASC,
AdminOperatorsTable.id to SortOrder.ASC,
)
.map(ResultRow::toOperatorRecord)
}
override suspend fun listOperatorsPage(
limit: Int,
before: AdminOperatorCursor?,
): List<AdminOperatorRecord> =
databaseFactory.query {
require(limit in 1..101)
val query = AdminOperatorsTable.selectAll()
if (before != null) {
query.andWhere {
(AdminOperatorsTable.createdAt greater before.createdAt) or
(
(AdminOperatorsTable.createdAt eq before.createdAt) and
(AdminOperatorsTable.id greater before.id.toString())
)
}
}
query
.orderBy(
AdminOperatorsTable.createdAt to SortOrder.ASC,
AdminOperatorsTable.id to SortOrder.ASC,
)
.limit(limit)
.map(ResultRow::toOperatorRecord)
}
override suspend fun countActiveSessions(now: Instant): Long =
databaseFactory.query {
AdminSessionsTable.selectAll()
.where {
AdminSessionsTable.revokedAt.isNull() and
(AdminSessionsTable.expiresAt greater now)
}
.count()
}
override suspend fun findOperator(operatorId: UUID): AdminOperatorRecord? =
databaseFactory.query {
AdminOperatorsTable.selectAll()
.where { AdminOperatorsTable.id eq operatorId.toString() }
.limit(1)
.singleOrNull()
?.toOperatorRecord()
}
override suspend fun setOperatorEnabled(
operatorId: UUID,
enabled: Boolean,
now: Instant,
auditEvent: NewAdminAuditEvent,
): AdminOperatorMutationResult = databaseFactory.query {
if (!enabled) {
// Lock every enabled super-administrator in deterministic order so
// concurrent disables cannot both observe themselves as non-last.
AdminOperatorsTable.selectAll()
.where {
(AdminOperatorsTable.role eq AdminRole.SUPER_ADMIN.name) and
AdminOperatorsTable.disabledAt.isNull()
}
.orderBy(AdminOperatorsTable.id to SortOrder.ASC)
.forUpdate()
.toList()
}
val row = AdminOperatorsTable.selectAll()
.where { AdminOperatorsTable.id eq operatorId.toString() }
.forUpdate()
.singleOrNull()
val result = when {
row == null -> AdminOperatorMutationResult.NOT_FOUND
!enabled &&
row[AdminOperatorsTable.disabledAt] == null &&
row[AdminOperatorsTable.role] == AdminRole.SUPER_ADMIN.name &&
enabledSuperAdministratorCount() <= 1 -> AdminOperatorMutationResult.LAST_SUPER_ADMIN
else -> {
AdminOperatorsTable.update({ AdminOperatorsTable.id eq operatorId.toString() }) {
it[disabledAt] = if (enabled) null else now
it[updatedAt] = now
}
if (!enabled) revokeActiveSessions(operatorId, now)
AdminOperatorMutationResult.SUCCESS
}
}
insertAudit(auditEvent.withResult(result))
result
}
override suspend fun unlockOperator(
operatorId: UUID,
now: Instant,
auditEvent: NewAdminAuditEvent,
): AdminOperatorMutationResult = databaseFactory.query {
val row = lockOperator(operatorId)
val result = if (row == null) {
AdminOperatorMutationResult.NOT_FOUND
} else {
AdminOperatorsTable.update({ AdminOperatorsTable.id eq operatorId.toString() }) {
it[failedLoginCount] = 0
it[lockedUntil] = null
it[updatedAt] = now
}
AdminOperatorMutationResult.SUCCESS
}
insertAudit(auditEvent.withResult(result))
result
}
override suspend fun resetOperatorCredentials(
operatorId: UUID,
passwordHash: String,
encryptedTotpSecret: String,
now: Instant,
auditEvent: NewAdminAuditEvent,
): AdminOperatorMutationResult = databaseFactory.query {
require(passwordHash.isNotBlank())
require(encryptedTotpSecret.isNotBlank())
val row = lockOperator(operatorId)
val result = if (row == null) {
AdminOperatorMutationResult.NOT_FOUND
} else {
AdminOperatorsTable.update({ AdminOperatorsTable.id eq operatorId.toString() }) {
it[AdminOperatorsTable.passwordHash] = passwordHash
it[AdminOperatorsTable.encryptedTotpSecret] = encryptedTotpSecret
it[failedLoginCount] = 0
it[lockedUntil] = null
it[lastTotpCounter] = null
it[updatedAt] = now
}
revokeActiveSessions(operatorId, now)
AdminOperatorMutationResult.SUCCESS
}
insertAudit(auditEvent.withResult(result))
result
}
override suspend fun revokeOperatorSessions(
operatorId: UUID,
now: Instant,
auditEvent: NewAdminAuditEvent,
): AdminOperatorMutationResult = databaseFactory.query {
val row = lockOperator(operatorId)
val result = if (row == null) {
AdminOperatorMutationResult.NOT_FOUND
} else {
revokeActiveSessions(operatorId, now)
AdminOperatorMutationResult.SUCCESS
}
insertAudit(auditEvent.withResult(result))
result
}
override suspend fun findOperatorForAuthentication(
normalizedUsername: String,
): AdminOperatorAuthRecord? = databaseFactory.query {
AdminOperatorsTable.selectAll()
.where { AdminOperatorsTable.username eq normalizedUsername }
.limit(1)
.singleOrNull()
?.toAuthRecord()
}
override suspend fun updateLockState(
operatorId: UUID,
now: Instant,
auditEvent: NewAdminAuditEvent,
transform: (AdminLockState) -> AdminLockState,
): AdminLockState? = databaseFactory.query {
val row = AdminOperatorsTable.selectAll()
.where { AdminOperatorsTable.id eq operatorId.toString() }
.forUpdate()
.singleOrNull()
?: return@query null
if (row[AdminOperatorsTable.disabledAt] != null) return@query null
val next = transform(
AdminLockState(
failedLoginCount = row[AdminOperatorsTable.failedLoginCount],
lockedUntil = row[AdminOperatorsTable.lockedUntil],
),
)
AdminOperatorsTable.update({ AdminOperatorsTable.id eq operatorId.toString() }) {
it[failedLoginCount] = next.failedLoginCount
it[lockedUntil] = next.lockedUntil
it[updatedAt] = now
}
insertAudit(auditEvent)
next
}
override suspend fun createSessionIfTotpCounterFresh(
session: NewAdminSession,
totpCounter: Long,
now: Instant,
auditEvent: NewAdminAuditEvent,
): AdminSessionRecord? = databaseFactory.query {
require(totpCounter >= 0)
val operator = AdminOperatorsTable.selectAll()
.where { AdminOperatorsTable.id eq session.operatorId.toString() }
.forUpdate()
.singleOrNull()
?: return@query null
if (operator[AdminOperatorsTable.disabledAt] != null) return@query null
if (operator[AdminOperatorsTable.lockedUntil]?.isAfter(now) == true) return@query null
if (operator[AdminOperatorsTable.lastTotpCounter]?.let { it >= totpCounter } == true) {
return@query null
}
AdminOperatorsTable.update({ AdminOperatorsTable.id eq session.operatorId.toString() }) {
it[lastTotpCounter] = totpCounter
it[failedLoginCount] = 0
it[lockedUntil] = null
it[lastLoginAt] = now
it[updatedAt] = now
}
AdminSessionsTable.insert {
it[id] = session.id.toString()
it[operatorId] = session.operatorId.toString()
it[tokenHash] = session.tokenHash
it[csrfTokenHash] = session.csrfTokenHash
it[createdAt] = session.createdAt
it[expiresAt] = session.expiresAt
}
insertAudit(auditEvent)
AdminSessionRecord(
id = session.id,
operatorId = session.operatorId,
normalizedUsername = operator[AdminOperatorsTable.username],
role = AdminRole.valueOf(operator[AdminOperatorsTable.role]),
csrfTokenHash = session.csrfTokenHash,
expiresAt = session.expiresAt,
)
}
override suspend fun findActiveSessionByTokenHash(
tokenHash: String,
now: Instant,
): AdminSessionRecord? = databaseFactory.query {
val session = AdminSessionsTable.selectAll()
.where {
(AdminSessionsTable.tokenHash eq tokenHash) and
AdminSessionsTable.revokedAt.isNull() and
(AdminSessionsTable.expiresAt greater now)
}
.limit(1)
.singleOrNull()
?: return@query null
val operator = activeOperator(session[AdminSessionsTable.operatorId]) ?: return@query null
session.toSessionRecord(
role = AdminRole.valueOf(operator[AdminOperatorsTable.role]),
normalizedUsername = operator[AdminOperatorsTable.username],
)
}
override suspend fun revokeSessionByTokenHash(
tokenHash: String,
now: Instant,
auditEvent: NewAdminAuditEvent,
): AdminSessionRecord? = databaseFactory.query {
val session = AdminSessionsTable.selectAll()
.where {
(AdminSessionsTable.tokenHash eq tokenHash) and
AdminSessionsTable.revokedAt.isNull()
}
.forUpdate()
.singleOrNull()
?: return@query null
val operator = AdminOperatorsTable.selectAll()
.where { AdminOperatorsTable.id eq session[AdminSessionsTable.operatorId] }
.singleOrNull()
?: return@query null
AdminSessionsTable.update({ AdminSessionsTable.id eq session[AdminSessionsTable.id] }) {
it[revokedAt] = now
}
insertAudit(auditEvent)
session.toSessionRecord(
role = AdminRole.valueOf(operator[AdminOperatorsTable.role]),
normalizedUsername = operator[AdminOperatorsTable.username],
)
}
override suspend fun purgeInactiveSessions(cutoff: Instant, limit: Int): Int =
databaseFactory.query {
require(limit in 1..1_000)
val candidateIds = AdminSessionsTable.selectAll()
.where {
(AdminSessionsTable.expiresAt lessEq cutoff) or
(
AdminSessionsTable.revokedAt.isNotNull() and
(AdminSessionsTable.revokedAt lessEq cutoff)
)
}
.orderBy(
AdminSessionsTable.expiresAt to SortOrder.ASC,
AdminSessionsTable.id to SortOrder.ASC,
)
.limit(limit)
.map { it[AdminSessionsTable.id] }
if (candidateIds.isEmpty()) {
0
} else {
AdminSessionsTable.deleteWhere {
AdminSessionsTable.id inList candidateIds
}
}
}
override suspend fun appendAudit(event: NewAdminAuditEvent) {
databaseFactory.query {
insertAudit(event)
}
}
override suspend fun listAudit(
limit: Int,
before: AdminAuditCursor?,
): List<AdminAuditRecord> =
databaseFactory.query {
require(limit in 1..101)
val query = AdminAuditLogTable.selectAll()
if (before != null) {
query.andWhere {
(AdminAuditLogTable.occurredAt less before.occurredAt) or
(
(AdminAuditLogTable.occurredAt eq before.occurredAt) and
(AdminAuditLogTable.id less before.id.toString())
)
}
}
query
.orderBy(
AdminAuditLogTable.occurredAt to SortOrder.DESC,
AdminAuditLogTable.id to SortOrder.DESC,
)
.limit(limit)
.map {
AdminAuditRecord(
id = UUID.fromString(it[AdminAuditLogTable.id]),
actorOperatorId = it[AdminAuditLogTable.actorOperatorId]?.let(UUID::fromString),
action = AdminAuditAction.valueOf(it[AdminAuditLogTable.action]),
outcome = AdminAuditOutcome.valueOf(it[AdminAuditLogTable.outcome]),
targetType = it[AdminAuditLogTable.targetType],
targetId = it[AdminAuditLogTable.targetId],
requestId = it[AdminAuditLogTable.requestId],
occurredAt = it[AdminAuditLogTable.occurredAt],
)
}
}
private fun insertAudit(event: NewAdminAuditEvent) {
AdminAuditLogTable.insert {
it[id] = event.id.toString()
it[actorOperatorId] = event.actorOperatorId?.toString()
it[action] = event.action.name
it[outcome] = event.outcome.name
it[targetType] = event.targetType
it[targetId] = event.targetId
it[requestId] = event.requestId
it[occurredAt] = event.occurredAt
}
}
private fun insertOperatorIgnoringConflict(operator: NewAdminOperator): Boolean =
AdminOperatorsTable.insertIgnore {
it[id] = operator.id.toString()
it[username] = operator.normalizedUsername
it[passwordHash] = operator.passwordHash
it[encryptedTotpSecret] = operator.encryptedTotpSecret
it[role] = operator.role.name
it[failedLoginCount] = 0
it[lockedUntil] = null
it[lastTotpCounter] = null
it[lastLoginAt] = null
it[disabledAt] = null
it[createdAt] = operator.createdAt
it[updatedAt] = operator.createdAt
}.insertedCount > 0
private fun lockOperator(operatorId: UUID): ResultRow? =
AdminOperatorsTable.selectAll()
.where { AdminOperatorsTable.id eq operatorId.toString() }
.forUpdate()
.singleOrNull()
private fun enabledSuperAdministratorCount(): Int =
AdminOperatorsTable.selectAll()
.where {
(AdminOperatorsTable.role eq AdminRole.SUPER_ADMIN.name) and
AdminOperatorsTable.disabledAt.isNull()
}
.count()
.toInt()
private fun revokeActiveSessions(operatorId: UUID, now: Instant) {
AdminSessionsTable.update({
(AdminSessionsTable.operatorId eq operatorId.toString()) and
AdminSessionsTable.revokedAt.isNull()
}) {
it[revokedAt] = now
}
}
private fun activeOperator(operatorId: String): ResultRow? =
AdminOperatorsTable.selectAll()
.where {
(AdminOperatorsTable.id eq operatorId) and
AdminOperatorsTable.disabledAt.isNull()
}
.limit(1)
.singleOrNull()
}
private fun NewAdminAuditEvent.withResult(
result: AdminOperatorMutationResult,
): NewAdminAuditEvent = copy(
outcome = if (result == AdminOperatorMutationResult.SUCCESS) {
AdminAuditOutcome.SUCCESS
} else {
AdminAuditOutcome.DENIED
},
)
private fun ResultRow.toOperatorRecord(): AdminOperatorRecord = AdminOperatorRecord(
id = UUID.fromString(this[AdminOperatorsTable.id]),
normalizedUsername = this[AdminOperatorsTable.username],
role = AdminRole.valueOf(this[AdminOperatorsTable.role]),
lockState = AdminLockState(
failedLoginCount = this[AdminOperatorsTable.failedLoginCount],
lockedUntil = this[AdminOperatorsTable.lockedUntil],
),
disabledAt = this[AdminOperatorsTable.disabledAt],
lastLoginAt = this[AdminOperatorsTable.lastLoginAt],
createdAt = this[AdminOperatorsTable.createdAt],
updatedAt = this[AdminOperatorsTable.updatedAt],
)
private fun ResultRow.toAuthRecord(): AdminOperatorAuthRecord = AdminOperatorAuthRecord(
id = UUID.fromString(this[AdminOperatorsTable.id]),
normalizedUsername = this[AdminOperatorsTable.username],
passwordHash = this[AdminOperatorsTable.passwordHash],
encryptedTotpSecret = this[AdminOperatorsTable.encryptedTotpSecret],
role = AdminRole.valueOf(this[AdminOperatorsTable.role]),
lockState = AdminLockState(
failedLoginCount = this[AdminOperatorsTable.failedLoginCount],
lockedUntil = this[AdminOperatorsTable.lockedUntil],
),
disabledAt = this[AdminOperatorsTable.disabledAt],
)
private fun ResultRow.toSessionRecord(
role: AdminRole,
normalizedUsername: String,
): AdminSessionRecord = AdminSessionRecord(
id = UUID.fromString(this[AdminSessionsTable.id]),
operatorId = UUID.fromString(this[AdminSessionsTable.operatorId]),
normalizedUsername = normalizedUsername,
role = role,
csrfTokenHash = this[AdminSessionsTable.csrfTokenHash],
expiresAt = this[AdminSessionsTable.expiresAt],
)
@@ -0,0 +1,887 @@
package com.osglab.account.features.admin.routes
import com.osglab.account.config.AppConfig
import com.osglab.account.features.admin.grants.models.ManualGrantCommand
import com.osglab.account.features.admin.grants.services.AdminGrantService
import com.osglab.account.features.admin.models.AdminLoginResult
import com.osglab.account.features.admin.models.AdminPrincipal
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.models.AdminOperatorRecord
import com.osglab.account.features.admin.services.AdminAuditCursorException
import com.osglab.account.features.admin.services.AdminAuditService
import com.osglab.account.features.admin.services.AdminAuthService
import com.osglab.account.features.admin.services.AdminOperatorCredentials
import com.osglab.account.features.admin.services.AdminOperatorCursorException
import com.osglab.account.features.admin.services.AdminOperatorErrorCode
import com.osglab.account.features.admin.services.AdminOperatorException
import com.osglab.account.features.admin.services.AdminOperatorService
import com.osglab.account.features.admin.services.AdminSessionService
import com.osglab.account.features.admin.stats.models.AdminStatsDto
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
import com.osglab.account.features.admin.stats.services.AdminStatsService
import com.osglab.account.features.admin.users.models.AdminUserDetailDto
import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
import com.osglab.account.features.admin.users.models.AdminUserReferralDto
import com.osglab.account.features.admin.users.models.AdminUserSummaryDto
import com.osglab.account.features.admin.users.services.AdminUserNotFoundException
import com.osglab.account.features.admin.users.services.AdminUsersService
import com.osglab.account.features.credits.domain.CreditConflict
import com.osglab.account.features.credits.domain.CreditNotFound
import com.osglab.account.features.credits.domain.InvalidCreditRequest
import io.ktor.http.Cookie
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.call
import io.ktor.server.http.content.staticResources
import io.ktor.server.plugins.BadRequestException
import io.ktor.server.plugins.ratelimit.RateLimitName
import io.ktor.server.plugins.ratelimit.rateLimit
import io.ktor.server.request.header
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.delete
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.route
import kotlinx.serialization.Serializable
import java.time.Clock
import java.time.Duration
import java.util.UUID
fun Route.adminWebRoutes() {
staticResources("/admin", "admin", index = "index.html")
}
fun Route.adminApiRoutes(
config: AppConfig,
authService: AdminAuthService,
sessionService: AdminSessionService,
statsService: AdminStatsService,
usersService: AdminUsersService,
grantService: AdminGrantService,
operatorService: AdminOperatorService,
auditService: AdminAuditService,
clock: Clock = Clock.systemUTC(),
) {
route("/v1/admin") {
rateLimit(ADMIN_AUTH_RATE_LIMIT) {
route("/auth") {
get("/session") {
if (!call.requireVerifiedAdminEdge()) return@get
val principal = call.currentPrincipal(sessionService)
call.respond(
AdminSessionResponse(
authenticated = principal != null,
operatorName = principal?.normalizedUsername,
role = principal?.role?.name,
),
)
}
post("/login") {
if (!call.requireVerifiedAdminEdge() || !call.requireSameOrigin(config)) return@post
val request = call.receive<AdminLoginRequest>()
val password = request.password.toCharArray()
val result = try {
authService.login(
username = request.username,
password = password,
totpCode = request.totpCode,
requestId = call.request.header("X-Request-ID"),
)
} finally {
password.fill('\u0000')
}
when (result) {
is AdminLoginResult.Authenticated -> {
call.setAdminCookies(config, result.credentials.sessionToken, result.credentials.csrfToken)
call.respond(
AdminLoginResponse(
operatorName = result.principal.normalizedUsername,
role = result.principal.role.name,
csrfToken = result.credentials.csrfToken,
),
)
}
is AdminLoginResult.Locked -> call.respond(
HttpStatusCode.TooManyRequests,
AdminErrorResponse("RATE_LIMITED"),
)
AdminLoginResult.InvalidCredentials -> call.respond(
HttpStatusCode.Unauthorized,
AdminErrorResponse("INVALID_CREDENTIALS"),
)
}
}
post("/logout") {
if (!call.requireVerifiedAdminEdge() || !call.requireSameOrigin(config)) return@post
val sessionToken = call.request.cookies[SESSION_COOKIE]
val csrfToken = call.request.header(CSRF_HEADER)
if (
sessionToken == null ||
csrfToken == null ||
!sessionService.revoke(
sessionToken,
csrfToken,
call.request.header("X-Request-ID"),
)
) {
call.respond(HttpStatusCode.Unauthorized, AdminErrorResponse("UNAUTHORIZED"))
return@post
}
call.clearAdminCookies(config)
call.respond(HttpStatusCode.NoContent)
}
}
}
get("/overview") {
if (call.requirePrincipal(sessionService) == null) return@get
val stats = statsService.getRange(call.request.queryParameters["range"], clock)
?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
}
call.respond(stats.toOverviewResponse())
}
get("/referrals") {
if (call.requirePrincipal(sessionService) == null) return@get
val stats = statsService.getRange(call.request.queryParameters["range"], clock)
?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
}
call.respond(stats.toReferralResponse())
}
get("/users") {
if (
call.requireRole(
sessionService,
setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT),
) == null
) return@get
val limit = call.pageLimit(maximum = 100) ?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
}
val query = call.request.queryParameters["q"]?.trim().orEmpty()
val page = try {
if (query.isEmpty()) {
usersService.list(
limit = limit,
cursor = call.request.queryParameters["cursor"],
)
} else {
usersService.searchByInternalId(query)
}
} catch (_: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
}
call.respond(
PageResponse(
items = page.items.map(AdminUserSummaryDto::toUserSummaryResponse),
nextCursor = page.nextCursor,
),
)
}
get("/users/{userId}") {
if (
call.requireRole(
sessionService,
setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT),
) == null
) return@get
val userId = call.uuidPathParameter("userId") ?: return@get
try {
call.respond(usersService.detail(userId).toUserDetailResponse())
} catch (_: AdminUserNotFoundException) {
call.respond(HttpStatusCode.NotFound, AdminErrorResponse("USER_NOT_FOUND"))
}
}
get("/users/{userId}/ledger") {
if (
call.requireRole(
sessionService,
setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT),
) == null
) return@get
val userId = call.uuidPathParameter("userId") ?: return@get
val limit = call.pageLimit(maximum = 100, default = 100) ?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
}
try {
val page = usersService.ledger(
userId = userId,
limit = limit,
cursor = call.request.queryParameters["cursor"],
)
call.respond(
PageResponse(
page.items.map(AdminUserLedgerEntryDto::toLedgerResponse),
page.nextCursor,
),
)
} catch (_: AdminUserNotFoundException) {
call.respond(HttpStatusCode.NotFound, AdminErrorResponse("USER_NOT_FOUND"))
} catch (_: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
}
}
post("/credits/grants") {
val principal = call.requireMutationPrincipal(config, sessionService) ?: return@post
if (principal.role != AdminRole.SUPER_ADMIN) {
call.respond(HttpStatusCode.Forbidden, AdminErrorResponse("INSUFFICIENT_PERMISSION"))
return@post
}
val request = call.receive<AdminGrantRequest>()
val idempotencyKey = call.request.header("Idempotency-Key")
val userId = runCatching { UUID.fromString(request.userId) }.getOrNull()
if (
userId == null ||
request.amount !in 1..config.admin.maximumManualGrant ||
request.reason.trim().length !in 4..200 ||
idempotencyKey == null ||
idempotencyKey.length !in 8..128
) {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@post
}
val result = try {
grantService.grant(
ManualGrantCommand(
operatorId = principal.operatorId,
userId = userId,
credits = request.amount,
reason = request.reason.trim(),
requestId = call.request.header("X-Request-ID"),
idempotencyKey = idempotencyKey,
),
)
} catch (_: CreditNotFound) {
call.respond(HttpStatusCode.NotFound, AdminErrorResponse("USER_NOT_FOUND"))
return@post
} catch (_: CreditConflict) {
call.respond(HttpStatusCode.Conflict, AdminErrorResponse("IDEMPOTENCY_CONFLICT"))
return@post
} catch (_: InvalidCreditRequest) {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@post
}
call.respond(
AdminGrantResponse(
transactionId = result.ledgerEntryId,
balanceAfter = result.balanceAfter,
),
)
}
get("/operators/summary") {
val principal = call.requirePrincipal(sessionService) ?: return@get
try {
val summary = operatorService.summary(principal)
call.respond(
AdminSecuritySummaryResponse(
enabledOperators = summary.enabledOperators,
lockedOperators = summary.lockedOperators,
activeSessions = summary.activeSessions,
),
)
} catch (exception: AdminOperatorException) {
call.respondOperatorError(exception)
}
}
get("/operators") {
val principal = call.requirePrincipal(sessionService) ?: return@get
val limit = call.pageLimit(maximum = 100) ?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
}
try {
val page = operatorService.listPage(
actor = principal,
cursor = call.request.queryParameters["cursor"],
limit = limit,
)
call.respond(
PageResponse(
items = page.items.map(AdminOperatorRecord::toResponse),
nextCursor = page.nextCursor,
),
)
} catch (_: AdminOperatorCursorException) {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
} catch (exception: AdminOperatorException) {
call.respondOperatorError(exception)
}
}
post("/operators") {
val principal = call.requireMutationPrincipal(config, sessionService) ?: return@post
val request = call.receiveAdminRequest<AdminOperatorCreateRequest>() ?: return@post
val password = request.password.toCharArray()
try {
val created = operatorService.create(
actor = principal,
username = request.username,
password = password,
roleName = request.role,
requestId = call.request.header("X-Request-ID"),
)
call.respond(HttpStatusCode.Created, created.toProvisioningResponse())
} catch (exception: AdminOperatorException) {
call.respondOperatorError(exception)
} finally {
password.fill('\u0000')
}
}
post("/operators/{operatorId}/enable") {
val principal = call.requireMutationPrincipal(config, sessionService) ?: return@post
val operatorId = call.uuidPathParameter("operatorId") ?: return@post
try {
operatorService.setEnabled(
principal,
operatorId,
enabled = true,
call.request.header("X-Request-ID"),
)
call.respond(HttpStatusCode.NoContent)
} catch (exception: AdminOperatorException) {
call.respondOperatorError(exception)
}
}
post("/operators/{operatorId}/disable") {
val principal = call.requireMutationPrincipal(config, sessionService) ?: return@post
val operatorId = call.uuidPathParameter("operatorId") ?: return@post
try {
operatorService.setEnabled(
principal,
operatorId,
enabled = false,
call.request.header("X-Request-ID"),
)
call.respond(HttpStatusCode.NoContent)
} catch (exception: AdminOperatorException) {
call.respondOperatorError(exception)
}
}
post("/operators/{operatorId}/unlock") {
val principal = call.requireMutationPrincipal(config, sessionService) ?: return@post
val operatorId = call.uuidPathParameter("operatorId") ?: return@post
try {
operatorService.unlock(
principal,
operatorId,
call.request.header("X-Request-ID"),
)
call.respond(HttpStatusCode.NoContent)
} catch (exception: AdminOperatorException) {
call.respondOperatorError(exception)
}
}
post("/operators/{operatorId}/credentials/reset") {
val principal = call.requireMutationPrincipal(config, sessionService) ?: return@post
val operatorId = call.uuidPathParameter("operatorId") ?: return@post
val request = call.receiveAdminRequest<AdminOperatorPasswordRequest>() ?: return@post
val password = request.password.toCharArray()
try {
val credentials = operatorService.resetCredentials(
principal,
operatorId,
password,
call.request.header("X-Request-ID"),
)
call.respond(credentials.toProvisioningResponse(operatorId))
} catch (exception: AdminOperatorException) {
call.respondOperatorError(exception)
} finally {
password.fill('\u0000')
}
}
post("/operators/{operatorId}/sessions/revoke") {
val principal = call.requireMutationPrincipal(config, sessionService) ?: return@post
val operatorId = call.uuidPathParameter("operatorId") ?: return@post
try {
operatorService.revokeSessions(
principal,
operatorId,
call.request.header("X-Request-ID"),
)
call.respond(HttpStatusCode.NoContent)
} catch (exception: AdminOperatorException) {
call.respondOperatorError(exception)
}
}
get("/audit") {
val principal = call.requirePrincipal(sessionService) ?: return@get
val limit = call.pageLimit(maximum = 100) ?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
}
val page = try {
auditService.list(
actor = principal,
cursor = call.request.queryParameters["cursor"],
limit = limit,
)
} catch (exception: AdminOperatorException) {
call.respondOperatorError(exception)
return@get
} catch (_: AdminAuditCursorException) {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
}
call.respond(
PageResponse(
items = page.items.map { item ->
val audit = item.record
AdminAuditResponse(
auditId = audit.id.toString(),
operatorName = item.operatorName,
action = audit.action.name,
targetType = audit.targetType ?: "NONE",
targetId = audit.targetId ?: "",
requestId = audit.requestId,
result = if (audit.outcome.name == "SUCCESS") "success" else "rejected",
createdAt = audit.occurredAt.toString(),
)
},
nextCursor = page.nextCursor,
),
)
}
}
}
private suspend fun AdminStatsService.getRange(
range: String?,
clock: Clock,
): AdminStatsDto? {
val days = when (range) {
null, "30d" -> 30L
"7d" -> 7L
"90d" -> 90L
else -> return null
}
val until = clock.instant()
return get(until.minus(Duration.ofDays(days)), until)
}
private suspend fun ApplicationCall.requirePrincipal(
sessions: AdminSessionService,
): AdminPrincipal? {
if (!requireVerifiedAdminEdge()) return null
val principal = currentPrincipal(sessions)
if (principal == null) {
respond(HttpStatusCode.Unauthorized, AdminErrorResponse("UNAUTHORIZED"))
}
return principal
}
private suspend fun ApplicationCall.requireRole(
sessions: AdminSessionService,
allowedRoles: Set<AdminRole>,
): AdminPrincipal? {
val principal = requirePrincipal(sessions) ?: return null
if (principal.role !in allowedRoles) {
respond(HttpStatusCode.Forbidden, AdminErrorResponse("INSUFFICIENT_PERMISSION"))
return null
}
return principal
}
private suspend fun ApplicationCall.requireMutationPrincipal(
config: AppConfig,
sessions: AdminSessionService,
): AdminPrincipal? {
if (!requireVerifiedAdminEdge() || !requireSameOrigin(config)) return null
val sessionToken = request.cookies[SESSION_COOKIE]
val csrfToken = request.header(CSRF_HEADER)
val principal = if (sessionToken != null && csrfToken != null) {
sessions.authenticateMutation(sessionToken, csrfToken)
} else {
null
}
if (principal == null) {
respond(HttpStatusCode.Forbidden, AdminErrorResponse("CSRF_INVALID"))
}
return principal
}
private suspend fun ApplicationCall.requireVerifiedAdminEdge(): Boolean {
if (request.header(MTLS_HEADER) == MTLS_VERIFIED) return true
respond(HttpStatusCode.NotFound)
return false
}
private suspend fun ApplicationCall.requireSameOrigin(config: AppConfig): Boolean {
if (request.header(HttpHeaders.Origin) == config.publicBaseUrl) return true
respond(HttpStatusCode.Forbidden, AdminErrorResponse("ORIGIN_INVALID"))
return false
}
private suspend fun ApplicationCall.currentPrincipal(
sessions: AdminSessionService,
): AdminPrincipal? = request.cookies[SESSION_COOKIE]?.let { sessions.authenticate(it) }
private suspend fun ApplicationCall.uuidPathParameter(name: String): UUID? {
val value = parameters[name]
val parsed = value?.let { runCatching { UUID.fromString(it) }.getOrNull() }
if (parsed == null) respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return parsed
}
private fun ApplicationCall.pageLimit(
maximum: Int,
default: Int = 50,
): Int? {
val raw = request.queryParameters["limit"] ?: return default
return raw.toIntOrNull()?.takeIf { it in 1..maximum }
}
private suspend inline fun <reified T : Any> ApplicationCall.receiveAdminRequest(): T? =
try {
receive<T>()
} catch (_: BadRequestException) {
respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
null
}
private suspend fun ApplicationCall.respondOperatorError(exception: AdminOperatorException) {
val status = when (exception.code) {
AdminOperatorErrorCode.VALIDATION_ERROR -> HttpStatusCode.BadRequest
AdminOperatorErrorCode.INSUFFICIENT_PERMISSION -> HttpStatusCode.Forbidden
AdminOperatorErrorCode.ADMIN_OPERATOR_NOT_FOUND -> HttpStatusCode.NotFound
AdminOperatorErrorCode.ADMIN_USERNAME_CONFLICT,
AdminOperatorErrorCode.CANNOT_DISABLE_SELF,
AdminOperatorErrorCode.LAST_SUPER_ADMIN_REQUIRED,
-> HttpStatusCode.Conflict
}
respond(status, AdminErrorResponse(exception.code.name))
}
private fun ApplicationCall.setAdminCookies(config: AppConfig, sessionToken: String, csrfToken: String) {
response.cookies.append(adminCookie(SESSION_COOKIE, sessionToken, config, httpOnly = true))
response.cookies.append(adminCookie(CSRF_COOKIE, csrfToken, config, httpOnly = false))
}
private fun ApplicationCall.clearAdminCookies(config: AppConfig) {
response.cookies.append(adminCookie(SESSION_COOKIE, "", config, httpOnly = true, maxAge = 0))
response.cookies.append(adminCookie(CSRF_COOKIE, "", config, httpOnly = false, maxAge = 0))
}
private fun adminCookie(
name: String,
value: String,
config: AppConfig,
httpOnly: Boolean,
maxAge: Int? = null,
): Cookie = Cookie(
name = name,
value = value,
path = "/",
maxAge = maxAge,
secure = config.isProduction,
httpOnly = httpOnly,
extensions = mapOf("SameSite" to "Strict"),
)
private fun AdminStatsDto.toOverviewResponse(): AdminOverviewResponse {
val consumedByDate = creditFlow.associateBy { it.date }
return AdminOverviewResponse(
totalUsers = overview.totalUsers,
activeUsers = overview.activeUsers,
newUsers = overview.registrations,
totalCreditBalance = overview.totalCreditBalance,
creditsGranted = overview.issuedCredits,
creditsUsed = overview.consumedCredits,
trend = registrationTrend.map {
AdminTrendResponse(
date = it.date,
registrations = it.registrations,
creditsUsed = consumedByDate[it.date]?.consumedCredits ?: 0,
)
},
usage = usage,
)
}
private fun AdminStatsDto.toReferralResponse(): AdminReferralResponse =
AdminReferralResponse(
pendingBindings = referralFunnel.pendingBindings,
ineligibleBindings = referralFunnel.ineligibleBindings,
funnel = listOf(
AdminFunnelResponse("邀请码创建", referralFunnel.codesCreated),
AdminFunnelResponse("成功绑定", referralFunnel.bindings),
AdminFunnelResponse("有效使用并奖励", referralFunnel.rewardedBindings),
AdminFunnelResponse("待资格确认", referralFunnel.pendingBindings),
AdminFunnelResponse("不符合奖励条件", referralFunnel.ineligibleBindings),
),
ranking = referralRanking.map {
AdminReferralRankResponse(
userId = it.userId,
invited = it.invitedUsers,
qualified = it.rewardedUsers,
creditsEarned = it.earnedCredits,
)
},
)
private fun AdminUserSummaryDto.toUserSummaryResponse(): AdminUserSummaryResponse =
AdminUserSummaryResponse(
userId = id,
displayName = "用户 ${id.take(8)}",
status = if (antiAbuseRestricted) "suspended" else "active",
creditBalance = creditBalance,
createdAt = createdAt,
)
private fun AdminUserDetailDto.toUserDetailResponse(): AdminUserDetailResponse =
AdminUserDetailResponse(
summary = summary.toUserSummaryResponse(),
lastActiveAt = summary.lastActiveAt,
qualifiedUsage = summary.usageRequests > 0,
referralCode = referralCode,
referredByUserId = referral.inviterUserId,
usage = usage,
referral = referral,
)
private fun AdminUserLedgerEntryDto.toLedgerResponse(): AdminLedgerResponse =
AdminLedgerResponse(
entryId = id,
type = when (type) {
"USAGE_RESERVE" -> "reserve"
"USAGE_SETTLE" -> "settle"
"USAGE_RELEASE", "USAGE_REFUND" -> "refund"
"SIGNUP_TRIAL", "MANUAL_GRANT", "REFERRAL_INVITER", "REFERRAL_INVITEE",
"STOREKIT_PURCHASE", "SUBSCRIPTION_GRANT" -> "grant"
else -> "adjustment"
},
amount = amountDelta,
balanceAfter = balanceAfter,
reasonCode = type,
createdAt = createdAt,
)
private fun AdminOperatorRecord.toResponse(): AdminOperatorResponse =
AdminOperatorResponse(
operatorId = id.toString(),
username = normalizedUsername,
role = role.name,
enabled = disabledAt == null,
failedLoginCount = lockState.failedLoginCount,
lockedUntil = lockState.lockedUntil?.toString(),
lastLoginAt = lastLoginAt?.toString(),
createdAt = createdAt.toString(),
updatedAt = updatedAt.toString(),
)
private fun AdminOperatorCredentials.toProvisioningResponse(
fallbackOperatorId: UUID? = null,
): AdminOperatorProvisioningResponse = AdminOperatorProvisioningResponse(
operator = operator?.toResponse(),
operatorId = operator?.id?.toString() ?: requireNotNull(fallbackOperatorId).toString(),
totpSecret = totpSecret,
otpauthUri = otpauthUri,
)
@Serializable
private data class AdminLoginRequest(
val username: String,
val password: String,
val totpCode: String,
)
@Serializable
private data class AdminLoginResponse(
val operatorName: String,
val role: String,
val csrfToken: String,
)
@Serializable
private data class AdminSessionResponse(
val authenticated: Boolean,
val operatorName: String? = null,
val role: String? = null,
)
@Serializable
private data class AdminErrorResponse(val code: String)
@Serializable
private data class AdminGrantRequest(val userId: String, val amount: Long, val reason: String)
@Serializable
private data class AdminGrantResponse(val transactionId: String, val balanceAfter: Long)
@Serializable
private data class AdminOperatorCreateRequest(
val username: String,
val password: String,
val role: String,
)
@Serializable
private data class AdminOperatorPasswordRequest(val password: String)
@Serializable
private data class AdminOperatorResponse(
val operatorId: String,
val username: String,
val role: String,
val enabled: Boolean,
val failedLoginCount: Int,
val lockedUntil: String?,
val lastLoginAt: String?,
val createdAt: String,
val updatedAt: String,
)
@Serializable
private data class AdminOperatorProvisioningResponse(
val operatorId: String,
val operator: AdminOperatorResponse? = null,
val totpSecret: String,
val otpauthUri: String,
)
@Serializable
private data class AdminSecuritySummaryResponse(
val enabledOperators: Int,
val lockedOperators: Int,
val activeSessions: Long,
)
@Serializable
private data class PageResponse<T>(val items: List<T>, val nextCursor: String? = null)
@Serializable
private data class AdminOverviewResponse(
val totalUsers: Long,
val activeUsers: Long,
val newUsers: Long,
val totalCreditBalance: Long,
val creditsGranted: Long,
val creditsUsed: Long,
val trend: List<AdminTrendResponse>,
val usage: List<AdminUsageAggregateDto>,
)
@Serializable
private data class AdminTrendResponse(
val date: String,
val registrations: Long,
val creditsUsed: Long,
)
@Serializable
private data class AdminReferralResponse(
val pendingBindings: Long,
val ineligibleBindings: Long,
val funnel: List<AdminFunnelResponse>,
val ranking: List<AdminReferralRankResponse>,
)
@Serializable
private data class AdminFunnelResponse(val label: String, val count: Long)
@Serializable
private data class AdminReferralRankResponse(
val userId: String,
val invited: Long,
val qualified: Long,
val creditsEarned: Long,
)
@Serializable
private data class AdminUserSummaryResponse(
val userId: String,
val displayName: String,
val status: String,
val creditBalance: Long,
val createdAt: String,
)
@Serializable
private data class AdminUserDetailResponse(
val userId: String,
val displayName: String,
val status: String,
val creditBalance: Long,
val createdAt: String,
val lastActiveAt: String?,
val qualifiedUsage: Boolean,
val referralCode: String?,
val referredByUserId: String?,
val usage: List<AdminUsageAggregateDto>,
val referral: AdminUserReferralDto,
) {
constructor(
summary: AdminUserSummaryResponse,
lastActiveAt: String?,
qualifiedUsage: Boolean,
referralCode: String?,
referredByUserId: String?,
usage: List<AdminUsageAggregateDto>,
referral: AdminUserReferralDto,
) : this(
userId = summary.userId,
displayName = summary.displayName,
status = summary.status,
creditBalance = summary.creditBalance,
createdAt = summary.createdAt,
lastActiveAt = lastActiveAt,
qualifiedUsage = qualifiedUsage,
referralCode = referralCode,
referredByUserId = referredByUserId,
usage = usage,
referral = referral,
)
}
@Serializable
private data class AdminLedgerResponse(
val entryId: String,
val type: String,
val amount: Long,
val balanceAfter: Long,
val reasonCode: String,
val createdAt: String,
)
@Serializable
private data class AdminAuditResponse(
val auditId: String,
val operatorName: String,
val action: String,
val targetType: String,
val targetId: String,
val requestId: String? = null,
val result: String,
val createdAt: String,
)
private const val MTLS_HEADER = "X-OSG-mTLS-Verified"
private const val MTLS_VERIFIED = "SUCCESS"
private const val SESSION_COOKIE = "osg_admin_session"
private const val CSRF_COOKIE = "osg_admin_csrf"
private const val CSRF_HEADER = "X-CSRF-Token"
private val ADMIN_AUTH_RATE_LIMIT = RateLimitName("admin-auth")
@@ -0,0 +1,20 @@
package com.osglab.account.features.admin.security
import com.osglab.account.common.security.TokenHash
/**
* Validates a double-submit CSRF token against the digest bound to the server
* session. Cookie/header extraction remains a route-layer concern.
*/
class AdminCsrfVerifier {
fun verify(presentedToken: String, expectedHash: String): Boolean {
if (presentedToken.isBlank() || presentedToken.length > MAX_TOKEN_LENGTH) return false
if (!SHA256_HEX.matches(expectedHash)) return false
return TokenHash.matches(presentedToken, expectedHash)
}
private companion object {
const val MAX_TOKEN_LENGTH = 512
val SHA256_HEX = Regex("^[0-9a-f]{64}$")
}
}
@@ -0,0 +1,131 @@
package com.osglab.account.features.admin.security
import org.bouncycastle.crypto.generators.Argon2BytesGenerator
import org.bouncycastle.crypto.params.Argon2Parameters
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.Base64
interface AdminPasswordHasher {
fun hash(password: CharArray): String
fun verify(password: CharArray, encodedHash: String): Boolean
}
data class Argon2idConfig(
val memoryKb: Int = 65_536,
val iterations: Int = 3,
val parallelism: Int = 1,
val saltBytes: Int = 16,
val hashBytes: Int = 32,
) {
init {
require(memoryKb in MIN_MEMORY_KB..MAX_MEMORY_KB)
require(iterations in 1..MAX_ITERATIONS)
require(parallelism in 1..MAX_PARALLELISM)
require(memoryKb >= parallelism * 8)
require(saltBytes in 16..MAX_SALT_BYTES)
require(hashBytes in 16..MAX_HASH_BYTES)
}
private companion object {
const val MIN_MEMORY_KB = 8
const val MAX_MEMORY_KB = 262_144
const val MAX_ITERATIONS = 10
const val MAX_PARALLELISM = 16
const val MAX_SALT_BYTES = 64
const val MAX_HASH_BYTES = 64
}
}
/**
* PHC-compatible Argon2id password hashing backed by the project's existing
* Bouncy Castle provider. Verification bounds parsed work factors to prevent a
* malformed database value from causing excessive CPU or memory consumption.
*/
class BouncyCastleArgon2idPasswordHasher(
private val config: Argon2idConfig = Argon2idConfig(),
private val secureRandom: SecureRandom = SecureRandom(),
) : AdminPasswordHasher {
override fun hash(password: CharArray): String {
requirePasswordLength(password)
val salt = ByteArray(config.saltBytes).also(secureRandom::nextBytes)
val digest = derive(password, salt, config)
return try {
val encoder = Base64.getEncoder().withoutPadding()
"\$argon2id\$v=19\$m=${config.memoryKb},t=${config.iterations},p=${config.parallelism}\$" +
"${encoder.encodeToString(salt)}\$${encoder.encodeToString(digest)}"
} finally {
salt.fill(0)
digest.fill(0)
}
}
override fun verify(password: CharArray, encodedHash: String): Boolean {
if (password.isEmpty() || password.size > MAX_PASSWORD_CHARS) return false
val parsed = runCatching { parse(encodedHash) }.getOrNull() ?: return false
val actual = runCatching { derive(password, parsed.salt, parsed.config) }.getOrNull()
?: return false
return try {
MessageDigest.isEqual(actual, parsed.digest)
} finally {
actual.fill(0)
parsed.salt.fill(0)
parsed.digest.fill(0)
}
}
private fun derive(password: CharArray, salt: ByteArray, parameters: Argon2idConfig): ByteArray {
val generator = Argon2BytesGenerator()
generator.init(
Argon2Parameters.Builder(Argon2Parameters.ARGON2_id)
.withVersion(Argon2Parameters.ARGON2_VERSION_13)
.withMemoryAsKB(parameters.memoryKb)
.withIterations(parameters.iterations)
.withParallelism(parameters.parallelism)
.withSalt(salt)
.build(),
)
return ByteArray(parameters.hashBytes).also { generator.generateBytes(password, it) }
}
private fun parse(encodedHash: String): ParsedHash {
require(encodedHash.length <= MAX_ENCODED_HASH_CHARS)
val parts = encodedHash.split('$')
require(parts.size == 6 && parts[0].isEmpty())
require(parts[1] == "argon2id" && parts[2] == "v=19")
val parameterComponents = parts[3].split(',')
require(parameterComponents.size == 3)
val values = parameterComponents.associate { component ->
val pair = component.split('=', limit = 2)
require(pair.size == 2)
pair[0] to pair[1].toInt()
}
require(values.keys == setOf("m", "t", "p"))
val salt = Base64.getDecoder().decode(parts[4])
val digest = Base64.getDecoder().decode(parts[5])
val parsedConfig = Argon2idConfig(
memoryKb = requireNotNull(values["m"]),
iterations = requireNotNull(values["t"]),
parallelism = requireNotNull(values["p"]),
saltBytes = salt.size,
hashBytes = digest.size,
)
return ParsedHash(parsedConfig, salt, digest)
}
private fun requirePasswordLength(password: CharArray) {
require(password.isNotEmpty()) { "Password must not be empty" }
require(password.size <= MAX_PASSWORD_CHARS) { "Password exceeds maximum length" }
}
private data class ParsedHash(
val config: Argon2idConfig,
val salt: ByteArray,
val digest: ByteArray,
)
private companion object {
const val MAX_PASSWORD_CHARS = 1_024
const val MAX_ENCODED_HASH_CHARS = 512
}
}
@@ -0,0 +1,185 @@
package com.osglab.account.features.admin.security
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
import java.security.MessageDigest
import java.security.SecureRandom
import java.time.Instant
import java.net.URLEncoder
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
fun interface AdminTotpVerifier {
/**
* Returns the accepted moving counter. Persisting this counter with a
* compare-and-set prevents reuse of an otherwise valid code.
*/
fun verify(secret: ByteArray, code: String, now: Instant): Long?
}
class HmacTotpVerifier(
private val digits: Int = 6,
private val periodSeconds: Long = 30,
private val allowedWindow: Int = 1,
private val algorithm: String = "HmacSHA1",
) : AdminTotpVerifier {
init {
require(digits in 6..8)
require(periodSeconds in 15..120)
require(allowedWindow in 0..10)
require(algorithm in SUPPORTED_ALGORITHMS)
}
override fun verify(secret: ByteArray, code: String, now: Instant): Long? {
if (secret.size !in MIN_SECRET_BYTES..MAX_SECRET_BYTES) return null
if (code.length != digits || code.any { it !in '0'..'9' }) return null
if (now.epochSecond < 0) return null
val currentCounter = now.epochSecond / periodSeconds
var acceptedCounter: Long? = null
for (offset in -allowedWindow..allowedWindow) {
val candidate = currentCounter + offset
if (candidate < 0) continue
val expected = generateForCounter(secret, candidate)
if (
MessageDigest.isEqual(
code.toByteArray(Charsets.US_ASCII),
expected.toByteArray(Charsets.US_ASCII),
)
) {
// Prefer the newest matching counter if a short code collides
// within the configured window.
acceptedCounter = candidate
}
}
return acceptedCounter
}
fun generate(secret: ByteArray, at: Instant): String {
require(secret.size in MIN_SECRET_BYTES..MAX_SECRET_BYTES)
require(at.epochSecond >= 0)
return generateForCounter(secret, at.epochSecond / periodSeconds)
}
internal fun generateForCounter(secret: ByteArray, counter: Long): String {
require(secret.size in MIN_SECRET_BYTES..MAX_SECRET_BYTES)
require(counter >= 0)
val mac = Mac.getInstance(algorithm)
mac.init(SecretKeySpec(secret, algorithm))
val digest = mac.doFinal(ByteBuffer.allocate(Long.SIZE_BYTES).putLong(counter).array())
val offset = digest.last().toInt() and 0x0f
val binary = ((digest[offset].toInt() and 0x7f) shl 24) or
((digest[offset + 1].toInt() and 0xff) shl 16) or
((digest[offset + 2].toInt() and 0xff) shl 8) or
(digest[offset + 3].toInt() and 0xff)
return (binary % POWERS_OF_TEN[digits]).toString().padStart(digits, '0')
}
private companion object {
val SUPPORTED_ALGORITHMS = setOf("HmacSHA1", "HmacSHA256", "HmacSHA512")
val POWERS_OF_TEN = intArrayOf(1, 10, 100, 1_000, 10_000, 100_000, 1_000_000, 10_000_000, 100_000_000)
const val MIN_SECRET_BYTES = 16
const val MAX_SECRET_BYTES = 64
}
}
object Base32TotpSecret {
fun encode(secret: ByteArray): String {
require(secret.size in MIN_SECRET_BYTES..MAX_SECRET_BYTES)
val result = StringBuilder((secret.size * 8 + 4) / 5)
var buffer = 0
var bufferedBits = 0
secret.forEach { byte ->
buffer = (buffer shl Byte.SIZE_BITS) or (byte.toInt() and 0xff)
bufferedBits += Byte.SIZE_BITS
while (bufferedBits >= 5) {
bufferedBits -= 5
result.append(ALPHABET[(buffer shr bufferedBits) and 0x1f])
}
buffer = if (bufferedBits == 0) 0 else buffer and ((1 shl bufferedBits) - 1)
}
if (bufferedBits > 0) {
result.append(ALPHABET[(buffer shl (5 - bufferedBits)) and 0x1f])
}
return result.toString()
}
fun decode(encoded: String): ByteArray {
val normalized = encoded
.trim()
.replace(" ", "")
.uppercase()
.trimEnd('=')
require(normalized.isNotEmpty()) { "TOTP secret must not be empty" }
val output = ByteArrayOutputStream()
var buffer = 0
var bufferedBits = 0
normalized.forEach { character ->
val value = ALPHABET.indexOf(character)
require(value >= 0) { "TOTP secret is not valid Base32" }
buffer = (buffer shl 5) or value
bufferedBits += 5
if (bufferedBits >= Byte.SIZE_BITS) {
bufferedBits -= Byte.SIZE_BITS
output.write((buffer shr bufferedBits) and 0xff)
buffer = if (bufferedBits == 0) 0 else buffer and ((1 shl bufferedBits) - 1)
}
}
require(buffer == 0) { "TOTP secret has non-zero trailing bits" }
return output.toByteArray().also {
require(it.size in MIN_SECRET_BYTES..MAX_SECRET_BYTES) {
"TOTP secret must contain 128 to 512 bits"
}
}
}
private const val ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
private const val MIN_SECRET_BYTES = 16
private const val MAX_SECRET_BYTES = 64
}
class AdminTotpProvisioning(
val secretBase32: String,
val otpauthUri: String,
) {
override fun toString(): String =
"AdminTotpProvisioning(secretBase32=[REDACTED], otpauthUri=[REDACTED])"
}
fun interface AdminTotpSecretGenerator {
fun generate(normalizedUsername: String): AdminTotpProvisioning
}
class SecureAdminTotpSecretGenerator(
private val secureRandom: SecureRandom = SecureRandom(),
private val issuer: String = "OSGKeyboard",
) : AdminTotpSecretGenerator {
init {
require(issuer.isNotBlank())
}
override fun generate(normalizedUsername: String): AdminTotpProvisioning {
require(normalizedUsername.isNotBlank())
val rawSecret = ByteArray(SECRET_BYTES).also(secureRandom::nextBytes)
val secret = try {
Base32TotpSecret.encode(rawSecret)
} finally {
rawSecret.fill(0)
}
val encodedIssuer = encodeUriComponent(issuer)
val encodedLabel = encodeUriComponent("$issuer:$normalizedUsername")
return AdminTotpProvisioning(
secretBase32 = secret,
otpauthUri = "otpauth://totp/$encodedLabel?secret=$secret&issuer=$encodedIssuer" +
"&algorithm=SHA1&digits=6&period=30",
)
}
private fun encodeUriComponent(value: String): String =
URLEncoder.encode(value, Charsets.UTF_8).replace("+", "%20")
private companion object {
const val SECRET_BYTES = 20
}
}
@@ -0,0 +1,99 @@
package com.osglab.account.features.admin.services
import com.osglab.account.features.admin.models.AdminAuditCursor
import com.osglab.account.features.admin.models.AdminAuditRecord
import com.osglab.account.features.admin.models.AdminPrincipal
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.repositories.AdminRepository
import java.nio.charset.StandardCharsets
import java.time.Instant
import java.util.Base64
import java.util.UUID
class AdminAuditCursorException : RuntimeException("Invalid admin audit cursor")
data class AdminAuditItem(
val record: AdminAuditRecord,
val operatorName: String,
)
data class AdminAuditPage(
val items: List<AdminAuditItem>,
val nextCursor: String?,
)
class AdminAuditService(
private val repository: AdminRepository,
) {
suspend fun list(
actor: AdminPrincipal,
cursor: String?,
limit: Int = DEFAULT_PAGE_SIZE,
): AdminAuditPage {
if (actor.role != AdminRole.SUPER_ADMIN) {
throw AdminOperatorException(AdminOperatorErrorCode.INSUFFICIENT_PERMISSION)
}
require(limit in 1..MAX_PAGE_SIZE)
val decodedCursor = cursor?.let(::decodeCursor)
val records = repository.listAudit(limit + 1, decodedCursor)
val pageRecords = records.take(limit)
val operatorNames = repository.listOperators().associate {
it.id to it.normalizedUsername
}
return AdminAuditPage(
items = pageRecords.map { record ->
AdminAuditItem(
record = record,
operatorName = record.actorOperatorId?.let(operatorNames::get)
?: record.actorOperatorId?.toString()
?: "system",
)
},
nextCursor = if (records.size > limit) {
pageRecords.lastOrNull()?.let(::encodeCursor)
} else {
null
},
)
}
private fun decodeCursor(value: String): AdminAuditCursor {
if (value.length !in 1..MAX_CURSOR_LENGTH) throw AdminAuditCursorException()
return runCatching {
val decoded = String(
Base64.getUrlDecoder().decode(value),
StandardCharsets.UTF_8,
)
val parts = decoded.split(':', limit = 3)
require(parts.size == 3)
AdminAuditCursor(
occurredAt = Instant.ofEpochSecond(
parts[0].toLong(),
parts[1].toLong(),
),
id = UUID.fromString(parts[2]),
)
}.getOrElse {
throw AdminAuditCursorException()
}
}
private fun encodeCursor(record: AdminAuditRecord): String {
val payload = buildString {
append(record.occurredAt.epochSecond)
append(':')
append(record.occurredAt.nano)
append(':')
append(record.id)
}
return Base64.getUrlEncoder().withoutPadding().encodeToString(
payload.toByteArray(StandardCharsets.UTF_8),
)
}
private companion object {
const val DEFAULT_PAGE_SIZE = 50
const val MAX_PAGE_SIZE = 100
const val MAX_CURSOR_LENGTH = 256
}
}
@@ -0,0 +1,236 @@
package com.osglab.account.features.admin.services
import com.osglab.account.common.security.FieldEncryptor
import com.osglab.account.common.security.SecureTokenGenerator
import com.osglab.account.common.security.Sha256SecureTokenGenerator
import com.osglab.account.common.security.TokenHash
import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminAuditOutcome
import com.osglab.account.features.admin.models.AdminLockState
import com.osglab.account.features.admin.models.AdminLoginResult
import com.osglab.account.features.admin.models.AdminPrincipal
import com.osglab.account.features.admin.models.AdminSessionCredentials
import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.admin.models.NewAdminSession
import com.osglab.account.features.admin.repositories.AdminRepository
import com.osglab.account.features.admin.security.AdminPasswordHasher
import com.osglab.account.features.admin.security.AdminTotpVerifier
import com.osglab.account.features.admin.security.Base32TotpSecret
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.util.UUID
class AdminLoginLockPolicy(
private val maxFailedAttempts: Int = 5,
private val lockDuration: Duration = Duration.ofMinutes(15),
) {
init {
require(maxFailedAttempts in 2..20)
require(!lockDuration.isNegative && !lockDuration.isZero)
require(lockDuration <= Duration.ofHours(24))
}
fun afterFailure(current: AdminLockState, now: Instant): AdminLockState {
if (current.isLockedAt(now)) return current
val failuresBeforeAttempt = if (
current.lockedUntil != null && !current.lockedUntil.isAfter(now)
) {
0
} else {
current.failedLoginCount
}
val failures = failuresBeforeAttempt + 1
return if (failures >= maxFailedAttempts) {
AdminLockState(failedLoginCount = 0, lockedUntil = now.plus(lockDuration))
} else {
AdminLockState(failedLoginCount = failures, lockedUntil = null)
}
}
}
class AdminAuthService(
private val repository: AdminRepository,
private val passwordHasher: AdminPasswordHasher,
private val dummyPasswordHash: String,
private val totpVerifier: AdminTotpVerifier,
private val fieldEncryptor: FieldEncryptor,
sessionTtl: Duration,
private val lockPolicy: AdminLoginLockPolicy = AdminLoginLockPolicy(),
private val tokenGenerator: SecureTokenGenerator = Sha256SecureTokenGenerator(),
private val clock: Clock = Clock.systemUTC(),
) {
private val sessionTtl = sessionTtl.also {
require(it in MIN_SESSION_TTL..MAX_SESSION_TTL)
}
init {
require(dummyPasswordHash.isNotBlank())
}
suspend fun login(
username: String,
password: CharArray,
totpCode: String,
requestId: String? = null,
): AdminLoginResult {
val normalizedRequestId = validateRequestId(requestId)
val normalizedUsername = normalizeAdminUsername(username)
?: return AdminLoginResult.InvalidCredentials
if (password.isEmpty() || password.size > MAX_PASSWORD_CHARS) {
return AdminLoginResult.InvalidCredentials
}
val now = clock.instant()
val operator = repository.findOperatorForAuthentication(normalizedUsername)
val passwordMatches = passwordHasher.verify(
password,
operator?.passwordHash ?: dummyPasswordHash,
)
if (operator == null) {
repository.appendAudit(
loginAudit(null, AdminAuditOutcome.DENIED, now, normalizedRequestId),
)
return AdminLoginResult.InvalidCredentials
}
if (operator.disabledAt != null) {
repository.appendAudit(
loginAudit(operator.id, AdminAuditOutcome.DENIED, now, normalizedRequestId),
)
return AdminLoginResult.InvalidCredentials
}
if (operator.lockState.isLockedAt(now)) {
// Password verification above keeps locked and unknown users on
// the same expensive hashing path.
repository.appendAudit(
loginAudit(operator.id, AdminAuditOutcome.DENIED, now, normalizedRequestId),
)
return AdminLoginResult.Locked(requireNotNull(operator.lockState.lockedUntil))
}
if (!passwordMatches) {
return failAuthentication(operator.id, now, normalizedRequestId)
}
val acceptedCounter = verifyTotp(operator.id, operator.encryptedTotpSecret, totpCode, now)
?: return failAuthentication(operator.id, now, normalizedRequestId)
val rawSessionToken = tokenGenerator.newRefreshToken()
val rawCsrfToken = tokenGenerator.newRefreshToken()
val session = NewAdminSession(
id = UUID.randomUUID(),
operatorId = operator.id,
tokenHash = TokenHash.sha256(rawSessionToken),
csrfTokenHash = TokenHash.sha256(rawCsrfToken),
createdAt = now,
expiresAt = now.plus(sessionTtl),
)
val created = repository.createSessionIfTotpCounterFresh(
session = session,
totpCounter = acceptedCounter,
now = now,
auditEvent = loginAudit(
operator.id,
AdminAuditOutcome.SUCCESS,
now,
normalizedRequestId,
),
) ?: run {
repository.appendAudit(
loginAudit(operator.id, AdminAuditOutcome.DENIED, now, normalizedRequestId),
)
return AdminLoginResult.InvalidCredentials
}
return AdminLoginResult.Authenticated(
principal = AdminPrincipal(
operatorId = created.operatorId,
sessionId = created.id,
normalizedUsername = created.normalizedUsername,
role = created.role,
),
credentials = AdminSessionCredentials(
sessionToken = rawSessionToken,
csrfToken = rawCsrfToken,
expiresAt = created.expiresAt,
),
)
}
private suspend fun failAuthentication(
operatorId: UUID,
now: Instant,
requestId: String?,
): AdminLoginResult {
val state = repository.updateLockState(
operatorId = operatorId,
now = now,
auditEvent = loginAudit(operatorId, AdminAuditOutcome.DENIED, now, requestId),
) {
lockPolicy.afterFailure(it, now)
}
if (state == null) {
repository.appendAudit(
loginAudit(operatorId, AdminAuditOutcome.DENIED, now, requestId),
)
return AdminLoginResult.InvalidCredentials
}
return state.lockedUntil
?.takeIf { it.isAfter(now) }
?.let(AdminLoginResult::Locked)
?: AdminLoginResult.InvalidCredentials
}
private fun verifyTotp(
operatorId: UUID,
encryptedSecret: String,
code: String,
now: Instant,
): Long? {
val secret = runCatching {
Base32TotpSecret.decode(
fieldEncryptor.decrypt(encryptedSecret, adminTotpContext(operatorId)),
)
}.getOrNull() ?: return null
return try {
totpVerifier.verify(secret, code, now)
} finally {
secret.fill(0)
}
}
private fun loginAudit(
operatorId: UUID?,
outcome: AdminAuditOutcome,
now: Instant,
requestId: String?,
) = NewAdminAuditEvent(
actorOperatorId = operatorId,
action = if (outcome == AdminAuditOutcome.SUCCESS) {
AdminAuditAction.LOGIN_SUCCEEDED
} else {
AdminAuditAction.LOGIN_FAILED
},
outcome = outcome,
targetType = operatorId?.let { OPERATOR_TARGET },
targetId = operatorId?.toString(),
requestId = requestId,
occurredAt = now,
)
private fun validateRequestId(requestId: String?): String? {
val normalized = requestId?.trim() ?: return null
return normalized.takeIf {
it.length in 1..MAX_REQUEST_ID_LENGTH && REQUEST_ID.matches(it)
}
}
private companion object {
val MIN_SESSION_TTL: Duration = Duration.ofMinutes(5)
val MAX_SESSION_TTL: Duration = Duration.ofHours(24)
const val MAX_PASSWORD_CHARS = 1_024
const val MAX_REQUEST_ID_LENGTH = 128
const val OPERATOR_TARGET = "ADMIN_OPERATOR"
val REQUEST_ID = Regex("^[A-Za-z0-9._:-]+$")
}
}
fun adminTotpContext(operatorId: UUID): String = "admin-totp-secret:$operatorId"
@@ -0,0 +1,65 @@
package com.osglab.account.features.admin.services
import com.osglab.account.common.security.FieldEncryptor
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.models.NewAdminOperator
import com.osglab.account.features.admin.repositories.AdminRepository
import com.osglab.account.features.admin.security.Base32TotpSecret
import java.time.Clock
import java.util.UUID
data class AdminBootstrapConfig(
val enabled: Boolean,
val operatorId: UUID?,
val username: String?,
val passwordHash: String?,
val totpSecretBase32: String?,
) {
init {
if (enabled) {
requireNotNull(operatorId) { "Admin bootstrap operator ID is required" }
require(!username.isNullOrBlank()) { "Admin bootstrap username is required" }
require(passwordHash?.startsWith("\$argon2id\$v=19\$") == true) {
"Admin bootstrap password must be an Argon2id PHC hash"
}
require(!totpSecretBase32.isNullOrBlank()) { "Admin bootstrap TOTP secret is required" }
}
}
}
class AdminBootstrapService(
private val repository: AdminRepository,
private val fieldEncryptor: FieldEncryptor,
private val clock: Clock = Clock.systemUTC(),
) {
suspend fun initialize(config: AdminBootstrapConfig): Boolean {
if (!config.enabled) return false
val operatorId = requireNotNull(config.operatorId)
val username = requireNotNull(
normalizeAdminUsername(requireNotNull(config.username)),
) { "Admin bootstrap username is invalid" }
val totpSecret = requireNotNull(config.totpSecretBase32).trim().uppercase()
Base32TotpSecret.decode(totpSecret).fill(0)
val created = repository.createOperatorIfAbsent(
NewAdminOperator(
id = operatorId,
normalizedUsername = username,
passwordHash = requireNotNull(config.passwordHash),
encryptedTotpSecret = fieldEncryptor.encrypt(
totpSecret,
adminTotpContext(operatorId),
),
role = AdminRole.SUPER_ADMIN,
createdAt = clock.instant(),
),
)
if (!created) {
val existing = repository.findOperatorForAuthentication(username)
require(existing?.id == operatorId) {
"Admin bootstrap configuration does not match the existing operator"
}
}
return created
}
}
@@ -0,0 +1,414 @@
package com.osglab.account.features.admin.services
import com.osglab.account.common.security.FieldEncryptor
import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminAuditOutcome
import com.osglab.account.features.admin.models.AdminOperatorMutationResult
import com.osglab.account.features.admin.models.AdminOperatorCursor
import com.osglab.account.features.admin.models.AdminOperatorRecord
import com.osglab.account.features.admin.models.AdminPrincipal
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.admin.models.NewAdminOperator
import com.osglab.account.features.admin.repositories.AdminRepository
import com.osglab.account.features.admin.security.AdminPasswordHasher
import com.osglab.account.features.admin.security.AdminTotpProvisioning
import com.osglab.account.features.admin.security.AdminTotpSecretGenerator
import com.osglab.account.features.admin.security.SecureAdminTotpSecretGenerator
import java.time.Clock
import java.time.Instant
import java.nio.charset.StandardCharsets
import java.util.Base64
import java.util.Locale
import java.util.UUID
enum class AdminOperatorErrorCode {
VALIDATION_ERROR,
INSUFFICIENT_PERMISSION,
ADMIN_OPERATOR_NOT_FOUND,
ADMIN_USERNAME_CONFLICT,
CANNOT_DISABLE_SELF,
LAST_SUPER_ADMIN_REQUIRED,
}
class AdminOperatorException(
val code: AdminOperatorErrorCode,
) : RuntimeException(code.name)
class AdminOperatorCursorException : RuntimeException("Invalid admin operator cursor")
class AdminOperatorCredentials(
val operator: AdminOperatorRecord?,
val totpSecret: String,
val otpauthUri: String,
) {
override fun toString(): String =
"AdminOperatorCredentials(operator=${operator?.id}, totpSecret=[REDACTED], otpauthUri=[REDACTED])"
}
data class AdminSecuritySummary(
val enabledOperators: Int,
val lockedOperators: Int,
val activeSessions: Long,
)
data class AdminOperatorPage(
val items: List<AdminOperatorRecord>,
val nextCursor: String?,
)
class AdminOperatorService(
private val repository: AdminRepository,
private val passwordHasher: AdminPasswordHasher,
private val fieldEncryptor: FieldEncryptor,
private val totpSecretGenerator: AdminTotpSecretGenerator = SecureAdminTotpSecretGenerator(),
private val clock: Clock = Clock.systemUTC(),
) {
suspend fun list(actor: AdminPrincipal): List<AdminOperatorRecord> {
requireSuperAdministrator(actor)
return repository.listOperators()
}
suspend fun listPage(
actor: AdminPrincipal,
cursor: String?,
limit: Int = DEFAULT_PAGE_SIZE,
): AdminOperatorPage {
requireSuperAdministrator(actor)
if (limit !in 1..MAX_PAGE_SIZE) {
throw AdminOperatorCursorException()
}
val decodedCursor = cursor?.let(::decodeCursor)
val records = repository.listOperatorsPage(limit + 1, decodedCursor)
val items = records.take(limit)
return AdminOperatorPage(
items = items,
nextCursor = if (records.size > limit) items.lastOrNull()?.let(::encodeCursor) else null,
)
}
suspend fun summary(actor: AdminPrincipal): AdminSecuritySummary {
requireSuperAdministrator(actor)
val now = clock.instant()
val operators = repository.listOperators()
return AdminSecuritySummary(
enabledOperators = operators.count { it.disabledAt == null },
lockedOperators = operators.count {
it.lockState.lockedUntil?.isAfter(now) == true
},
activeSessions = repository.countActiveSessions(now),
)
}
suspend fun create(
actor: AdminPrincipal,
username: String,
password: CharArray,
roleName: String,
requestId: String? = null,
): AdminOperatorCredentials = try {
createInternal(actor, username, password, roleName, requestId)
} finally {
password.fill('\u0000')
}
suspend fun setEnabled(
actor: AdminPrincipal,
operatorId: UUID,
enabled: Boolean,
requestId: String? = null,
) {
val action = if (enabled) {
AdminAuditAction.OPERATOR_ENABLED
} else {
AdminAuditAction.OPERATOR_DISABLED
}
authorizeMutation(actor, action, operatorId, requestId)
if (!enabled && actor.operatorId == operatorId) {
auditDenied(actor, action, operatorId, requestId)
throw AdminOperatorException(AdminOperatorErrorCode.CANNOT_DISABLE_SELF)
}
mapMutationResult(
repository.setOperatorEnabled(
operatorId = operatorId,
enabled = enabled,
now = clock.instant(),
auditEvent = audit(actor, action, operatorId, requestId),
),
)
}
suspend fun unlock(
actor: AdminPrincipal,
operatorId: UUID,
requestId: String? = null,
) {
authorizeMutation(actor, AdminAuditAction.OPERATOR_UNLOCKED, operatorId, requestId)
mapMutationResult(
repository.unlockOperator(
operatorId = operatorId,
now = clock.instant(),
auditEvent = audit(actor, AdminAuditAction.OPERATOR_UNLOCKED, operatorId, requestId),
),
)
}
suspend fun resetCredentials(
actor: AdminPrincipal,
operatorId: UUID,
password: CharArray,
requestId: String? = null,
): AdminOperatorCredentials = try {
resetCredentialsInternal(actor, operatorId, password, requestId)
} finally {
password.fill('\u0000')
}
suspend fun revokeSessions(
actor: AdminPrincipal,
operatorId: UUID,
requestId: String? = null,
) {
authorizeMutation(actor, AdminAuditAction.OPERATOR_SESSIONS_REVOKED, operatorId, requestId)
mapMutationResult(
repository.revokeOperatorSessions(
operatorId = operatorId,
now = clock.instant(),
auditEvent = audit(
actor,
AdminAuditAction.OPERATOR_SESSIONS_REVOKED,
operatorId,
requestId,
),
),
)
}
private suspend fun createInternal(
actor: AdminPrincipal,
username: String,
password: CharArray,
roleName: String,
requestId: String?,
): AdminOperatorCredentials {
authorizeMutation(actor, AdminAuditAction.OPERATOR_CREATED, null, requestId)
val normalizedUsername = normalizeAdminUsername(username)
val role = parseRole(roleName)
if (normalizedUsername == null || role == null || !validPassword(password)) {
auditDenied(
actor,
AdminAuditAction.OPERATOR_CREATED,
null,
requestId,
safeUsernameTarget(username),
)
throw AdminOperatorException(AdminOperatorErrorCode.VALIDATION_ERROR)
}
val operatorId = UUID.randomUUID()
val now = clock.instant()
val provisioning = totpSecretGenerator.generate(normalizedUsername)
val operator = NewAdminOperator(
id = operatorId,
normalizedUsername = normalizedUsername,
passwordHash = passwordHasher.hash(password),
encryptedTotpSecret = fieldEncryptor.encrypt(
provisioning.secretBase32,
adminTotpContext(operatorId),
),
role = role,
createdAt = now,
)
mapMutationResult(
repository.createOperator(
operator,
audit(actor, AdminAuditAction.OPERATOR_CREATED, operatorId, requestId),
),
)
return credentialsFromProvisioning(
operator = operator.toRecord(),
provisioning = provisioning,
)
}
private suspend fun resetCredentialsInternal(
actor: AdminPrincipal,
operatorId: UUID,
password: CharArray,
requestId: String?,
): AdminOperatorCredentials {
authorizeMutation(actor, AdminAuditAction.OPERATOR_CREDENTIALS_RESET, operatorId, requestId)
if (!validPassword(password)) {
auditDenied(actor, AdminAuditAction.OPERATOR_CREDENTIALS_RESET, operatorId, requestId)
throw AdminOperatorException(AdminOperatorErrorCode.VALIDATION_ERROR)
}
val current = repository.findOperator(operatorId)
if (current == null) {
auditDenied(actor, AdminAuditAction.OPERATOR_CREDENTIALS_RESET, operatorId, requestId)
throw AdminOperatorException(AdminOperatorErrorCode.ADMIN_OPERATOR_NOT_FOUND)
}
val provisioning = totpSecretGenerator.generate(current.normalizedUsername)
val result = repository.resetOperatorCredentials(
operatorId = operatorId,
passwordHash = passwordHasher.hash(password),
encryptedTotpSecret = fieldEncryptor.encrypt(
provisioning.secretBase32,
adminTotpContext(operatorId),
),
now = clock.instant(),
auditEvent = audit(
actor,
AdminAuditAction.OPERATOR_CREDENTIALS_RESET,
operatorId,
requestId,
),
)
mapMutationResult(result)
return credentialsFromProvisioning(null, provisioning)
}
private fun requireSuperAdministrator(actor: AdminPrincipal) {
if (actor.role != AdminRole.SUPER_ADMIN) {
throw AdminOperatorException(AdminOperatorErrorCode.INSUFFICIENT_PERMISSION)
}
}
private suspend fun authorizeMutation(
actor: AdminPrincipal,
action: AdminAuditAction,
operatorId: UUID?,
requestId: String?,
) {
if (actor.role != AdminRole.SUPER_ADMIN) {
auditDenied(actor, action, operatorId, requestId)
throw AdminOperatorException(AdminOperatorErrorCode.INSUFFICIENT_PERMISSION)
}
}
private suspend fun auditDenied(
actor: AdminPrincipal,
action: AdminAuditAction,
operatorId: UUID?,
requestId: String?,
targetOverride: String? = null,
) {
repository.appendAudit(
audit(
actor = actor,
action = action,
operatorId = operatorId,
requestId = requestId,
outcome = AdminAuditOutcome.DENIED,
targetOverride = targetOverride,
),
)
}
private fun audit(
actor: AdminPrincipal,
action: AdminAuditAction,
operatorId: UUID?,
requestId: String?,
outcome: AdminAuditOutcome = AdminAuditOutcome.SUCCESS,
targetOverride: String? = null,
) = NewAdminAuditEvent(
actorOperatorId = actor.operatorId,
action = action,
outcome = outcome,
targetType = OPERATOR_TARGET,
targetId = targetOverride ?: operatorId?.toString() ?: "NEW",
requestId = normalizeAdminRequestId(requestId),
occurredAt = clock.instant(),
)
private fun mapMutationResult(result: AdminOperatorMutationResult) {
when (result) {
AdminOperatorMutationResult.SUCCESS -> Unit
AdminOperatorMutationResult.NOT_FOUND ->
throw AdminOperatorException(AdminOperatorErrorCode.ADMIN_OPERATOR_NOT_FOUND)
AdminOperatorMutationResult.USERNAME_CONFLICT ->
throw AdminOperatorException(AdminOperatorErrorCode.ADMIN_USERNAME_CONFLICT)
AdminOperatorMutationResult.LAST_SUPER_ADMIN ->
throw AdminOperatorException(AdminOperatorErrorCode.LAST_SUPER_ADMIN_REQUIRED)
}
}
private fun validPassword(password: CharArray): Boolean =
password.size in MIN_PASSWORD_CHARS..MAX_PASSWORD_CHARS
private fun parseRole(roleName: String): AdminRole? =
runCatching { AdminRole.valueOf(roleName) }.getOrNull()
private fun safeUsernameTarget(username: String): String =
username.trim().lowercase(Locale.ROOT).take(MAX_AUDIT_TARGET_CHARS).ifBlank { "INVALID" }
private fun NewAdminOperator.toRecord() = AdminOperatorRecord(
id = id,
normalizedUsername = normalizedUsername,
role = role,
lockState = com.osglab.account.features.admin.models.AdminLockState(0, null),
disabledAt = null,
lastLoginAt = null,
createdAt = createdAt,
updatedAt = createdAt,
)
private fun credentialsFromProvisioning(
operator: AdminOperatorRecord?,
provisioning: AdminTotpProvisioning,
) = AdminOperatorCredentials(
operator = operator,
totpSecret = provisioning.secretBase32,
otpauthUri = provisioning.otpauthUri,
)
private companion object {
const val DEFAULT_PAGE_SIZE = 50
const val MAX_PAGE_SIZE = 100
const val MAX_CURSOR_LENGTH = 256
const val MIN_PASSWORD_CHARS = 12
const val MAX_PASSWORD_CHARS = 1_024
const val MAX_AUDIT_TARGET_CHARS = 128
const val OPERATOR_TARGET = "ADMIN_OPERATOR"
}
private fun decodeCursor(value: String): AdminOperatorCursor {
if (value.length !in 1..MAX_CURSOR_LENGTH) throw AdminOperatorCursorException()
return runCatching {
val decoded = String(
Base64.getUrlDecoder().decode(value),
StandardCharsets.UTF_8,
)
val parts = decoded.split(':', limit = 3)
require(parts.size == 3)
AdminOperatorCursor(
createdAt = Instant.ofEpochSecond(parts[0].toLong(), parts[1].toLong()),
id = UUID.fromString(parts[2]),
)
}.getOrElse {
throw AdminOperatorCursorException()
}
}
private fun encodeCursor(record: AdminOperatorRecord): String {
val payload = "${record.createdAt.epochSecond}:${record.createdAt.nano}:${record.id}"
return Base64.getUrlEncoder().withoutPadding().encodeToString(
payload.toByteArray(StandardCharsets.UTF_8),
)
}
}
fun normalizeAdminUsername(value: String): String? {
val normalized = value.trim().lowercase(Locale.ROOT)
return normalized.takeIf { ADMIN_USERNAME.matches(it) }
}
fun normalizeAdminRequestId(value: String?): String? {
val normalized = value?.trim() ?: return null
return normalized.takeIf {
it.length <= 128 && ADMIN_REQUEST_ID.matches(it)
}
}
private val ADMIN_USERNAME = Regex("^[a-z0-9][a-z0-9._@-]{2,63}$")
private val ADMIN_REQUEST_ID = Regex("^[A-Za-z0-9._:-]+$")
@@ -0,0 +1,94 @@
package com.osglab.account.features.admin.services
import com.osglab.account.common.security.TokenHash
import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminAuditOutcome
import com.osglab.account.features.admin.models.AdminPrincipal
import com.osglab.account.features.admin.models.AdminSessionRecord
import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.admin.repositories.AdminRepository
import com.osglab.account.features.admin.security.AdminCsrfVerifier
import java.time.Clock
import java.time.Duration
class AdminSessionService(
private val repository: AdminRepository,
private val csrfVerifier: AdminCsrfVerifier = AdminCsrfVerifier(),
private val clock: Clock = Clock.systemUTC(),
) {
suspend fun authenticate(sessionToken: String): AdminPrincipal? =
findSession(sessionToken)?.toPrincipal()
suspend fun authenticateMutation(
sessionToken: String,
csrfToken: String,
): AdminPrincipal? {
val session = findSession(sessionToken) ?: return null
if (!csrfVerifier.verify(csrfToken, session.csrfTokenHash)) return null
return session.toPrincipal()
}
suspend fun revoke(
sessionToken: String,
csrfToken: String,
requestId: String? = null,
): Boolean {
val tokenHash = hashValidToken(sessionToken) ?: return false
val now = clock.instant()
val active = repository.findActiveSessionByTokenHash(tokenHash, now) ?: return false
if (!csrfVerifier.verify(csrfToken, active.csrfTokenHash)) return false
val event = NewAdminAuditEvent(
actorOperatorId = active.operatorId,
action = AdminAuditAction.SESSION_REVOKED,
outcome = AdminAuditOutcome.SUCCESS,
targetType = SESSION_TARGET,
targetId = active.id.toString(),
requestId = validateRequestId(requestId),
occurredAt = now,
)
return repository.revokeSessionByTokenHash(tokenHash, now, event) != null
}
suspend fun cleanupInactive(
retention: Duration = DEFAULT_INACTIVE_RETENTION,
limit: Int = DEFAULT_CLEANUP_BATCH_SIZE,
): Int {
require(!retention.isNegative && !retention.isZero)
require(limit in 1..MAX_CLEANUP_BATCH_SIZE)
return repository.purgeInactiveSessions(clock.instant().minus(retention), limit)
}
private suspend fun findSession(sessionToken: String): AdminSessionRecord? {
val tokenHash = hashValidToken(sessionToken) ?: return null
return repository.findActiveSessionByTokenHash(tokenHash, clock.instant())
}
private fun hashValidToken(token: String): String? {
if (token.isBlank() || token.length > MAX_TOKEN_LENGTH) return null
return TokenHash.sha256(token)
}
private fun validateRequestId(requestId: String?): String? {
val normalized = requestId?.trim() ?: return null
return normalized.takeIf {
it.length in 1..MAX_REQUEST_ID_LENGTH && REQUEST_ID.matches(it)
}
}
private fun AdminSessionRecord.toPrincipal() = AdminPrincipal(
operatorId = operatorId,
sessionId = id,
normalizedUsername = normalizedUsername,
role = role,
)
private companion object {
const val MAX_TOKEN_LENGTH = 512
const val MAX_REQUEST_ID_LENGTH = 128
const val DEFAULT_CLEANUP_BATCH_SIZE = 500
const val MAX_CLEANUP_BATCH_SIZE = 1_000
const val SESSION_TARGET = "ADMIN_SESSION"
val DEFAULT_INACTIVE_RETENTION: Duration = Duration.ofDays(7)
val REQUEST_ID = Regex("^[A-Za-z0-9._:-]+$")
}
}
@@ -0,0 +1,70 @@
package com.osglab.account.features.admin.stats.models
import kotlinx.serialization.Serializable
@Serializable
data class AdminStatsPeriodDto(
val from: String,
val until: String,
)
@Serializable
data class AdminOverviewDto(
val totalUsers: Long,
val registrations: Long,
val activeUsers: Long,
val totalCreditBalance: Long,
val issuedCredits: Long,
val consumedCredits: Long,
)
@Serializable
data class AdminRegistrationPointDto(
val date: String,
val registrations: Long,
)
@Serializable
data class AdminCreditFlowPointDto(
val date: String,
val issuedCredits: Long,
val consumedCredits: Long,
)
@Serializable
data class AdminReferralFunnelDto(
val codesCreated: Long,
val bindings: Long,
val rewardedBindings: Long,
val pendingBindings: Long,
val ineligibleBindings: Long,
)
@Serializable
data class AdminReferralRankDto(
val userId: String,
val invitedUsers: Long,
val rewardedUsers: Long,
val earnedCredits: Long,
)
@Serializable
data class AdminUsageAggregateDto(
val kind: String,
val requests: Long,
val chargedCredits: Long,
val asrMillis: Long,
val inputTokens: Long,
val outputTokens: Long,
)
@Serializable
data class AdminStatsDto(
val period: AdminStatsPeriodDto,
val overview: AdminOverviewDto,
val registrationTrend: List<AdminRegistrationPointDto>,
val creditFlow: List<AdminCreditFlowPointDto>,
val referralFunnel: AdminReferralFunnelDto,
val referralRanking: List<AdminReferralRankDto>,
val usage: List<AdminUsageAggregateDto>,
)
@@ -0,0 +1,334 @@
package com.osglab.account.features.admin.stats.repositories
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.admin.stats.models.AdminOverviewDto
import com.osglab.account.features.admin.stats.models.AdminReferralFunnelDto
import com.osglab.account.features.admin.stats.models.AdminReferralRankDto
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
import org.jetbrains.exposed.v1.core.IColumnType
import org.jetbrains.exposed.v1.javatime.JavaInstantColumnType
import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager
import java.math.BigDecimal
import java.sql.ResultSet
import java.time.Instant
import java.time.LocalDate
data class AdminStatsRange(
val from: Instant,
val until: Instant,
) {
init {
require(from < until) { "Admin statistics range must be non-empty" }
}
}
data class AdminStatsSnapshot(
val overview: AdminOverviewDto,
val registrationsByDate: Map<LocalDate, Long>,
val issuedCreditsByDate: Map<LocalDate, Long>,
val consumedCreditsByDate: Map<LocalDate, Long>,
val referralFunnel: AdminReferralFunnelDto,
val referralRanking: List<AdminReferralRankDto>,
val usage: List<AdminUsageAggregateDto>,
)
fun interface AdminStatsRepository {
suspend fun load(range: AdminStatsRange): AdminStatsSnapshot
}
internal data class AdminStatsAggregates(
val overview: AdminOverviewDto,
val registrationsByDate: Map<LocalDate, Long>,
val issuedCreditsByDate: Map<LocalDate, Long>,
val consumedCreditsByDate: Map<LocalDate, Long>,
val referralFunnel: AdminReferralFunnelDto,
val referralBindingsByInviter: List<ReferralBindingAggregateRow>,
val referralCreditsByInviter: Map<String, Long>,
val usage: List<AdminUsageAggregateDto>,
)
internal data class ReferralBindingAggregateRow(
val inviterUserId: String,
val invitedUsers: Long,
val rewardedUsers: Long,
)
class ExposedAdminStatsRepository(
private val databaseFactory: DatabaseFactory,
) : AdminStatsRepository {
override suspend fun load(range: AdminStatsRange): AdminStatsSnapshot =
databaseFactory.query {
assembleAdminStats(loadAggregates(range))
}
private fun loadAggregates(range: AdminStatsRange): AdminStatsAggregates =
AdminStatsAggregates(
overview = loadOverview(range),
registrationsByDate = loadDailyAggregates(
"""
SELECT DATE(created_at) AS aggregate_date, COUNT(*) AS aggregate_value
FROM accounts
WHERE created_at >= ? AND created_at < ?
GROUP BY DATE(created_at)
""",
range,
),
issuedCreditsByDate = loadDailyAggregates(
"""
SELECT DATE(created_at) AS aggregate_date,
COALESCE(SUM(amount_delta), 0) AS aggregate_value
FROM credit_ledger
WHERE created_at >= ? AND created_at < ?
AND amount_delta > 0
AND entry_type IN (
'SIGNUP_TRIAL', 'MANUAL_GRANT', 'REFERRAL_INVITER',
'REFERRAL_INVITEE', 'STOREKIT_PURCHASE', 'SUBSCRIPTION_GRANT'
)
GROUP BY DATE(created_at)
""",
range,
),
consumedCreditsByDate = loadDailyAggregates(
"""
SELECT DATE(created_at) AS aggregate_date,
COALESCE(SUM(charged_credits), 0) AS aggregate_value
FROM credit_usage_records
WHERE created_at >= ? AND created_at < ?
GROUP BY DATE(created_at)
""",
range,
),
referralFunnel = loadReferralFunnel(range),
referralBindingsByInviter = loadReferralBindingsByInviter(range),
referralCreditsByInviter = loadReferralCreditsByInviter(range),
usage = loadUsage(range),
)
private fun loadOverview(range: AdminStatsRange): AdminOverviewDto =
querySingle(
"""
SELECT
(SELECT COUNT(*) FROM accounts) AS total_users,
(
SELECT COUNT(*)
FROM accounts
WHERE created_at >= ? AND created_at < ?
) AS registrations,
(
SELECT COUNT(DISTINCT user_id)
FROM credit_usage_records
WHERE created_at >= ? AND created_at < ?
) AS active_users,
(
SELECT COALESCE(SUM(balance), 0)
FROM credit_accounts
) AS total_credit_balance,
(
SELECT COALESCE(SUM(amount_delta), 0)
FROM credit_ledger
WHERE created_at >= ? AND created_at < ?
AND amount_delta > 0
AND entry_type IN (
'SIGNUP_TRIAL', 'MANUAL_GRANT', 'REFERRAL_INVITER',
'REFERRAL_INVITEE', 'STOREKIT_PURCHASE', 'SUBSCRIPTION_GRANT'
)
) AS issued_credits,
(
SELECT COALESCE(SUM(charged_credits), 0)
FROM credit_usage_records
WHERE created_at >= ? AND created_at < ?
) AS consumed_credits
""",
range.arguments(repetitions = 4),
) { result ->
AdminOverviewDto(
totalUsers = result.exactLong("total_users"),
registrations = result.exactLong("registrations"),
activeUsers = result.exactLong("active_users"),
totalCreditBalance = result.exactLong("total_credit_balance"),
issuedCredits = result.exactLong("issued_credits"),
consumedCredits = result.exactLong("consumed_credits"),
)
}
private fun loadReferralFunnel(range: AdminStatsRange): AdminReferralFunnelDto =
querySingle(
"""
SELECT
(
SELECT COUNT(*)
FROM referral_codes
WHERE created_at >= ? AND created_at < ?
) AS codes_created,
(
SELECT COUNT(*)
FROM referral_bindings
WHERE bound_at >= ? AND bound_at < ?
) AS bindings,
(
SELECT COUNT(*)
FROM referral_bindings
WHERE reward_status = 'REWARDED'
AND rewarded_at >= ? AND rewarded_at < ?
) AS rewarded_bindings,
(
SELECT COUNT(*)
FROM referral_bindings
WHERE reward_status = 'PENDING'
AND bound_at >= ? AND bound_at < ?
) AS pending_bindings,
(
SELECT COUNT(*)
FROM referral_bindings
WHERE reward_status = 'INELIGIBLE_BUDGET'
AND bound_at >= ? AND bound_at < ?
) AS ineligible_bindings
""",
range.arguments(repetitions = 5),
) { result ->
AdminReferralFunnelDto(
codesCreated = result.exactLong("codes_created"),
bindings = result.exactLong("bindings"),
rewardedBindings = result.exactLong("rewarded_bindings"),
pendingBindings = result.exactLong("pending_bindings"),
ineligibleBindings = result.exactLong("ineligible_bindings"),
)
}
private fun loadReferralBindingsByInviter(
range: AdminStatsRange,
): List<ReferralBindingAggregateRow> =
queryRows(
"""
SELECT
inviter_user_id,
SUM(CASE WHEN bound_at >= ? AND bound_at < ? THEN 1 ELSE 0 END) AS invited_users,
SUM(
CASE
WHEN reward_status = 'REWARDED'
AND rewarded_at >= ? AND rewarded_at < ?
THEN 1 ELSE 0
END
) AS rewarded_users
FROM referral_bindings
WHERE (bound_at >= ? AND bound_at < ?)
OR (
reward_status = 'REWARDED'
AND rewarded_at >= ? AND rewarded_at < ?
)
GROUP BY inviter_user_id
""",
range.arguments(repetitions = 4),
) { result ->
ReferralBindingAggregateRow(
inviterUserId = result.getString("inviter_user_id"),
invitedUsers = result.exactLong("invited_users"),
rewardedUsers = result.exactLong("rewarded_users"),
)
}
private fun loadReferralCreditsByInviter(range: AdminStatsRange): Map<String, Long> =
queryRows(
"""
SELECT user_id, COALESCE(SUM(amount_delta), 0) AS earned_credits
FROM credit_ledger
WHERE created_at >= ? AND created_at < ?
AND entry_type = 'REFERRAL_INVITER'
AND amount_delta > 0
GROUP BY user_id
""",
range.arguments(),
) { result ->
result.getString("user_id") to result.exactLong("earned_credits")
}.toMap()
private fun loadUsage(range: AdminStatsRange): List<AdminUsageAggregateDto> =
queryRows(
"""
SELECT
usage_kind,
COUNT(*) AS requests,
COALESCE(SUM(charged_credits), 0) AS charged_credits,
COALESCE(SUM(asr_millis), 0) AS asr_millis,
COALESCE(SUM(input_tokens), 0) AS input_tokens,
COALESCE(SUM(output_tokens), 0) AS output_tokens
FROM credit_usage_records
WHERE created_at >= ? AND created_at < ?
GROUP BY usage_kind
ORDER BY usage_kind
""",
range.arguments(),
) { result ->
AdminUsageAggregateDto(
kind = result.getString("usage_kind"),
requests = result.exactLong("requests"),
chargedCredits = result.exactLong("charged_credits"),
asrMillis = result.exactLong("asr_millis"),
inputTokens = result.exactLong("input_tokens"),
outputTokens = result.exactLong("output_tokens"),
)
}
private fun loadDailyAggregates(sql: String, range: AdminStatsRange): Map<LocalDate, Long> =
queryRows(sql, range.arguments()) { result ->
result.getObject("aggregate_date", LocalDate::class.java) to
result.exactLong("aggregate_value")
}.toMap()
}
internal fun assembleAdminStats(aggregates: AdminStatsAggregates): AdminStatsSnapshot =
AdminStatsSnapshot(
overview = aggregates.overview,
registrationsByDate = aggregates.registrationsByDate,
issuedCreditsByDate = aggregates.issuedCreditsByDate,
consumedCreditsByDate = aggregates.consumedCreditsByDate,
referralFunnel = aggregates.referralFunnel,
referralRanking = aggregates.referralBindingsByInviter.map { binding ->
AdminReferralRankDto(
userId = binding.inviterUserId,
invitedUsers = binding.invitedUsers,
rewardedUsers = binding.rewardedUsers,
earnedCredits = aggregates.referralCreditsByInviter[binding.inviterUserId] ?: 0,
)
}.sortedWith(
compareByDescending<AdminReferralRankDto>(AdminReferralRankDto::invitedUsers)
.thenByDescending(AdminReferralRankDto::rewardedUsers)
.thenBy(AdminReferralRankDto::userId),
),
usage = aggregates.usage.sortedBy(AdminUsageAggregateDto::kind),
)
private fun AdminStatsRange.arguments(
repetitions: Int = 1,
): List<Pair<IColumnType<*>, Any?>> = buildList {
repeat(repetitions) {
add(INSTANT_COLUMN_TYPE to from)
add(INSTANT_COLUMN_TYPE to until)
}
}
private fun <T> querySingle(
sql: String,
arguments: List<Pair<IColumnType<*>, Any?>>,
transform: (ResultSet) -> T,
): T = queryRows(sql, arguments, transform).single()
private fun <T> queryRows(
sql: String,
arguments: List<Pair<IColumnType<*>, Any?>>,
transform: (ResultSet) -> T,
): List<T> = TransactionManager.current().exec(sql.trimIndent(), arguments) { result ->
buildList {
while (result.next()) {
add(transform(result))
}
}
} ?: emptyList()
private fun ResultSet.exactLong(column: String): Long =
requireNotNull(getBigDecimal(column)) { "Aggregate column $column must not be null" }
.toExactLong()
internal fun BigDecimal.toExactLong(): Long = longValueExact()
private val INSTANT_COLUMN_TYPE = JavaInstantColumnType()
@@ -0,0 +1,57 @@
package com.osglab.account.features.admin.stats.services
import com.osglab.account.features.admin.stats.models.AdminCreditFlowPointDto
import com.osglab.account.features.admin.stats.models.AdminRegistrationPointDto
import com.osglab.account.features.admin.stats.models.AdminStatsDto
import com.osglab.account.features.admin.stats.models.AdminStatsPeriodDto
import com.osglab.account.features.admin.stats.repositories.AdminStatsRange
import com.osglab.account.features.admin.stats.repositories.AdminStatsRepository
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
class AdminStatsService(
private val repository: AdminStatsRepository,
) {
suspend fun get(
from: Instant,
until: Instant,
referralRankLimit: Int = 20,
): AdminStatsDto {
require(from < until) { "Statistics range must be non-empty" }
require(referralRankLimit in 1..100) { "Referral rank limit must be between 1 and 100" }
val range = AdminStatsRange(from, until)
val snapshot = repository.load(range)
val dates = utcDates(range)
return AdminStatsDto(
period = AdminStatsPeriodDto(from.toString(), until.toString()),
overview = snapshot.overview,
registrationTrend = dates.map { date ->
AdminRegistrationPointDto(
date = date.toString(),
registrations = snapshot.registrationsByDate[date] ?: 0,
)
},
creditFlow = dates.map { date ->
AdminCreditFlowPointDto(
date = date.toString(),
issuedCredits = snapshot.issuedCreditsByDate[date] ?: 0,
consumedCredits = snapshot.consumedCreditsByDate[date] ?: 0,
)
},
referralFunnel = snapshot.referralFunnel,
referralRanking = snapshot.referralRanking.take(referralRankLimit),
usage = snapshot.usage,
)
}
}
private fun utcDates(range: AdminStatsRange): List<LocalDate> {
val dates = mutableListOf<LocalDate>()
var date = range.from.atZone(ZoneOffset.UTC).toLocalDate()
while (date.atStartOfDay(ZoneOffset.UTC).toInstant() < range.until) {
dates += date
date = date.plusDays(1)
}
return dates
}
@@ -0,0 +1,56 @@
package com.osglab.account.features.admin.users.models
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
import kotlinx.serialization.Serializable
@Serializable
data class AdminUserSummaryDto(
val id: String,
val createdAt: String,
val antiAbuseRestricted: Boolean,
val creditBalance: Long,
val consumedCredits: Long,
val manualGrantedCredits: Long,
val usageRequests: Long,
val lastActiveAt: String?,
val invitedUsers: Long,
val rewardedInvites: Long,
)
@Serializable
data class AdminUserPageDto(
val items: List<AdminUserSummaryDto>,
val nextCursor: String?,
)
@Serializable
data class AdminUserLedgerEntryDto(
val id: String,
val type: String,
val amountDelta: Long,
val balanceAfter: Long,
val referenceId: String?,
val createdAt: String,
)
@Serializable
data class AdminUserLedgerPageDto(
val items: List<AdminUserLedgerEntryDto>,
val nextCursor: String?,
)
@Serializable
data class AdminUserReferralDto(
val inviterUserId: String?,
val invitedUsers: Long,
val rewardedInvites: Long,
)
@Serializable
data class AdminUserDetailDto(
val summary: AdminUserSummaryDto,
val usage: List<AdminUsageAggregateDto>,
val referral: AdminUserReferralDto,
val recentLedger: List<AdminUserLedgerEntryDto>,
val referralCode: String? = null,
)
@@ -0,0 +1,332 @@
package com.osglab.account.features.admin.users.repositories
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
import com.osglab.account.features.admin.users.models.AdminUserDetailDto
import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
import com.osglab.account.features.admin.users.models.AdminUserReferralDto
import com.osglab.account.features.admin.users.models.AdminUserSummaryDto
import com.osglab.account.features.credits.domain.LedgerEntryType
import com.osglab.account.features.credits.domain.UsageKind
import com.osglab.account.features.referrals.domain.ReferralRewardStatus
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.inList
import org.jetbrains.exposed.v1.core.less
import org.jetbrains.exposed.v1.core.or
import org.jetbrains.exposed.v1.javatime.timestamp
import org.jetbrains.exposed.v1.jdbc.selectAll
import java.time.Instant
import java.util.UUID
data class AdminUserCursor(
val createdAt: Instant,
val userId: UUID,
)
data class AdminUserLedgerCursor(
val createdAt: Instant,
val ledgerEntryId: UUID,
)
interface AdminUsersRepository {
suspend fun list(limit: Int, cursor: AdminUserCursor?): List<AdminUserSummaryDto>
suspend fun exists(userId: UUID): Boolean
suspend fun findDetail(userId: UUID, ledgerLimit: Int): AdminUserDetailDto?
suspend fun listLedger(
userId: UUID,
limit: Int,
cursor: AdminUserLedgerCursor?,
): List<AdminUserLedgerEntryDto>
}
class ExposedAdminUsersRepository(
private val databaseFactory: DatabaseFactory,
) : AdminUsersRepository {
override suspend fun list(
limit: Int,
cursor: AdminUserCursor?,
): List<AdminUserSummaryDto> = databaseFactory.query {
val query = AdminUsersAccountsTable.selectAll()
if (cursor != null) {
query.where {
(AdminUsersAccountsTable.createdAt less cursor.createdAt) or
(
(AdminUsersAccountsTable.createdAt eq cursor.createdAt) and
(AdminUsersAccountsTable.id less cursor.userId.toString())
)
}
}
val accountRows = query
.orderBy(
AdminUsersAccountsTable.createdAt to SortOrder.DESC,
AdminUsersAccountsTable.id to SortOrder.DESC,
)
.limit(limit)
.toList()
val support = loadSupport(accountRows.map { it.userId() }.toSet())
accountRows.map { it.toSummary(support) }
}
override suspend fun exists(userId: UUID): Boolean = databaseFactory.query {
AdminUsersAccountsTable.selectAll()
.where { AdminUsersAccountsTable.id eq userId.toString() }
.limit(1)
.any()
}
override suspend fun findDetail(
userId: UUID,
ledgerLimit: Int,
): AdminUserDetailDto? = databaseFactory.query {
val account = AdminUsersAccountsTable.selectAll()
.where { AdminUsersAccountsTable.id eq userId.toString() }
.singleOrNull()
?: return@query null
val support = loadSupport(setOf(userId))
val invitations = support.bindings.filter { it.inviterUserId == userId }
val inviter = support.bindings.singleOrNull { it.inviteeUserId == userId }?.inviterUserId
val usage = support.usage.filter { it.userId == userId }
.groupBy(UserUsageRow::kind)
.map { (kind, records) ->
AdminUsageAggregateDto(
kind = kind.name,
requests = records.size.toLong(),
chargedCredits = records.exactSumOf(UserUsageRow::chargedCredits),
asrMillis = records.exactSumOf { it.asrMillis ?: 0 },
inputTokens = records.exactSumOf { it.inputTokens ?: 0 },
outputTokens = records.exactSumOf { it.outputTokens ?: 0 },
)
}
.sortedBy(AdminUsageAggregateDto::kind)
val recentLedger = support.ledger.filter { it.userId == userId }
.sortedWith(
compareByDescending<UserLedgerRow>(UserLedgerRow::createdAt)
.thenByDescending { it.id.toString() },
)
.take(ledgerLimit)
.map(UserLedgerRow::toDto)
AdminUserDetailDto(
summary = account.toSummary(support),
referralCode = findReferralCode(userId),
usage = usage,
referral = AdminUserReferralDto(
inviterUserId = inviter?.toString(),
invitedUsers = invitations.size.toLong(),
rewardedInvites = invitations.count {
it.status == ReferralRewardStatus.REWARDED
}.toLong(),
),
recentLedger = recentLedger,
)
}
override suspend fun listLedger(
userId: UUID,
limit: Int,
cursor: AdminUserLedgerCursor?,
): List<AdminUserLedgerEntryDto> = databaseFactory.query {
val query = AdminUsersCreditLedgerTable.selectAll()
if (cursor == null) {
query.where { AdminUsersCreditLedgerTable.userId eq userId.toString() }
} else {
query.where {
(AdminUsersCreditLedgerTable.userId eq userId.toString()) and
(
(AdminUsersCreditLedgerTable.createdAt less cursor.createdAt) or
(
(AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and
(AdminUsersCreditLedgerTable.id less cursor.ledgerEntryId.toString())
)
)
}
}
query.orderBy(
AdminUsersCreditLedgerTable.createdAt to SortOrder.DESC,
AdminUsersCreditLedgerTable.id to SortOrder.DESC,
)
.limit(limit)
.map { it.toUserLedgerRow().toDto() }
}
}
private data class UserSupportRows(
val balances: Map<UUID, Long>,
val ledger: List<UserLedgerRow>,
val usage: List<UserUsageRow>,
val bindings: List<UserReferralBindingRow>,
)
private data class UserLedgerRow(
val id: UUID,
val userId: UUID,
val type: LedgerEntryType,
val amountDelta: Long,
val balanceAfter: Long,
val referenceId: UUID?,
val createdAt: Instant,
)
private data class UserUsageRow(
val userId: UUID,
val kind: UsageKind,
val asrMillis: Long?,
val inputTokens: Long?,
val outputTokens: Long?,
val chargedCredits: Long,
val createdAt: Instant,
)
private data class UserReferralBindingRow(
val inviterUserId: UUID,
val inviteeUserId: UUID,
val status: ReferralRewardStatus,
)
private fun loadSupport(userIds: Set<UUID>): UserSupportRows {
if (userIds.isEmpty()) return UserSupportRows(emptyMap(), emptyList(), emptyList(), emptyList())
val ids = userIds.map(UUID::toString)
return UserSupportRows(
balances = AdminUsersCreditAccountsTable.selectAll()
.where { AdminUsersCreditAccountsTable.userId inList ids }
.map { UUID.fromString(it[AdminUsersCreditAccountsTable.userId]) to it[AdminUsersCreditAccountsTable.balance] }
.toMap(),
ledger = AdminUsersCreditLedgerTable.selectAll()
.where { AdminUsersCreditLedgerTable.userId inList ids }
.map(ResultRow::toUserLedgerRow),
usage = AdminUsersCreditUsageTable.selectAll()
.where { AdminUsersCreditUsageTable.userId inList ids }
.map(ResultRow::toUserUsageRow),
bindings = AdminUsersReferralBindingsTable.selectAll()
.where {
(AdminUsersReferralBindingsTable.inviterUserId inList ids) or
(AdminUsersReferralBindingsTable.inviteeUserId inList ids)
}
.map(ResultRow::toUserReferralBindingRow),
)
}
private fun findReferralCode(userId: UUID): String? =
AdminUsersReferralCodesTable.selectAll()
.where { AdminUsersReferralCodesTable.ownerUserId eq userId.toString() }
.orderBy(
AdminUsersReferralCodesTable.createdAt to SortOrder.DESC,
AdminUsersReferralCodesTable.id to SortOrder.DESC,
)
.limit(1)
.singleOrNull()
?.get(AdminUsersReferralCodesTable.code)
private fun ResultRow.toSummary(support: UserSupportRows): AdminUserSummaryDto {
val userId = userId()
val usage = support.usage.filter { it.userId == userId }
val ledger = support.ledger.filter { it.userId == userId }
val invitations = support.bindings.filter { it.inviterUserId == userId }
return AdminUserSummaryDto(
id = userId.toString(),
createdAt = this[AdminUsersAccountsTable.createdAt].toString(),
antiAbuseRestricted = this[AdminUsersAccountsTable.antiAbuseRestricted],
creditBalance = support.balances[userId] ?: 0,
consumedCredits = usage.exactSumOf(UserUsageRow::chargedCredits),
manualGrantedCredits = ledger.filter { it.type == LedgerEntryType.MANUAL_GRANT }
.exactSumOf(UserLedgerRow::amountDelta),
usageRequests = usage.size.toLong(),
lastActiveAt = usage.maxOfOrNull(UserUsageRow::createdAt)?.toString(),
invitedUsers = invitations.size.toLong(),
rewardedInvites = invitations.count {
it.status == ReferralRewardStatus.REWARDED
}.toLong(),
)
}
private object AdminUsersAccountsTable : Table("accounts") {
val id = varchar("id", 36)
val antiAbuseRestricted = bool("anti_abuse_restricted")
val createdAt = timestamp("created_at")
override val primaryKey = PrimaryKey(id)
}
private object AdminUsersCreditAccountsTable : Table("credit_accounts") {
val userId = varchar("user_id", 36)
val balance = long("balance")
override val primaryKey = PrimaryKey(userId)
}
private object AdminUsersCreditLedgerTable : Table("credit_ledger") {
val id = varchar("id", 36)
val userId = varchar("user_id", 36)
val entryType = enumerationByName<LedgerEntryType>("entry_type", 32)
val amountDelta = long("amount_delta")
val balanceAfter = long("balance_after")
val referenceId = varchar("reference_id", 36).nullable()
val createdAt = timestamp("created_at")
}
private object AdminUsersCreditUsageTable : Table("credit_usage_records") {
val userId = varchar("user_id", 36)
val usageKind = enumerationByName<UsageKind>("usage_kind", 8)
val asrMillis = long("asr_millis").nullable()
val inputTokens = long("input_tokens").nullable()
val outputTokens = long("output_tokens").nullable()
val chargedCredits = long("charged_credits")
val createdAt = timestamp("created_at")
}
private object AdminUsersReferralBindingsTable : Table("referral_bindings") {
val inviterUserId = varchar("inviter_user_id", 36)
val inviteeUserId = varchar("invitee_user_id", 36)
val rewardStatus = enumerationByName<ReferralRewardStatus>("reward_status", 24)
}
private object AdminUsersReferralCodesTable : Table("referral_codes") {
val id = varchar("id", 36)
val ownerUserId = varchar("owner_user_id", 36)
val code = varchar("code", 32)
val createdAt = timestamp("created_at")
}
private fun ResultRow.userId(): UUID = UUID.fromString(this[AdminUsersAccountsTable.id])
private fun ResultRow.toUserLedgerRow() = UserLedgerRow(
id = UUID.fromString(this[AdminUsersCreditLedgerTable.id]),
userId = UUID.fromString(this[AdminUsersCreditLedgerTable.userId]),
type = this[AdminUsersCreditLedgerTable.entryType],
amountDelta = this[AdminUsersCreditLedgerTable.amountDelta],
balanceAfter = this[AdminUsersCreditLedgerTable.balanceAfter],
referenceId = this[AdminUsersCreditLedgerTable.referenceId]?.let(UUID::fromString),
createdAt = this[AdminUsersCreditLedgerTable.createdAt],
)
private fun UserLedgerRow.toDto() = AdminUserLedgerEntryDto(
id = id.toString(),
type = type.name,
amountDelta = amountDelta,
balanceAfter = balanceAfter,
referenceId = referenceId?.toString(),
createdAt = createdAt.toString(),
)
private fun ResultRow.toUserUsageRow() = UserUsageRow(
userId = UUID.fromString(this[AdminUsersCreditUsageTable.userId]),
kind = this[AdminUsersCreditUsageTable.usageKind],
asrMillis = this[AdminUsersCreditUsageTable.asrMillis],
inputTokens = this[AdminUsersCreditUsageTable.inputTokens],
outputTokens = this[AdminUsersCreditUsageTable.outputTokens],
chargedCredits = this[AdminUsersCreditUsageTable.chargedCredits],
createdAt = this[AdminUsersCreditUsageTable.createdAt],
)
private fun ResultRow.toUserReferralBindingRow() = UserReferralBindingRow(
inviterUserId = UUID.fromString(this[AdminUsersReferralBindingsTable.inviterUserId]),
inviteeUserId = UUID.fromString(this[AdminUsersReferralBindingsTable.inviteeUserId]),
status = this[AdminUsersReferralBindingsTable.rewardStatus],
)
private inline fun <T> Iterable<T>.exactSumOf(value: (T) -> Long): Long =
fold(0L) { total, item -> Math.addExact(total, value(item)) }
@@ -0,0 +1,135 @@
package com.osglab.account.features.admin.users.services
import com.osglab.account.features.admin.users.models.AdminUserDetailDto
import com.osglab.account.features.admin.users.models.AdminUserLedgerPageDto
import com.osglab.account.features.admin.users.models.AdminUserPageDto
import com.osglab.account.features.admin.users.repositories.AdminUserCursor
import com.osglab.account.features.admin.users.repositories.AdminUserLedgerCursor
import com.osglab.account.features.admin.users.repositories.AdminUsersRepository
import java.nio.charset.StandardCharsets
import java.time.Instant
import java.util.Base64
import java.util.UUID
class AdminUserNotFoundException : RuntimeException("Admin user view does not exist")
class AdminUsersService(
private val repository: AdminUsersRepository,
) {
suspend fun searchByInternalId(query: String): AdminUserPageDto {
val userId = runCatching { UUID.fromString(query.trim()) }.getOrNull()
?: return AdminUserPageDto(emptyList(), null)
val user = repository.findDetail(userId, ledgerLimit = 1)?.summary
return AdminUserPageDto(user?.let(::listOf) ?: emptyList(), null)
}
suspend fun list(
limit: Int = 50,
cursor: String? = null,
): AdminUserPageDto {
require(limit in 1..100) { "User page limit must be between 1 and 100" }
val decodedCursor = cursor?.let(AdminUserCursorCodec::decode)
val results = repository.list(limit + 1, decodedCursor)
val hasMore = results.size > limit
val items = results.take(limit)
val nextCursor = if (hasMore) {
val last = items.last()
AdminUserCursorCodec.encode(
AdminUserCursor(
createdAt = Instant.parse(last.createdAt),
userId = UUID.fromString(last.id),
),
)
} else {
null
}
return AdminUserPageDto(items, nextCursor)
}
suspend fun detail(
userId: UUID,
ledgerLimit: Int = 50,
): AdminUserDetailDto {
require(ledgerLimit in 1..100) { "Ledger limit must be between 1 and 100" }
return repository.findDetail(userId, ledgerLimit) ?: throw AdminUserNotFoundException()
}
suspend fun ledger(
userId: UUID,
limit: Int = 50,
cursor: String? = null,
): AdminUserLedgerPageDto {
require(limit in 1..100) { "Ledger page limit must be between 1 and 100" }
val decodedCursor = cursor?.let(AdminUserLedgerCursorCodec::decode)
if (!repository.exists(userId)) throw AdminUserNotFoundException()
val results = repository.listLedger(userId, limit + 1, decodedCursor)
val hasMore = results.size > limit
val items = results.take(limit)
val nextCursor = if (hasMore) {
val last = items.last()
AdminUserLedgerCursorCodec.encode(
AdminUserLedgerCursor(
createdAt = Instant.parse(last.createdAt),
ledgerEntryId = UUID.fromString(last.id),
),
)
} else {
null
}
return AdminUserLedgerPageDto(items, nextCursor)
}
}
internal object AdminUserCursorCodec {
fun encode(cursor: AdminUserCursor): String {
val value = "${cursor.createdAt}|${cursor.userId}"
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(value.toByteArray(StandardCharsets.UTF_8))
}
fun decode(value: String): AdminUserCursor {
require(value.length in 1..256) { "User cursor is invalid" }
return try {
val decoded = String(
Base64.getUrlDecoder().decode(value),
StandardCharsets.UTF_8,
)
val parts = decoded.split('|')
require(parts.size == 2)
AdminUserCursor(
createdAt = Instant.parse(parts[0]),
userId = UUID.fromString(parts[1]),
)
} catch (failure: IllegalArgumentException) {
throw IllegalArgumentException("User cursor is invalid", failure)
}
}
}
internal object AdminUserLedgerCursorCodec {
private const val INVALID_CURSOR_MESSAGE = "User ledger cursor is invalid"
fun encode(cursor: AdminUserLedgerCursor): String {
val value = "${cursor.createdAt}|${cursor.ledgerEntryId}"
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(value.toByteArray(StandardCharsets.UTF_8))
}
fun decode(value: String): AdminUserLedgerCursor {
require(value.length in 1..256) { INVALID_CURSOR_MESSAGE }
return try {
val decoded = String(
Base64.getUrlDecoder().decode(value),
StandardCharsets.UTF_8,
)
val parts = decoded.split('|')
require(parts.size == 2)
AdminUserLedgerCursor(
createdAt = Instant.parse(parts[0]),
ledgerEntryId = UUID.fromString(parts[1]),
)
} catch (failure: IllegalArgumentException) {
throw IllegalArgumentException(INVALID_CURSOR_MESSAGE, failure)
}
}
}
@@ -62,6 +62,24 @@ data class LedgerEntry(
val createdAt: Instant,
)
data class ManualCreditGrant(
val id: UUID,
val operatorId: UUID,
val userId: UUID,
val amount: Long,
val reason: String,
val idempotencyKey: String,
val ledgerEntryId: UUID,
val auditLogId: UUID,
val createdAt: Instant,
)
data class ManualCreditGrantResult(
val grant: ManualCreditGrant,
val balanceAfter: Long,
val replayed: Boolean,
)
/**
* Billing metadata only. Provider input, audio, prompts and responses must
* never be persisted in a usage record.
@@ -1,5 +1,6 @@
package com.osglab.account.features.credits.repositories
import com.osglab.account.features.admin.grants.repositories.AdminCreditGrantRepository
import com.osglab.account.features.credits.domain.CreditAccount
import com.osglab.account.features.credits.domain.CreditRateVersion
import com.osglab.account.features.credits.domain.CreditReservation
@@ -11,6 +12,8 @@ import java.time.Instant
import java.util.UUID
interface CreditsRepository {
fun accountExists(userId: UUID): Boolean
fun createAccountIfAbsent(userId: UUID, now: Instant)
fun lockAccount(userId: UUID): CreditAccount
@@ -48,6 +51,7 @@ interface CreditsRepository {
interface BillingUnitOfWork {
val credits: CreditsRepository
val referrals: ReferralsRepository
val adminCreditGrants: AdminCreditGrantRepository
}
interface BillingTransactionRunner {
@@ -1,5 +1,7 @@
package com.osglab.account.features.credits.repositories
import com.osglab.account.features.admin.grants.repositories.AdminCreditGrantRepository
import com.osglab.account.features.admin.grants.repositories.ExposedAdminCreditGrantRepository
import com.osglab.account.features.credits.domain.CreditAccount
import com.osglab.account.features.credits.domain.CreditNotFound
import com.osglab.account.features.credits.domain.CreditRateVersion
@@ -31,6 +33,12 @@ import org.jetbrains.exposed.v1.jdbc.update
import java.time.Instant
import java.util.UUID
private object Accounts : Table("accounts") {
val id = varchar("id", 36)
override val primaryKey = PrimaryKey(id)
}
private object CreditAccounts : Table("credit_accounts") {
val userId = varchar("user_id", 36)
val balance = long("balance")
@@ -175,9 +183,17 @@ class ExposedBillingTransactionRunner(
private object ExposedBillingUnitOfWork : BillingUnitOfWork {
override val credits: CreditsRepository = ExposedCreditsRepository
override val referrals: ReferralsRepository = ExposedReferralsRepository
override val adminCreditGrants: AdminCreditGrantRepository = ExposedAdminCreditGrantRepository
}
private object ExposedCreditsRepository : CreditsRepository {
override fun accountExists(userId: UUID): Boolean =
Accounts
.selectAll()
.where { Accounts.id eq userId.toString() }
.limit(1)
.singleOrNull() != null
override fun createAccountIfAbsent(userId: UUID, now: Instant) {
CreditAccounts.insertIgnore {
it[CreditAccounts.userId] = userId.toString()
@@ -1,5 +1,8 @@
package com.osglab.account.features.credits.services
import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminAuditOutcome
import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.credits.domain.CreditAccount
import com.osglab.account.features.credits.domain.CreditConflict
import com.osglab.account.features.credits.domain.CreditCostCalculator
@@ -11,6 +14,8 @@ import com.osglab.account.features.credits.domain.InsufficientCredits
import com.osglab.account.features.credits.domain.InvalidCreditRequest
import com.osglab.account.features.credits.domain.LedgerEntry
import com.osglab.account.features.credits.domain.LedgerEntryType
import com.osglab.account.features.credits.domain.ManualCreditGrant
import com.osglab.account.features.credits.domain.ManualCreditGrantResult
import com.osglab.account.features.credits.domain.ReservationStatus
import com.osglab.account.features.credits.domain.ReservationStateRules
import com.osglab.account.features.credits.domain.UsageMeasurement
@@ -21,6 +26,7 @@ import com.osglab.account.features.referrals.domain.ReferralBinding
import com.osglab.account.features.referrals.domain.ReferralRewardStatus
import java.time.Clock
import java.time.Instant
import java.security.MessageDigest
import java.util.UUID
data class ReferralRewardConfig(
@@ -52,6 +58,15 @@ interface CreditOperations {
idempotencyKey: String,
): CreditAccount
suspend fun grantManual(
operatorId: UUID,
userId: UUID,
credits: Long,
reason: String,
requestId: String?,
idempotencyKey: String,
): ManualCreditGrantResult
suspend fun reserve(
userId: UUID,
provider: String,
@@ -146,6 +161,96 @@ class CreditService(
}
}
override suspend fun grantManual(
operatorId: UUID,
userId: UUID,
credits: Long,
reason: String,
requestId: String?,
idempotencyKey: String,
): ManualCreditGrantResult {
if (credits <= 0) throw InvalidCreditRequest("Manual grant credits must be positive")
val normalizedReason = validatedText(reason, "Manual grant reason", 1, 500)
val normalizedRequestId = validatedAdminRequestId(requestId)
val key = manualGrantIdempotencyKey(idempotencyKey)
return transactions.inTransaction { unit ->
if (!unit.credits.accountExists(userId)) {
throw CreditNotFound("Manual grant target does not exist")
}
val now = clock.instant()
unit.credits.createAccountIfAbsent(userId, now)
val account = unit.credits.lockAccount(userId)
unit.adminCreditGrants.findByIdempotencyKey(key)?.let { existing ->
requireIdempotentManualGrant(
existing,
operatorId,
userId,
credits,
normalizedReason,
)
val ledger = unit.credits.findLedgerEntry(userId, key)
?: throw CreditConflict("Manual grant audit exists without its ledger entry")
requireIdempotentLedger(
ledger,
LedgerEntryType.MANUAL_GRANT,
credits,
existing.id,
)
if (ledger.id != existing.ledgerEntryId) {
throw CreditConflict("Manual grant audit does not match its ledger entry")
}
return@inTransaction ManualCreditGrantResult(
grant = existing,
balanceAfter = ledger.balanceAfter,
replayed = true,
)
}
ensureUnusedLedgerKey(unit, userId, key)
val grantId = newId()
val ledgerEntryId = newId()
val auditLogId = newId()
val updated = applyLedgerDelta(
unit = unit,
account = account,
delta = credits,
type = LedgerEntryType.MANUAL_GRANT,
key = key,
referenceId = grantId,
now = now,
entryId = ledgerEntryId,
)
val grant = ManualCreditGrant(
id = grantId,
operatorId = operatorId,
userId = userId,
amount = credits,
reason = normalizedReason,
idempotencyKey = key,
ledgerEntryId = ledgerEntryId,
auditLogId = auditLogId,
createdAt = now,
)
unit.adminCreditGrants.insertAudit(
NewAdminAuditEvent(
id = auditLogId,
actorOperatorId = operatorId,
action = AdminAuditAction.MANUAL_CREDIT_GRANTED,
outcome = AdminAuditOutcome.SUCCESS,
targetType = "ACCOUNT",
targetId = userId.toString(),
requestId = normalizedRequestId,
occurredAt = now,
),
)
unit.adminCreditGrants.insert(grant)
ManualCreditGrantResult(
grant = grant,
balanceAfter = updated.balance,
replayed = false,
)
}
}
override suspend fun reserve(
userId: UUID,
provider: String,
@@ -503,6 +608,7 @@ class CreditService(
key: String,
referenceId: UUID?,
now: Instant,
entryId: UUID = newId(),
): CreditAccount {
val newBalance = try {
Math.addExact(account.balance, delta)
@@ -512,7 +618,7 @@ class CreditService(
if (newBalance < 0) throw InsufficientCredits(account.balance, -delta)
unit.credits.insertLedgerEntry(
LedgerEntry(
id = newId(),
id = entryId,
userId = account.userId,
type = type,
amountDelta = delta,
@@ -539,6 +645,22 @@ class CreditService(
}
}
private fun requireIdempotentManualGrant(
existing: ManualCreditGrant,
expectedOperatorId: UUID,
expectedUserId: UUID,
expectedAmount: Long,
expectedReason: String,
) {
if (existing.operatorId != expectedOperatorId ||
existing.userId != expectedUserId ||
existing.amount != expectedAmount ||
existing.reason != expectedReason
) {
throw CreditConflict("Idempotency key was already used with different manual grant parameters")
}
}
private fun calculatePositiveCost(
rate: CreditRateVersion,
usage: UsageMeasurement,
@@ -557,6 +679,38 @@ class CreditService(
return normalized
}
private fun validatedText(
value: String,
label: String,
minimumLength: Int,
maximumLength: Int,
): String {
val normalized = value.trim()
if (normalized.length !in minimumLength..maximumLength) {
throw InvalidCreditRequest(
"$label must contain $minimumLength to $maximumLength characters",
)
}
return normalized
}
private fun manualGrantIdempotencyKey(value: String): String {
val normalized = validatedIdempotencyKey(value)
val digest = MessageDigest.getInstance("SHA-256")
.digest(normalized.toByteArray(Charsets.UTF_8))
.joinToString("") { byte -> "%02x".format(byte.toInt() and 0xff) }
return "internal:manual:$digest"
}
private fun validatedAdminRequestId(value: String?): String? {
if (value == null) return null
val normalized = value.trim()
if (normalized.length !in 1..128 || !ADMIN_REQUEST_ID.matches(normalized)) {
throw InvalidCreditRequest("Admin request ID is invalid")
}
return normalized
}
private fun addOrNull(left: Long, right: Long): Long? =
try {
Math.addExact(left, right)
@@ -569,4 +723,8 @@ class CreditService(
val inviterCredits: Long,
val inviteeCredits: Long,
)
private companion object {
val ADMIN_REQUEST_ID = Regex("^[A-Za-z0-9._:-]+$")
}
}
@@ -0,0 +1,104 @@
package com.osglab.account.tools
import com.osglab.account.features.admin.security.BouncyCastleArgon2idPasswordHasher
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardOpenOption.CREATE_NEW
import java.nio.file.attribute.PosixFilePermission
import java.security.SecureRandom
import java.util.Base64
import java.util.UUID
/**
* Generates one bootstrap operator without printing credentials to stdout.
* Both output files are created with owner-only permissions and must not exist.
*/
object AdminCredentialGenerator {
@JvmStatic
fun main(arguments: Array<String>) {
require(arguments.size == 3) {
"Usage: <username> <runtime-env-output> <operator-handoff-output>"
}
val username = arguments[0].trim().lowercase()
require(USERNAME.matches(username)) { "Username must match ${USERNAME.pattern}" }
val runtimeOutput = Path.of(arguments[1]).toAbsolutePath()
val handoffOutput = Path.of(arguments[2]).toAbsolutePath()
require(runtimeOutput != handoffOutput) { "Output paths must be different" }
require(Files.notExists(runtimeOutput) && Files.notExists(handoffOutput)) {
"Output files must not already exist"
}
val random = SecureRandom()
val password = Base64.getUrlEncoder().withoutPadding()
.encodeToString(ByteArray(24).also(random::nextBytes))
val passwordChars = password.toCharArray()
val passwordHash = try {
BouncyCastleArgon2idPasswordHasher(secureRandom = random).hash(passwordChars)
} finally {
passwordChars.fill('\u0000')
}
val totpSecret = base32(ByteArray(20).also(random::nextBytes))
val operatorId = UUID.randomUUID()
writePrivate(
runtimeOutput,
"""
# One-time admin bootstrap values. Never commit, upload, or screenshot.
# After the first successful startup, set ADMIN_BOOTSTRAP_ENABLED=false
# and permanently remove all ADMIN_BOOTSTRAP_* credential values.
ADMIN_ENABLED=true
ADMIN_BOOTSTRAP_ENABLED=true
ADMIN_BOOTSTRAP_OPERATOR_ID=$operatorId
ADMIN_BOOTSTRAP_USERNAME=$username
ADMIN_BOOTSTRAP_PASSWORD_HASH='$passwordHash'
ADMIN_BOOTSTRAP_TOTP_SECRET_BASE32=$totpSecret
ADMIN_SESSION_HOURS=8
ADMIN_MAXIMUM_MANUAL_GRANT=100000
""".trimIndent() + "\n",
)
writePrivate(
handoffOutput,
"""
OSG 运营后台初始管理员
用户名:$username
密码:$password
TOTP 密钥:$totpSecret
认证器 URIotpauth://totp/OSG%20Admin:$username?secret=$totpSecret&issuer=OSG%20Admin&algorithm=SHA1&digits=6&period=30
仅保存在受信设备。首次部署成功后,请将密码录入密码管理器,并删除本文件。
""".trimIndent() + "\n",
)
}
private fun writePrivate(path: Path, content: String) {
Files.writeString(path, content, CREATE_NEW)
runCatching {
Files.setPosixFilePermissions(
path,
setOf(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE),
)
}
}
private fun base32(bytes: ByteArray): String {
val output = StringBuilder((bytes.size * 8 + 4) / 5)
var buffer = 0
var bufferedBits = 0
bytes.forEach { byte ->
buffer = (buffer shl 8) or (byte.toInt() and 0xff)
bufferedBits += 8
while (bufferedBits >= 5) {
bufferedBits -= 5
output.append(BASE32_ALPHABET[(buffer shr bufferedBits) and 0x1f])
buffer = if (bufferedBits == 0) 0 else buffer and ((1 shl bufferedBits) - 1)
}
}
if (bufferedBits > 0) {
output.append(BASE32_ALPHABET[(buffer shl (5 - bufferedBits)) and 0x1f])
}
return output.toString()
}
private val USERNAME = Regex("^[a-z0-9][a-z0-9._@-]{2,63}$")
private const val BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
}
+9
View File
@@ -30,6 +30,15 @@ app:
antiAbuse:
identityHmacKeyBase64: "$IDENTITY_HMAC_KEY"
tombstoneRetentionDays: "$IDENTITY_TOMBSTONE_RETENTION_DAYS:365"
admin:
enabled: "$ADMIN_ENABLED:false"
bootstrapEnabled: "$ADMIN_BOOTSTRAP_ENABLED:false"
bootstrapOperatorId: "$ADMIN_BOOTSTRAP_OPERATOR_ID:"
bootstrapUsername: "$ADMIN_BOOTSTRAP_USERNAME:"
bootstrapPasswordHash: "$ADMIN_BOOTSTRAP_PASSWORD_HASH:"
bootstrapTotpSecretBase32: "$ADMIN_BOOTSTRAP_TOTP_SECRET_BASE32:"
sessionHours: "$ADMIN_SESSION_HOURS:8"
maximumManualGrant: "$ADMIN_MAXIMUM_MANUAL_GRANT:100000"
apple:
teamId: "$APPLE_TEAM_ID:"
keyId: "$APPLE_KEY_ID:"
@@ -0,0 +1,107 @@
CREATE TABLE admin_operators (
id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
username VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
password_hash VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
encrypted_totp_secret TEXT NOT NULL,
role VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
failed_login_count INT NOT NULL DEFAULT 0,
locked_until DATETIME(6) NULL,
last_totp_counter BIGINT NULL,
last_login_at DATETIME(6) NULL,
disabled_at DATETIME(6) NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
UNIQUE KEY uk_admin_operators_username (username),
INDEX idx_admin_operators_status_created (disabled_at, created_at),
CONSTRAINT chk_admin_operators_role
CHECK (role IN ('SUPER_ADMIN', 'SUPPORT', 'ANALYST')),
CONSTRAINT chk_admin_operators_failed_logins
CHECK (failed_login_count >= 0),
CONSTRAINT chk_admin_operators_totp_counter
CHECK (last_totp_counter IS NULL OR last_totp_counter >= 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE admin_sessions (
id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
operator_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
token_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
csrf_token_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
expires_at DATETIME(6) NOT NULL,
revoked_at DATETIME(6) NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_admin_sessions_token_hash (token_hash),
INDEX idx_admin_sessions_operator_active (operator_id, revoked_at, expires_at),
INDEX idx_admin_sessions_expiry (expires_at),
CONSTRAINT fk_admin_sessions_operator
FOREIGN KEY (operator_id) REFERENCES admin_operators (id) ON DELETE RESTRICT,
CONSTRAINT chk_admin_sessions_expiry
CHECK (expires_at > created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE admin_audit_log (
id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
actor_operator_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,
action VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
outcome VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
target_type VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NULL,
target_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL,
request_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NULL,
occurred_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
INDEX idx_admin_audit_occurred (occurred_at, id),
INDEX idx_admin_audit_action_occurred (action, occurred_at),
INDEX idx_admin_audit_actor_occurred (actor_operator_id, occurred_at),
INDEX idx_admin_audit_target (target_type, target_id, occurred_at),
CONSTRAINT fk_admin_audit_actor
FOREIGN KEY (actor_operator_id) REFERENCES admin_operators (id) ON DELETE RESTRICT,
CONSTRAINT chk_admin_audit_target_pair CHECK (
(target_type IS NULL AND target_id IS NULL)
OR (target_type IS NOT NULL AND target_id IS NOT NULL)
)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE admin_credit_grants (
id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
operator_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
account_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
amount BIGINT NOT NULL,
reason VARCHAR(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
idempotency_key VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
ledger_entry_id CHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
audit_log_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
UNIQUE KEY uk_admin_credit_grants_idempotency (idempotency_key),
UNIQUE KEY uk_admin_credit_grants_ledger (ledger_entry_id),
UNIQUE KEY uk_admin_credit_grants_audit (audit_log_id),
INDEX idx_admin_credit_grants_account_created (account_id, created_at),
INDEX idx_admin_credit_grants_operator_created (operator_id, created_at),
INDEX idx_admin_credit_grants_created (created_at),
CONSTRAINT fk_admin_credit_grants_operator
FOREIGN KEY (operator_id) REFERENCES admin_operators (id) ON DELETE RESTRICT,
CONSTRAINT fk_admin_credit_grants_ledger
FOREIGN KEY (ledger_entry_id) REFERENCES credit_ledger (id) ON DELETE RESTRICT,
CONSTRAINT fk_admin_credit_grants_audit
FOREIGN KEY (audit_log_id) REFERENCES admin_audit_log (id) ON DELETE RESTRICT,
CONSTRAINT chk_admin_credit_grants_amount CHECK (amount > 0),
CONSTRAINT chk_admin_credit_grants_reason
CHECK (CHAR_LENGTH(TRIM(reason)) BETWEEN 1 AND 500),
CONSTRAINT chk_admin_credit_grants_idempotency
CHECK (CHAR_LENGTH(idempotency_key) BETWEEN 8 AND 128)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- account_id intentionally has no foreign key: account deletion must preserve
-- the immutable, pseudonymized grant and ledger records.
-- Dashboard queries aggregate by creation time and state. These indexes avoid
-- full scans without changing existing business data or ledger semantics.
CREATE INDEX idx_accounts_created ON accounts (created_at);
CREATE INDEX idx_credit_ledger_type_created ON credit_ledger (entry_type, created_at);
CREATE INDEX idx_credit_usage_created ON credit_usage_records (created_at);
CREATE INDEX idx_referral_bindings_status_bound
ON referral_bindings (reward_status, bound_at);
-- admin_audit_log and admin_credit_grants are append-only. The production
-- runtime database role must receive SELECT/INSERT only on these tables.
@@ -34,6 +34,48 @@ class AppConfigTest : FunSpec({
config.database.migrationUsername shouldBe "test_migrator"
}
test("production accepts enabled admin bootstrap with Argon2 PHC hash") {
val config = validProductionConfig().apply {
put("app.admin.enabled", "true")
put("app.admin.bootstrapEnabled", "true")
put("app.admin.bootstrapOperatorId", "2c031def-4517-4fde-b592-5db3a3eefdf6")
put("app.admin.bootstrapUsername", "owner")
put(
"app.admin.bootstrapPasswordHash",
"\$argon2id\$v=19\$m=65536,t=3,p=1\$c2FsdHNhbHRzYWx0c2FsdA\$aGFzaGhhc2hoYXNoaGFzaGhhc2hoYXNoaGFzaA",
)
put("app.admin.bootstrapTotpSecretBase32", "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP")
}
val admin = AppConfig.from(config).admin
admin.enabled shouldBe true
admin.bootstrapEnabled shouldBe true
}
test("production accepts established admin without bootstrap credentials") {
val config = validProductionConfig().apply {
put("app.admin.enabled", "true")
put("app.admin.bootstrapEnabled", "false")
}
val admin = AppConfig.from(config).admin
admin.enabled shouldBe true
admin.bootstrapEnabled shouldBe false
admin.bootstrapTotpSecretBase32 shouldBe null
}
test("admin bootstrap cannot be enabled while admin routes are disabled") {
val config = validProductionConfig().apply {
put("app.admin.enabled", "false")
put("app.admin.bootstrapEnabled", "true")
}
shouldThrow<IllegalArgumentException> {
AppConfig.from(config)
}.message.orEmpty() shouldContain "bootstrapEnabled requires"
}
test("production fails fast when Apple signing credentials are missing") {
val config = validProductionConfig().apply {
put("app.apple.keyId", "")
@@ -0,0 +1,426 @@
package com.osglab.account.features.admin
import com.osglab.account.features.admin.models.AdminAuditCursor
import com.osglab.account.features.admin.models.AdminAuditRecord
import com.osglab.account.features.admin.models.AdminLockState
import com.osglab.account.features.admin.models.AdminOperatorAuthRecord
import com.osglab.account.features.admin.models.AdminOperatorCursor
import com.osglab.account.features.admin.models.AdminOperatorMutationResult
import com.osglab.account.features.admin.models.AdminOperatorRecord
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.models.AdminSessionRecord
import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.admin.models.NewAdminOperator
import com.osglab.account.features.admin.models.NewAdminSession
import com.osglab.account.features.admin.repositories.AdminRepository
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.time.Instant
import java.util.UUID
internal class InMemoryAdminRepository(
val operatorId: UUID = UUID.randomUUID(),
var username: String = "admin@example.com",
var passwordHash: String = "valid-password-hash",
var encryptedTotpSecret: String,
var role: AdminRole = AdminRole.SUPER_ADMIN,
) : AdminRepository {
private val mutex = Mutex()
var lockState = AdminLockState(0, null)
var disabledAt: Instant? = null
var lastTotpCounter: Long? = null
val sessions = mutableMapOf<String, AdminSessionRecord>()
val revokedTokenHashes = mutableSetOf<String>()
private val revokedAtByTokenHash = mutableMapOf<String, Instant>()
val audits = mutableListOf<NewAdminAuditEvent>()
private val additionalOperators = linkedMapOf<UUID, MutableOperator>()
override suspend fun createOperatorIfAbsent(operator: NewAdminOperator): Boolean =
mutex.withLock {
if (operator.normalizedUsername == username || usernameExists(operator.normalizedUsername)) {
false
} else {
additionalOperators[operator.id] = MutableOperator(operator)
true
}
}
override suspend fun createOperator(
operator: NewAdminOperator,
auditEvent: NewAdminAuditEvent,
): AdminOperatorMutationResult = mutex.withLock {
val result = if (operator.normalizedUsername == username || usernameExists(operator.normalizedUsername)) {
AdminOperatorMutationResult.USERNAME_CONFLICT
} else {
additionalOperators[operator.id] = MutableOperator(operator)
AdminOperatorMutationResult.SUCCESS
}
audits += auditEvent.forResult(result)
result
}
override suspend fun listOperators(): List<AdminOperatorRecord> = mutex.withLock {
listOf(baseOperatorRecord()) + additionalOperators.values.map(MutableOperator::toRecord)
}
override suspend fun listOperatorsPage(
limit: Int,
before: AdminOperatorCursor?,
): List<AdminOperatorRecord> = mutex.withLock {
require(limit in 1..101)
(listOf(baseOperatorRecord()) + additionalOperators.values.map(MutableOperator::toRecord))
.asSequence()
.filter {
before == null ||
it.createdAt.isAfter(before.createdAt) ||
(it.createdAt == before.createdAt && it.id.toString() > before.id.toString())
}
.sortedWith(
compareBy<AdminOperatorRecord> { it.createdAt }
.thenBy { it.id.toString() },
)
.take(limit)
.toList()
}
override suspend fun countActiveSessions(now: Instant): Long = mutex.withLock {
sessions.count { (tokenHash, session) ->
tokenHash !in revokedTokenHashes &&
session.expiresAt.isAfter(now) &&
operatorDisabledAt(session.operatorId) == null
}.toLong()
}
override suspend fun findOperator(operatorId: UUID): AdminOperatorRecord? = mutex.withLock {
if (operatorId == this.operatorId) {
baseOperatorRecord()
} else {
additionalOperators[operatorId]?.toRecord()
}
}
override suspend fun setOperatorEnabled(
operatorId: UUID,
enabled: Boolean,
now: Instant,
auditEvent: NewAdminAuditEvent,
): AdminOperatorMutationResult = mutex.withLock {
val targetRole = when (operatorId) {
this.operatorId -> role
else -> additionalOperators[operatorId]?.operator?.role
}
val result = when {
targetRole == null -> AdminOperatorMutationResult.NOT_FOUND
!enabled &&
operatorDisabledAt(operatorId) == null &&
targetRole == AdminRole.SUPER_ADMIN &&
enabledSuperAdministrators() <= 1 ->
AdminOperatorMutationResult.LAST_SUPER_ADMIN
else -> {
if (operatorId == this.operatorId) {
disabledAt = if (enabled) null else now
} else {
additionalOperators.getValue(operatorId).apply {
disabledAt = if (enabled) null else now
updatedAt = now
}
}
if (!enabled) revokeSessionsFor(operatorId, now)
AdminOperatorMutationResult.SUCCESS
}
}
audits += auditEvent.forResult(result)
result
}
override suspend fun unlockOperator(
operatorId: UUID,
now: Instant,
auditEvent: NewAdminAuditEvent,
): AdminOperatorMutationResult = mutex.withLock {
val result = when (operatorId) {
this.operatorId -> {
lockState = AdminLockState(0, null)
AdminOperatorMutationResult.SUCCESS
}
in additionalOperators -> {
additionalOperators.getValue(operatorId).apply {
lockState = AdminLockState(0, null)
updatedAt = now
}
AdminOperatorMutationResult.SUCCESS
}
else -> AdminOperatorMutationResult.NOT_FOUND
}
audits += auditEvent.forResult(result)
result
}
override suspend fun resetOperatorCredentials(
operatorId: UUID,
passwordHash: String,
encryptedTotpSecret: String,
now: Instant,
auditEvent: NewAdminAuditEvent,
): AdminOperatorMutationResult = mutex.withLock {
val result = when (operatorId) {
this.operatorId -> {
this.passwordHash = passwordHash
this.encryptedTotpSecret = encryptedTotpSecret
lockState = AdminLockState(0, null)
lastTotpCounter = null
revokeSessionsFor(operatorId, now)
AdminOperatorMutationResult.SUCCESS
}
in additionalOperators -> {
additionalOperators.getValue(operatorId).apply {
this.passwordHash = passwordHash
this.encryptedTotpSecret = encryptedTotpSecret
lockState = AdminLockState(0, null)
lastTotpCounter = null
updatedAt = now
}
revokeSessionsFor(operatorId, now)
AdminOperatorMutationResult.SUCCESS
}
else -> AdminOperatorMutationResult.NOT_FOUND
}
audits += auditEvent.forResult(result)
result
}
override suspend fun revokeOperatorSessions(
operatorId: UUID,
now: Instant,
auditEvent: NewAdminAuditEvent,
): AdminOperatorMutationResult = mutex.withLock {
val result = if (operatorId == this.operatorId || operatorId in additionalOperators) {
revokeSessionsFor(operatorId, now)
AdminOperatorMutationResult.SUCCESS
} else {
AdminOperatorMutationResult.NOT_FOUND
}
audits += auditEvent.forResult(result)
result
}
override suspend fun findOperatorForAuthentication(
normalizedUsername: String,
): AdminOperatorAuthRecord? = mutex.withLock {
if (normalizedUsername == username) {
authRecord()
} else {
additionalOperators.values
.firstOrNull { it.operator.normalizedUsername == normalizedUsername }
?.toAuthRecord()
}
}
override suspend fun updateLockState(
operatorId: UUID,
now: Instant,
auditEvent: NewAdminAuditEvent,
transform: (AdminLockState) -> AdminLockState,
): AdminLockState? = mutex.withLock {
if (operatorId != this.operatorId || disabledAt != null) return@withLock null
transform(lockState).also {
lockState = it
audits += auditEvent
}
}
override suspend fun createSessionIfTotpCounterFresh(
session: NewAdminSession,
totpCounter: Long,
now: Instant,
auditEvent: NewAdminAuditEvent,
): AdminSessionRecord? = mutex.withLock {
if (session.operatorId != operatorId || disabledAt != null || lockState.isLockedAt(now)) {
return@withLock null
}
if (lastTotpCounter?.let { it >= totpCounter } == true) return@withLock null
lastTotpCounter = totpCounter
lockState = AdminLockState(0, null)
AdminSessionRecord(
id = session.id,
operatorId = operatorId,
normalizedUsername = username,
role = role,
csrfTokenHash = session.csrfTokenHash,
expiresAt = session.expiresAt,
).also {
sessions[session.tokenHash] = it
audits += auditEvent
}
}
override suspend fun findActiveSessionByTokenHash(
tokenHash: String,
now: Instant,
): AdminSessionRecord? = mutex.withLock {
if (disabledAt != null || tokenHash in revokedTokenHashes) return@withLock null
sessions[tokenHash]?.takeIf { it.expiresAt.isAfter(now) }
}
override suspend fun revokeSessionByTokenHash(
tokenHash: String,
now: Instant,
auditEvent: NewAdminAuditEvent,
): AdminSessionRecord? = mutex.withLock {
if (tokenHash in revokedTokenHashes) return@withLock null
sessions[tokenHash]?.also {
revokedTokenHashes += tokenHash
revokedAtByTokenHash[tokenHash] = now
audits += auditEvent
}
}
override suspend fun purgeInactiveSessions(cutoff: Instant, limit: Int): Int =
mutex.withLock {
require(limit in 1..1_000)
val candidates = sessions.entries.asSequence()
.filter { (tokenHash, session) ->
!session.expiresAt.isAfter(cutoff) ||
revokedAtByTokenHash[tokenHash]?.isAfter(cutoff) == false
}
.sortedWith(
compareBy<Map.Entry<String, AdminSessionRecord>> { it.value.expiresAt }
.thenBy { it.value.id.toString() },
)
.take(limit)
.map(Map.Entry<String, AdminSessionRecord>::key)
.toList()
candidates.forEach {
sessions.remove(it)
revokedTokenHashes.remove(it)
revokedAtByTokenHash.remove(it)
}
candidates.size
}
override suspend fun appendAudit(event: NewAdminAuditEvent) {
mutex.withLock {
audits += event
}
}
override suspend fun listAudit(
limit: Int,
before: AdminAuditCursor?,
): List<AdminAuditRecord> =
mutex.withLock {
audits.asSequence()
.filter {
before == null ||
it.occurredAt.isBefore(before.occurredAt) ||
(
it.occurredAt == before.occurredAt &&
it.id.toString() < before.id.toString()
)
}
.sortedWith(
compareByDescending<NewAdminAuditEvent> { it.occurredAt }
.thenByDescending { it.id.toString() },
)
.take(limit)
.map {
AdminAuditRecord(
id = it.id,
actorOperatorId = it.actorOperatorId,
action = it.action,
outcome = it.outcome,
targetType = it.targetType,
targetId = it.targetId,
requestId = it.requestId,
occurredAt = it.occurredAt,
)
}
.toList()
}
suspend fun seedSession(tokenHash: String, session: AdminSessionRecord) {
mutex.withLock {
sessions[tokenHash] = session
}
}
private fun authRecord() = AdminOperatorAuthRecord(
id = operatorId,
normalizedUsername = username,
passwordHash = passwordHash,
encryptedTotpSecret = encryptedTotpSecret,
role = role,
lockState = lockState,
disabledAt = disabledAt,
)
private fun baseOperatorRecord() = AdminOperatorRecord(
id = operatorId,
normalizedUsername = username,
role = role,
lockState = lockState,
disabledAt = disabledAt,
lastLoginAt = null,
createdAt = Instant.EPOCH,
updatedAt = Instant.EPOCH,
)
private fun usernameExists(candidate: String): Boolean =
additionalOperators.values.any { it.operator.normalizedUsername == candidate }
private fun enabledSuperAdministrators(): Int =
(if (role == AdminRole.SUPER_ADMIN && disabledAt == null) 1 else 0) +
additionalOperators.values.count {
it.operator.role == AdminRole.SUPER_ADMIN && it.disabledAt == null
}
private fun operatorDisabledAt(operatorId: UUID): Instant? =
if (operatorId == this.operatorId) disabledAt else additionalOperators[operatorId]?.disabledAt
private fun revokeSessionsFor(operatorId: UUID, now: Instant) {
sessions.filterValues { it.operatorId == operatorId }.keys.forEach {
revokedTokenHashes += it
revokedAtByTokenHash[it] = now
}
}
private class MutableOperator(
val operator: NewAdminOperator,
var passwordHash: String = operator.passwordHash,
var encryptedTotpSecret: String = operator.encryptedTotpSecret,
var lockState: AdminLockState = AdminLockState(0, null),
var lastTotpCounter: Long? = null,
var disabledAt: Instant? = null,
var updatedAt: Instant = operator.createdAt,
) {
fun toAuthRecord() = AdminOperatorAuthRecord(
id = operator.id,
normalizedUsername = operator.normalizedUsername,
passwordHash = passwordHash,
encryptedTotpSecret = encryptedTotpSecret,
role = operator.role,
lockState = lockState,
disabledAt = disabledAt,
)
fun toRecord() = AdminOperatorRecord(
id = operator.id,
normalizedUsername = operator.normalizedUsername,
role = operator.role,
lockState = lockState,
disabledAt = disabledAt,
lastLoginAt = null,
createdAt = operator.createdAt,
updatedAt = updatedAt,
)
}
}
private fun NewAdminAuditEvent.forResult(result: AdminOperatorMutationResult): NewAdminAuditEvent =
copy(
outcome = if (result == AdminOperatorMutationResult.SUCCESS) {
com.osglab.account.features.admin.models.AdminAuditOutcome.SUCCESS
} else {
com.osglab.account.features.admin.models.AdminAuditOutcome.DENIED
},
)
@@ -0,0 +1,246 @@
package com.osglab.account.features.admin.repositories
import com.osglab.account.config.DatabaseConfig
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminAuditOutcome
import com.osglab.account.features.admin.models.AdminOperatorMutationResult
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.admin.models.NewAdminOperator
import com.osglab.account.features.admin.models.NewAdminSession
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder
import io.kotest.matchers.shouldBe
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
import java.time.Duration
import java.time.Instant
import java.util.UUID
class AdminOperatorRepositoryIntegrationTest : FunSpec({
test("row locks preserve one enabled super administrator under concurrent disables") {
withAdminRepositories { first, second ->
val now = Instant.parse("2026-08-17T00:00:00Z")
val firstId = UUID.randomUUID()
val secondId = UUID.randomUUID()
first.createOperatorIfAbsent(
newOperator(firstId, "owner-${firstId.toString().take(8)}", AdminRole.SUPER_ADMIN, now),
)
first.createOperatorIfAbsent(
newOperator(secondId, "owner-${secondId.toString().take(8)}", AdminRole.SUPER_ADMIN, now),
)
val results = coroutineScope {
listOf(
async {
first.setOperatorEnabled(
firstId,
enabled = false,
now = now,
auditEvent = audit(secondId, AdminAuditAction.OPERATOR_DISABLED, firstId, now),
)
},
async {
second.setOperatorEnabled(
secondId,
enabled = false,
now = now,
auditEvent = audit(firstId, AdminAuditAction.OPERATOR_DISABLED, secondId, now),
)
},
).awaitAll()
}
results shouldContainExactlyInAnyOrder listOf(
AdminOperatorMutationResult.SUCCESS,
AdminOperatorMutationResult.LAST_SUPER_ADMIN,
)
first.listOperators().count {
it.role == AdminRole.SUPER_ADMIN && it.disabledAt == null
} shouldBe 1
first.listAudit(10).count {
it.action == AdminAuditAction.OPERATOR_DISABLED &&
it.outcome == AdminAuditOutcome.DENIED
} shouldBe 1
}
}
test("credential reset atomically revokes active sessions") {
withAdminRepositories { repository, _ ->
val now = Instant.parse("2026-08-17T00:00:00Z")
val ownerId = UUID.randomUUID()
val targetId = UUID.randomUUID()
val ownerUsername = "owner-${ownerId.toString().take(8)}"
val targetUsername = "target-${targetId.toString().take(8)}"
repository.createOperatorIfAbsent(
newOperator(ownerId, ownerUsername, AdminRole.SUPER_ADMIN, now),
)
repository.createOperatorIfAbsent(
newOperator(targetId, targetUsername, AdminRole.SUPPORT, now),
)
val session = NewAdminSession(
id = UUID.randomUUID(),
operatorId = targetId,
tokenHash = "a".repeat(64),
csrfTokenHash = "b".repeat(64),
createdAt = now,
expiresAt = now.plus(Duration.ofHours(1)),
)
repository.createSessionIfTotpCounterFresh(
session = session,
totpCounter = 1,
now = now,
auditEvent = audit(targetId, AdminAuditAction.LOGIN_SUCCEEDED, targetId, now),
)
(repository.findActiveSessionByTokenHash(session.tokenHash, now) != null) shouldBe true
repository.resetOperatorCredentials(
operatorId = targetId,
passwordHash = "new-password-hash",
encryptedTotpSecret = "new-encrypted-secret",
now = now.plusSeconds(1),
auditEvent = audit(
ownerId,
AdminAuditAction.OPERATOR_CREDENTIALS_RESET,
targetId,
now.plusSeconds(1),
),
) shouldBe AdminOperatorMutationResult.SUCCESS
repository.findActiveSessionByTokenHash(session.tokenHash, now.plusSeconds(1)) shouldBe null
repository.findOperatorForAuthentication(targetUsername)?.run {
passwordHash shouldBe "new-password-hash"
encryptedTotpSecret shouldBe "new-encrypted-secret"
lockState.failedLoginCount shouldBe 0
lockState.lockedUntil shouldBe null
}
}
}
test("inactive session cleanup is bounded and preserves active sessions") {
withAdminRepositories { repository, _ ->
val now = Instant.parse("2026-08-17T00:00:00Z")
val operatorId = UUID.randomUUID()
repository.createOperatorIfAbsent(
newOperator(operatorId, "cleanup-${operatorId.toString().take(8)}", AdminRole.SUPPORT, now),
)
val oldTokens = (1L..3L).map { counter ->
val tokenHash = counter.toString().repeat(64).take(64)
repository.createSessionIfTotpCounterFresh(
session = NewAdminSession(
id = UUID.randomUUID(),
operatorId = operatorId,
tokenHash = tokenHash,
csrfTokenHash = counter.plus(3).toString().repeat(64).take(64),
createdAt = now.minus(Duration.ofDays(10)),
expiresAt = now.minus(Duration.ofDays(9)),
),
totpCounter = counter,
now = now.minus(Duration.ofDays(10)),
auditEvent = audit(
operatorId,
AdminAuditAction.LOGIN_SUCCEEDED,
operatorId,
now.minus(Duration.ofDays(10)),
),
)
tokenHash
}
val activeToken = "f".repeat(64)
repository.createSessionIfTotpCounterFresh(
session = NewAdminSession(
id = UUID.randomUUID(),
operatorId = operatorId,
tokenHash = activeToken,
csrfTokenHash = "e".repeat(64),
createdAt = now,
expiresAt = now.plus(Duration.ofHours(1)),
),
totpCounter = 4,
now = now,
auditEvent = audit(operatorId, AdminAuditAction.LOGIN_SUCCEEDED, operatorId, now),
)
repository.purgeInactiveSessions(now.minus(Duration.ofDays(7)), limit = 2) shouldBe 2
repository.purgeInactiveSessions(now.minus(Duration.ofDays(7)), limit = 2) shouldBe 1
repository.purgeInactiveSessions(now.minus(Duration.ofDays(7)), limit = 2) shouldBe 0
oldTokens.forEach {
repository.findActiveSessionByTokenHash(it, now) shouldBe null
}
(repository.findActiveSessionByTokenHash(activeToken, now) != null) shouldBe true
}
}
})
private suspend fun withAdminRepositories(
block: suspend (ExposedAdminRepository, ExposedAdminRepository) -> 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) {
AdminMySqlContainer("mysql:8.4")
.withDatabaseName("osg_admin_repository_test")
.withUsername("test")
.withPassword("test")
.also(AdminMySqlContainer::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 = 4,
)
val firstFactory = DatabaseFactory(config)
val secondFactory = DatabaseFactory(config)
try {
firstFactory.database
secondFactory.database
block(ExposedAdminRepository(firstFactory), ExposedAdminRepository(secondFactory))
} finally {
secondFactory.close()
firstFactory.close()
mysql?.stop()
}
}
private fun newOperator(
id: UUID,
username: String,
role: AdminRole,
now: Instant,
) = NewAdminOperator(
id = id,
normalizedUsername = username,
passwordHash = "password-hash",
encryptedTotpSecret = "encrypted-secret",
role = role,
createdAt = now,
)
private fun audit(
actorId: UUID,
action: AdminAuditAction,
targetId: UUID,
now: Instant,
) = NewAdminAuditEvent(
actorOperatorId = actorId,
action = action,
outcome = AdminAuditOutcome.SUCCESS,
targetType = "ADMIN_OPERATOR",
targetId = targetId.toString(),
occurredAt = now,
)
private class AdminMySqlContainer(image: String) :
MySQLContainer<AdminMySqlContainer>(image)
@@ -0,0 +1,331 @@
package com.osglab.account.features.admin.routes
import com.osglab.account.config.AdminConfig
import com.osglab.account.config.AppConfig
import com.osglab.account.features.admin.grants.services.AdminGrantService
import com.osglab.account.features.admin.models.AdminPrincipal
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.services.AdminAuditService
import com.osglab.account.features.admin.services.AdminAuthService
import com.osglab.account.features.admin.services.AdminOperatorService
import com.osglab.account.features.admin.services.AdminOperatorErrorCode
import com.osglab.account.features.admin.services.AdminOperatorException
import com.osglab.account.features.admin.services.AdminSessionService
import com.osglab.account.features.admin.stats.services.AdminStatsService
import com.osglab.account.features.admin.users.services.AdminUsersService
import com.osglab.account.features.credits.domain.CreditConflict
import com.osglab.account.features.credits.domain.CreditNotFound
import com.osglab.account.features.credits.domain.InvalidCreditRequest
import io.kotest.matchers.string.shouldContain
import io.ktor.client.statement.bodyAsText
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.install
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.plugins.ratelimit.RateLimit
import io.ktor.server.plugins.ratelimit.RateLimitName
import io.ktor.server.routing.routing
import io.ktor.server.testing.testApplication
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.serialization.json.Json
import java.util.UUID
import kotlin.time.Duration.Companion.minutes
import kotlin.test.Test
import kotlin.test.assertEquals
class AdminRoutesTest {
@Test
fun `admin api is hidden without verified edge header`() = testApplication {
application { installAdminTestRoutes() }
val response = client.get("/v1/admin/auth/session")
assertEquals(HttpStatusCode.NotFound, response.status)
}
@Test
fun `verified edge can check anonymous session`() = testApplication {
application { installAdminTestRoutes() }
val response = client.get("/v1/admin/auth/session") {
header("X-OSG-mTLS-Verified", "SUCCESS")
}
assertEquals(HttpStatusCode.OK, response.status)
response.bodyAsText() shouldContain """"authenticated":false"""
}
@Test
fun `authenticated session exposes role for client-side capability navigation`() = testApplication {
application {
installAdminTestRoutes(
sessionService = sessionFixture(AdminRole.SUPER_ADMIN),
)
}
val response = client.get("/v1/admin/auth/session") {
header("X-OSG-mTLS-Verified", "SUCCESS")
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
}
assertEquals(HttpStatusCode.OK, response.status)
response.bodyAsText() shouldContain """"operatorName":"operator""""
response.bodyAsText() shouldContain """"role":"SUPER_ADMIN""""
}
@Test
fun `login rejects requests without exact same origin`() = testApplication {
val authService = mockk<AdminAuthService>(relaxed = true)
application { installAdminTestRoutes(authService) }
val response = client.post("/v1/admin/auth/login") {
header("X-OSG-mTLS-Verified", "SUCCESS")
header(HttpHeaders.Origin, "https://attacker.example")
contentType(ContentType.Application.Json)
setBody("""{"username":"owner","password":"not-a-real-password","totpCode":"123456"}""")
}
assertEquals(HttpStatusCode.Forbidden, response.status)
coVerify(exactly = 0) { authService.login(any(), any(), any(), any()) }
}
@Test
fun `admin web resources are embedded`() = testApplication {
application {
routing { adminWebRoutes() }
}
val response = client.get("/admin/")
assertEquals(HttpStatusCode.OK, response.status)
response.bodyAsText() shouldContain "OSG 运营后台"
response.bodyAsText() shouldContain """/admin/assets/"""
}
@Test
fun `manual grant maps missing user to stable admin error`() = testApplication {
val fixture = grantRouteFixture(CreditNotFound("missing"))
application {
installAdminTestRoutes(
sessionService = fixture.first,
grantService = fixture.second,
)
}
val response = client.postGrant()
assertEquals(HttpStatusCode.NotFound, response.status)
response.bodyAsText() shouldContain """"code":"USER_NOT_FOUND""""
}
@Test
fun `manual grant maps idempotency conflict to conflict`() = testApplication {
val fixture = grantRouteFixture(CreditConflict("conflict"))
application {
installAdminTestRoutes(
sessionService = fixture.first,
grantService = fixture.second,
)
}
val response = client.postGrant()
assertEquals(HttpStatusCode.Conflict, response.status)
response.bodyAsText() shouldContain """"code":"IDEMPOTENCY_CONFLICT""""
}
@Test
fun `manual grant maps invalid domain request to validation error`() = testApplication {
val fixture = grantRouteFixture(InvalidCreditRequest("invalid"))
application {
installAdminTestRoutes(
sessionService = fixture.first,
grantService = fixture.second,
)
}
val response = client.postGrant()
assertEquals(HttpStatusCode.BadRequest, response.status)
response.bodyAsText() shouldContain """"code":"VALIDATION_ERROR""""
}
@Test
fun `operator list maps non-super authorization to stable forbidden response`() = testApplication {
val sessionService = sessionFixture(AdminRole.SUPPORT)
val operatorService = mockk<AdminOperatorService>()
coEvery { operatorService.listPage(any(), any(), any()) } throws
AdminOperatorException(AdminOperatorErrorCode.INSUFFICIENT_PERMISSION)
application {
installAdminTestRoutes(
sessionService = sessionService,
operatorService = operatorService,
)
}
val response = client.get("/v1/admin/operators") {
header("X-OSG-mTLS-Verified", "SUCCESS")
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
}
assertEquals(HttpStatusCode.Forbidden, response.status)
response.bodyAsText() shouldContain """"code":"INSUFFICIENT_PERMISSION""""
}
@Test
fun `analyst cannot access user records`() = testApplication {
application {
installAdminTestRoutes(
sessionService = sessionFixture(AdminRole.ANALYST),
)
}
val response = client.get("/v1/admin/users") {
header("X-OSG-mTLS-Verified", "SUCCESS")
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
}
assertEquals(HttpStatusCode.Forbidden, response.status)
response.bodyAsText() shouldContain """"code":"INSUFFICIENT_PERMISSION""""
}
@Test
fun `operator creation maps normalized username conflict to 409`() = testApplication {
val sessionService = sessionFixture(AdminRole.SUPER_ADMIN)
val operatorService = mockk<AdminOperatorService>()
coEvery {
operatorService.create(any(), any(), any(), any(), any())
} throws AdminOperatorException(AdminOperatorErrorCode.ADMIN_USERNAME_CONFLICT)
application {
installAdminTestRoutes(
sessionService = sessionService,
operatorService = operatorService,
)
}
val response = client.post("/v1/admin/operators") {
header("X-OSG-mTLS-Verified", "SUCCESS")
header(HttpHeaders.Origin, "https://account.osglab.com")
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
header("X-CSRF-Token", "csrf-token")
contentType(ContentType.Application.Json)
setBody(
"""{"username":"owner","password":"long-enough-password","role":"SUPPORT"}""",
)
}
assertEquals(HttpStatusCode.Conflict, response.status)
response.bodyAsText() shouldContain """"code":"ADMIN_USERNAME_CONFLICT""""
}
@Test
fun `operator creation maps malformed body to stable validation error`() = testApplication {
application {
installAdminTestRoutes(
sessionService = sessionFixture(AdminRole.SUPER_ADMIN),
)
}
val response = client.post("/v1/admin/operators") {
header("X-OSG-mTLS-Verified", "SUCCESS")
header(HttpHeaders.Origin, "https://account.osglab.com")
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
header("X-CSRF-Token", "csrf-token")
contentType(ContentType.Application.Json)
setBody("""{"username":"owner","password":"long-enough-password"}""")
}
assertEquals(HttpStatusCode.BadRequest, response.status)
response.bodyAsText() shouldContain """"code":"VALIDATION_ERROR""""
}
}
private fun io.ktor.server.application.Application.installAdminTestRoutes(
authService: AdminAuthService = mockk(relaxed = true),
sessionService: AdminSessionService = mockk(relaxed = true),
grantService: AdminGrantService = mockk(relaxed = true),
operatorService: AdminOperatorService = mockk(relaxed = true),
auditService: AdminAuditService = mockk(relaxed = true),
) {
install(ContentNegotiation) {
json(Json { explicitNulls = false })
}
install(RateLimit) {
register(RateLimitName("admin-auth")) {
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()
}
routing {
adminApiRoutes(
config = config,
authService = authService,
sessionService = sessionService,
statsService = mockk<AdminStatsService>(relaxed = true),
usersService = mockk<AdminUsersService>(relaxed = true),
grantService = grantService,
operatorService = operatorService,
auditService = auditService,
)
}
}
private fun grantRouteFixture(
failure: RuntimeException,
): Pair<AdminSessionService, AdminGrantService> {
val sessionService = mockk<AdminSessionService>()
val grantService = mockk<AdminGrantService>()
coEvery {
sessionService.authenticateMutation("session-token", "csrf-token")
} returns AdminPrincipal(
operatorId = UUID.fromString("5d98fe09-da98-45b4-9466-f3f779dc1a4b"),
sessionId = UUID.fromString("c0271d1b-e5c8-4ca4-8409-1ab7ed148a34"),
normalizedUsername = "owner",
role = AdminRole.SUPER_ADMIN,
)
coEvery { grantService.grant(any()) } throws failure
return sessionService to grantService
}
private fun sessionFixture(role: AdminRole): AdminSessionService =
mockk<AdminSessionService>().also {
coEvery { it.authenticate("session-token") } returns AdminPrincipal(
operatorId = UUID.fromString("5d98fe09-da98-45b4-9466-f3f779dc1a4b"),
sessionId = UUID.fromString("c0271d1b-e5c8-4ca4-8409-1ab7ed148a34"),
normalizedUsername = "operator",
role = role,
)
coEvery { it.authenticateMutation("session-token", "csrf-token") } returns AdminPrincipal(
operatorId = UUID.fromString("5d98fe09-da98-45b4-9466-f3f779dc1a4b"),
sessionId = UUID.fromString("c0271d1b-e5c8-4ca4-8409-1ab7ed148a34"),
normalizedUsername = "operator",
role = role,
)
}
private suspend fun io.ktor.client.HttpClient.postGrant() =
post("/v1/admin/credits/grants") {
header("X-OSG-mTLS-Verified", "SUCCESS")
header(HttpHeaders.Origin, "https://account.osglab.com")
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
header("X-CSRF-Token", "csrf-token")
header("Idempotency-Key", "grant-request-1")
contentType(ContentType.Application.Json)
setBody(
"""{"userId":"5a33af2f-a878-43c0-8315-31729402b7cd","amount":100,"reason":"support credit"}""",
)
}
@@ -0,0 +1,37 @@
package com.osglab.account.features.admin.security
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldStartWith
class AdminPasswordHasherTest : FunSpec({
val hasher = BouncyCastleArgon2idPasswordHasher(
Argon2idConfig(memoryKb = 1_024, iterations = 2, parallelism = 1),
)
test("Argon2id hashes verify the correct password and use unique salts") {
val password = "correct horse battery staple".toCharArray()
val first = hasher.hash(password)
val second = hasher.hash(password)
first.shouldStartWith("\$argon2id\$v=19\$")
(first != second) shouldBe true
hasher.verify(password, first) shouldBe true
hasher.verify("wrong password".toCharArray(), first) shouldBe false
}
test("verification rejects malformed and excessive work factors") {
hasher.verify("password".toCharArray(), "not-a-phc-hash") shouldBe false
hasher.verify(
"password".toCharArray(),
"\$argon2id\$v=19\$m=999999999,t=3,p=1\$MTIzNDU2Nzg5MDEyMzQ1Ng\$" +
"MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY",
) shouldBe false
}
test("hashing rejects empty and oversized passwords") {
shouldThrow<IllegalArgumentException> { hasher.hash(charArrayOf()) }
shouldThrow<IllegalArgumentException> { hasher.hash(CharArray(1_025) { 'a' }) }
}
})
@@ -0,0 +1,54 @@
package com.osglab.account.features.admin.security
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldNotContain
import java.time.Instant
class AdminTotpTest : FunSpec({
val secret = "12345678901234567890".toByteArray()
test("TOTP generation matches RFC 6238 SHA-1 vectors") {
val verifier = HmacTotpVerifier(digits = 8, allowedWindow = 0)
verifier.generate(secret, Instant.ofEpochSecond(59)) shouldBe "94287082"
verifier.generate(secret, Instant.ofEpochSecond(1_111_111_109)) shouldBe "07081804"
}
test("verification accepts only the configured adjacent time window") {
val verifier = HmacTotpVerifier(digits = 6, allowedWindow = 1)
val previousStep = Instant.ofEpochSecond(1_700_000_010)
val currentStep = previousStep.plusSeconds(30)
val code = verifier.generate(secret, previousStep)
verifier.verify(secret, code, currentStep) shouldBe previousStep.epochSecond / 30
verifier.verify(secret, code, currentStep.plusSeconds(30)) shouldBe null
}
test("verification rejects malformed codes and secrets") {
val verifier = HmacTotpVerifier()
verifier.verify(secret, "12345x", Instant.EPOCH.plusSeconds(60)) shouldBe null
verifier.verify(ByteArray(15), "123456", Instant.EPOCH.plusSeconds(60)) shouldBe null
}
test("Base32 decoder accepts canonical secrets and rejects trailing bits") {
Base32TotpSecret.decode("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ")
.contentEquals(secret) shouldBe true
shouldThrow<IllegalArgumentException> {
Base32TotpSecret.decode("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQB")
}
}
test("provisioning generator returns a 160-bit secret without exposing it in logs") {
val provisioning = SecureAdminTotpSecretGenerator().generate("support.agent")
provisioning.secretBase32.length shouldBe 32
Base32TotpSecret.decode(provisioning.secretBase32).size shouldBe 20
provisioning.otpauthUri shouldContain "otpauth://totp/OSGKeyboard%3Asupport.agent"
provisioning.otpauthUri shouldContain "secret=${provisioning.secretBase32}"
provisioning.toString() shouldNotContain provisioning.secretBase32
}
})
@@ -0,0 +1,87 @@
package com.osglab.account.features.admin.services
import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminAuditOutcome
import com.osglab.account.features.admin.models.AdminAuditRecord
import com.osglab.account.features.admin.models.AdminPrincipal
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.repositories.AdminRepository
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import java.time.Instant
import java.util.UUID
class AdminAuditServiceTest : FunSpec({
test("page cursor continues after the final visible audit record") {
val repository = mockk<AdminRepository>()
val service = AdminAuditService(repository)
val records = listOf(
auditRecord("2026-08-17T00:00:03Z"),
auditRecord("2026-08-17T00:00:02Z"),
auditRecord("2026-08-17T00:00:01Z"),
)
coEvery { repository.listAudit(3, null) } returns records
coEvery { repository.listOperators() } returns emptyList()
val firstPage = service.list(superAdministrator(), null, limit = 2)
firstPage.items.size shouldBe 2
firstPage.nextCursor.isNullOrBlank() shouldBe false
coEvery { repository.listAudit(3, any()) } returns emptyList()
service.list(superAdministrator(), firstPage.nextCursor, limit = 2)
coVerify {
repository.listAudit(
3,
match {
it.occurredAt == records[1].occurredAt &&
it.id == records[1].id
},
)
}
}
test("malformed cursor is rejected before querying audit records") {
val repository = mockk<AdminRepository>()
val service = AdminAuditService(repository)
shouldThrow<AdminAuditCursorException> {
service.list(superAdministrator(), "not-a-valid-cursor")
}
coVerify(exactly = 0) { repository.listAudit(any(), any()) }
}
test("non-super administrator cannot list audit records") {
val repository = mockk<AdminRepository>()
val service = AdminAuditService(repository)
val support = superAdministrator().copy(role = AdminRole.SUPPORT)
shouldThrow<AdminOperatorException> {
service.list(support, null)
}.code shouldBe AdminOperatorErrorCode.INSUFFICIENT_PERMISSION
}
})
private fun auditRecord(occurredAt: String) = AdminAuditRecord(
id = UUID.randomUUID(),
actorOperatorId = UUID.randomUUID(),
action = AdminAuditAction.LOGIN_SUCCEEDED,
outcome = AdminAuditOutcome.SUCCESS,
targetType = "ADMIN_OPERATOR",
targetId = "target",
requestId = "request-1",
occurredAt = Instant.parse(occurredAt),
)
private fun superAdministrator() = AdminPrincipal(
operatorId = UUID.randomUUID(),
sessionId = UUID.randomUUID(),
normalizedUsername = "owner",
role = AdminRole.SUPER_ADMIN,
)
@@ -0,0 +1,226 @@
package com.osglab.account.features.admin.services
import com.osglab.account.common.security.FieldEncryptor
import com.osglab.account.common.security.SecureTokenGenerator
import com.osglab.account.common.security.TokenHash
import com.osglab.account.features.admin.InMemoryAdminRepository
import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminAuditOutcome
import com.osglab.account.features.admin.models.AdminLockState
import com.osglab.account.features.admin.models.AdminLoginResult
import com.osglab.account.features.admin.security.AdminPasswordHasher
import com.osglab.account.features.admin.security.HmacTotpVerifier
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
import java.util.UUID
import java.util.concurrent.atomic.AtomicInteger
class AdminAuthServiceTest : FunSpec({
test("successful login persists only token hashes and records an audit event") {
val fixture = authFixture()
val code = fixture.totp.generate(TOTP_SECRET_BYTES, fixture.clock.instant())
val result = fixture.service.login(
username = " ADMIN@EXAMPLE.COM ",
password = CORRECT_PASSWORD.toCharArray(),
totpCode = code,
requestId = "request-1",
) as AdminLoginResult.Authenticated
val sessionHash = TokenHash.sha256(result.credentials.sessionToken)
fixture.repository.sessions.containsKey(sessionHash) shouldBe true
fixture.repository.sessions.containsKey(result.credentials.sessionToken) shouldBe false
TokenHash.matches(
result.credentials.csrfToken,
fixture.repository.sessions.getValue(sessionHash).csrfTokenHash,
) shouldBe true
fixture.repository.audits.single().action shouldBe AdminAuditAction.LOGIN_SUCCEEDED
fixture.repository.audits.single().outcome shouldBe AdminAuditOutcome.SUCCESS
result.credentials.toString().contains(result.credentials.sessionToken) shouldBe false
}
test("same TOTP counter is accepted once under concurrent login") {
val fixture = authFixture()
val code = fixture.totp.generate(TOTP_SECRET_BYTES, fixture.clock.instant())
val results = coroutineScope {
List(2) {
async {
fixture.service.login(
username = "admin@example.com",
password = CORRECT_PASSWORD.toCharArray(),
totpCode = code,
)
}
}.awaitAll()
}
results.count { it is AdminLoginResult.Authenticated } shouldBe 1
results.count { it is AdminLoginResult.InvalidCredentials } shouldBe 1
fixture.repository.sessions.size shouldBe 1
}
test("failed credentials lock at the threshold") {
val fixture = authFixture(
lockPolicy = AdminLoginLockPolicy(
maxFailedAttempts = 3,
lockDuration = Duration.ofMinutes(10),
),
)
repeat(2) {
fixture.service.login(
"admin@example.com",
"wrong".toCharArray(),
"000000",
) shouldBe AdminLoginResult.InvalidCredentials
}
val third = fixture.service.login(
"admin@example.com",
"wrong".toCharArray(),
"000000",
)
third shouldBe AdminLoginResult.Locked(fixture.clock.instant().plusSeconds(600))
fixture.repository.lockState.failedLoginCount shouldBe 0
}
test("lock expiry boundary starts a fresh failure sequence") {
val policy = AdminLoginLockPolicy(
maxFailedAttempts = 3,
lockDuration = Duration.ofMinutes(10),
)
val lockEndsAt = Instant.parse("2026-08-16T01:00:00Z")
val next = policy.afterFailure(
AdminLockState(failedLoginCount = 0, lockedUntil = lockEndsAt),
lockEndsAt,
)
next shouldBe AdminLockState(failedLoginCount = 1, lockedUntil = null)
}
test("unknown users execute dummy password verification") {
val fixture = authFixture()
var verifiedHash: String? = null
val service = fixture.serviceWithHasher(
object : AdminPasswordHasher {
override fun hash(password: CharArray): String = error("Not used")
override fun verify(password: CharArray, encodedHash: String): Boolean {
verifiedHash = encodedHash
return false
}
},
)
service.login("unknown@example.com", "guess".toCharArray(), "000000") shouldBe
AdminLoginResult.InvalidCredentials
verifiedHash shouldBe DUMMY_HASH
}
test("invalid external request ID is omitted instead of failing login") {
val fixture = authFixture()
val code = fixture.totp.generate(TOTP_SECRET_BYTES, fixture.clock.instant())
val result = fixture.service.login(
username = "admin@example.com",
password = CORRECT_PASSWORD.toCharArray(),
totpCode = code,
requestId = "invalid request id with spaces",
)
(result is AdminLoginResult.Authenticated) shouldBe true
fixture.repository.audits.single().requestId shouldBe null
}
})
private data class AuthFixture(
val repository: InMemoryAdminRepository,
val service: AdminAuthService,
val totp: HmacTotpVerifier,
val encryptor: FieldEncryptor,
val clock: MutableClock,
val lockPolicy: AdminLoginLockPolicy,
val tokenGenerator: SecureTokenGenerator,
) {
fun serviceWithHasher(hasher: AdminPasswordHasher) = AdminAuthService(
repository = repository,
passwordHasher = hasher,
dummyPasswordHash = DUMMY_HASH,
totpVerifier = totp,
fieldEncryptor = encryptor,
sessionTtl = Duration.ofHours(1),
lockPolicy = lockPolicy,
tokenGenerator = tokenGenerator,
clock = clock,
)
}
private fun authFixture(
lockPolicy: AdminLoginLockPolicy = AdminLoginLockPolicy(),
): AuthFixture {
val operatorId = UUID.randomUUID()
val encryptor = FieldEncryptor(ByteArray(32) { 7 })
val encryptedSecret = encryptor.encrypt(TOTP_SECRET_BASE32, adminTotpContext(operatorId))
val repository = InMemoryAdminRepository(
operatorId = operatorId,
encryptedTotpSecret = encryptedSecret,
)
val totp = HmacTotpVerifier()
val clock = MutableClock(Instant.parse("2026-08-16T00:00:10Z"))
val tokenGenerator = CountingTokenGenerator()
val fixture = AuthFixture(
repository = repository,
service = AdminAuthService(
repository = repository,
passwordHasher = TestPasswordHasher,
dummyPasswordHash = DUMMY_HASH,
totpVerifier = totp,
fieldEncryptor = encryptor,
sessionTtl = Duration.ofHours(1),
lockPolicy = lockPolicy,
tokenGenerator = tokenGenerator,
clock = clock,
),
totp = totp,
encryptor = encryptor,
clock = clock,
lockPolicy = lockPolicy,
tokenGenerator = tokenGenerator,
)
return fixture
}
private data object TestPasswordHasher : AdminPasswordHasher {
override fun hash(password: CharArray): String = error("Not used")
override fun verify(password: CharArray, encodedHash: String): Boolean =
encodedHash == "valid-password-hash" && password.concatToString() == CORRECT_PASSWORD
}
private class CountingTokenGenerator : SecureTokenGenerator {
private val counter = AtomicInteger()
override fun newRefreshToken(): String = "test-token-${counter.incrementAndGet()}"
}
private class MutableClock(
var current: Instant,
) : Clock() {
override fun getZone(): ZoneId = ZoneOffset.UTC
override fun withZone(zone: ZoneId): Clock = this
override fun instant(): Instant = current
}
private const val CORRECT_PASSWORD = "correct-password"
private const val DUMMY_HASH = "dummy-password-hash"
private const val TOTP_SECRET_BASE32 = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
private val TOTP_SECRET_BYTES = "12345678901234567890".toByteArray()
@@ -0,0 +1,54 @@
package com.osglab.account.features.admin.services
import com.osglab.account.common.security.FieldEncryptor
import com.osglab.account.features.admin.models.NewAdminOperator
import com.osglab.account.features.admin.repositories.AdminRepository
import io.kotest.matchers.shouldBe
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import io.mockk.slot
import kotlinx.coroutines.runBlocking
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.util.UUID
import kotlin.test.Test
class AdminBootstrapServiceTest {
@Test
fun `bootstrap encrypts TOTP secret and creates owner idempotently`() = runBlocking {
val repository = mockk<AdminRepository>()
val operator = slot<NewAdminOperator>()
coEvery { repository.createOperatorIfAbsent(capture(operator)) } returns true
val encryptor = FieldEncryptor(ByteArray(32) { 9 })
val service = AdminBootstrapService(
repository,
encryptor,
Clock.fixed(Instant.parse("2026-08-16T00:00:00Z"), ZoneOffset.UTC),
)
val operatorId = UUID.fromString("2c031def-4517-4fde-b592-5db3a3eefdf6")
service.initialize(
AdminBootstrapConfig(
enabled = true,
operatorId = operatorId,
username = "Owner",
passwordHash = VALID_PASSWORD_HASH,
totpSecretBase32 = TOTP_SECRET,
),
) shouldBe true
operator.captured.normalizedUsername shouldBe "owner"
operator.captured.passwordHash shouldBe VALID_PASSWORD_HASH
encryptor.decrypt(
operator.captured.encryptedTotpSecret,
adminTotpContext(operatorId),
) shouldBe TOTP_SECRET
coVerify(exactly = 1) { repository.createOperatorIfAbsent(any()) }
}
}
private const val TOTP_SECRET = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP"
private const val VALID_PASSWORD_HASH =
"\$argon2id\$v=19\$m=65536,t=3,p=1\$c2FsdA\$aGFzaA"
@@ -0,0 +1,284 @@
package com.osglab.account.features.admin.services
import com.osglab.account.common.security.FieldEncryptor
import com.osglab.account.features.admin.InMemoryAdminRepository
import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminAuditOutcome
import com.osglab.account.features.admin.models.AdminLockState
import com.osglab.account.features.admin.models.AdminOperatorRecord
import com.osglab.account.features.admin.models.AdminPrincipal
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.models.AdminSessionRecord
import com.osglab.account.features.admin.models.NewAdminOperator
import com.osglab.account.features.admin.security.AdminPasswordHasher
import com.osglab.account.features.admin.security.AdminTotpProvisioning
import com.osglab.account.features.admin.security.AdminTotpSecretGenerator
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldNotContain
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.time.ZoneOffset
import java.util.UUID
class AdminOperatorServiceTest : FunSpec({
test("super administrator creates normalized operator with one-time encrypted TOTP") {
val fixture = operatorFixture()
val password = "long-enough-password".toCharArray()
val created = fixture.service.create(
fixture.owner,
" Support.Agent ",
password,
"SUPPORT",
"operator-create-1",
)
created.operator?.normalizedUsername shouldBe "support.agent"
created.operator?.role shouldBe AdminRole.SUPPORT
created.totpSecret shouldBe TOTP_SECRET
created.toString() shouldNotContain TOTP_SECRET
password.all { it == '\u0000' } shouldBe true
val stored = fixture.repository.findOperatorForAuthentication("support.agent")
fixture.encryptor.decrypt(
requireNotNull(stored).encryptedTotpSecret,
adminTotpContext(stored.id),
) shouldBe TOTP_SECRET
fixture.repository.audits.last().run {
action shouldBe AdminAuditAction.OPERATOR_CREATED
outcome shouldBe AdminAuditOutcome.SUCCESS
}
}
test("duplicate normalized username returns stable conflict and denied audit") {
val fixture = operatorFixture()
fixture.service.create(
fixture.owner,
"duplicate",
"long-enough-password".toCharArray(),
"ANALYST",
)
shouldThrow<AdminOperatorException> {
fixture.service.create(
fixture.owner,
" DUPLICATE ",
"another-long-password".toCharArray(),
"SUPPORT",
)
}.code shouldBe AdminOperatorErrorCode.ADMIN_USERNAME_CONFLICT
fixture.repository.audits.last().outcome shouldBe AdminAuditOutcome.DENIED
}
test("self disable is rejected and audited") {
val fixture = operatorFixture()
shouldThrow<AdminOperatorException> {
fixture.service.setEnabled(fixture.owner, fixture.owner.operatorId, enabled = false)
}.code shouldBe AdminOperatorErrorCode.CANNOT_DISABLE_SELF
fixture.repository.disabledAt shouldBe null
fixture.repository.audits.single().run {
action shouldBe AdminAuditAction.OPERATOR_DISABLED
outcome shouldBe AdminAuditOutcome.DENIED
}
}
test("repository rejects disabling the final enabled super administrator") {
val fixture = operatorFixture()
val secondSuper = fixture.seedOperator("second-owner", AdminRole.SUPER_ADMIN)
fixture.service.setEnabled(fixture.owner, secondSuper, enabled = false)
val disabledPrincipal = fixture.owner.copy(operatorId = secondSuper)
shouldThrow<AdminOperatorException> {
fixture.service.setEnabled(disabledPrincipal, fixture.owner.operatorId, enabled = false)
}.code shouldBe AdminOperatorErrorCode.LAST_SUPER_ADMIN_REQUIRED
fixture.repository.disabledAt shouldBe null
fixture.repository.audits.last().outcome shouldBe AdminAuditOutcome.DENIED
}
test("credential reset clears lock state and revokes every target session") {
val fixture = operatorFixture()
val targetId = fixture.seedOperator("reset-target", AdminRole.SUPPORT)
val session = AdminSessionRecord(
id = UUID.randomUUID(),
operatorId = targetId,
normalizedUsername = "reset-target",
role = AdminRole.SUPPORT,
csrfTokenHash = "csrf-hash",
expiresAt = fixture.clock.instant().plus(Duration.ofHours(1)),
)
fixture.repository.seedSession("target-session-hash", session)
val password = "replacement-password".toCharArray()
val credentials = fixture.service.resetCredentials(
fixture.owner,
targetId,
password,
"credentials-reset-1",
)
credentials.totpSecret shouldBe TOTP_SECRET
password.all { it == '\u0000' } shouldBe true
fixture.repository.revokedTokenHashes shouldBe setOf("target-session-hash")
fixture.repository.findOperatorForAuthentication("reset-target")?.lockState shouldBe
AdminLockState(0, null)
fixture.repository.audits.last().run {
action shouldBe AdminAuditAction.OPERATOR_CREDENTIALS_RESET
outcome shouldBe AdminAuditOutcome.SUCCESS
}
}
test("security summary counts enabled operators and active sessions") {
val fixture = operatorFixture()
val targetId = fixture.seedOperator("support-summary", AdminRole.SUPPORT)
fixture.repository.seedSession(
"summary-session-hash",
AdminSessionRecord(
id = UUID.randomUUID(),
operatorId = targetId,
normalizedUsername = "support-summary",
role = AdminRole.SUPPORT,
csrfTokenHash = "csrf-hash",
expiresAt = fixture.clock.instant().plus(Duration.ofHours(1)),
),
)
val summary = fixture.service.summary(fixture.owner)
summary.enabledOperators shouldBe 2
summary.lockedOperators shouldBe 0
summary.activeSessions shouldBe 1
}
test("non-super administrator cannot mutate operators and denial is audited") {
val fixture = operatorFixture()
val support = fixture.owner.copy(role = AdminRole.SUPPORT)
shouldThrow<AdminOperatorException> {
fixture.service.unlock(support, fixture.owner.operatorId)
}.code shouldBe AdminOperatorErrorCode.INSUFFICIENT_PERMISSION
fixture.repository.audits.single().outcome shouldBe AdminAuditOutcome.DENIED
}
test("username role and minimum password boundaries are validated") {
val fixture = operatorFixture()
val invalidRequests = listOf(
Triple("ab", "long-enough-password", "SUPPORT"),
Triple("valid-name", "elevenchars", "SUPPORT"),
Triple("valid-name", "long-enough-password", "OWNER"),
)
invalidRequests.forEach { (username, password, role) ->
shouldThrow<AdminOperatorException> {
fixture.service.create(
fixture.owner,
username,
password.toCharArray(),
role,
)
}.code shouldBe AdminOperatorErrorCode.VALIDATION_ERROR
}
fixture.repository.listOperators() shouldHaveSize 1
fixture.repository.audits.all { it.outcome == AdminAuditOutcome.DENIED } shouldBe true
}
test("operator cursor pagination is stable across equal timestamps") {
val fixture = operatorFixture()
fixture.seedOperator("operator-a", AdminRole.SUPPORT)
fixture.seedOperator("operator-b", AdminRole.ANALYST)
fixture.seedOperator("operator-c", AdminRole.SUPPORT)
val expected = fixture.repository.listOperators()
.sortedWith(compareBy<AdminOperatorRecord> { it.createdAt }.thenBy { it.id.toString() })
val first = fixture.service.listPage(fixture.owner, cursor = null, limit = 2)
val second = fixture.service.listPage(
fixture.owner,
cursor = requireNotNull(first.nextCursor),
limit = 2,
)
(first.items + second.items).map { it.id } shouldContainExactly expected.map { it.id }
second.nextCursor shouldBe null
}
test("invalid operator cursor returns a stable cursor error") {
val fixture = operatorFixture()
shouldThrow<AdminOperatorCursorException> {
fixture.service.listPage(fixture.owner, cursor = "not-a-cursor")
}
}
})
private data class OperatorFixture(
val repository: InMemoryAdminRepository,
val service: AdminOperatorService,
val owner: AdminPrincipal,
val encryptor: FieldEncryptor,
val clock: Clock,
) {
suspend fun seedOperator(username: String, role: AdminRole): UUID {
val id = UUID.randomUUID()
repository.createOperatorIfAbsent(
NewAdminOperator(
id = id,
normalizedUsername = username,
passwordHash = "seed-hash",
encryptedTotpSecret = "seed-encrypted-secret",
role = role,
createdAt = clock.instant(),
),
)
return id
}
}
private fun operatorFixture(): OperatorFixture {
val ownerId = UUID.randomUUID()
val encryptor = FieldEncryptor(ByteArray(32) { 4 })
val repository = InMemoryAdminRepository(
operatorId = ownerId,
encryptedTotpSecret = "owner-encrypted-secret",
)
val clock = Clock.fixed(Instant.parse("2026-08-17T00:00:00Z"), ZoneOffset.UTC)
val generator = AdminTotpSecretGenerator {
AdminTotpProvisioning(
secretBase32 = TOTP_SECRET,
otpauthUri = "otpauth://totp/OSGKeyboard:test?secret=$TOTP_SECRET",
)
}
return OperatorFixture(
repository = repository,
service = AdminOperatorService(
repository = repository,
passwordHasher = RecordingPasswordHasher,
fieldEncryptor = encryptor,
totpSecretGenerator = generator,
clock = clock,
),
owner = AdminPrincipal(
operatorId = ownerId,
sessionId = UUID.randomUUID(),
normalizedUsername = "owner",
role = AdminRole.SUPER_ADMIN,
),
encryptor = encryptor,
clock = clock,
)
}
private data object RecordingPasswordHasher : AdminPasswordHasher {
override fun hash(password: CharArray): String = "hash:${password.concatToString()}"
override fun verify(password: CharArray, encodedHash: String): Boolean = false
}
private const val TOTP_SECRET = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP"
@@ -0,0 +1,131 @@
package com.osglab.account.features.admin.services
import com.osglab.account.common.security.TokenHash
import com.osglab.account.features.admin.InMemoryAdminRepository
import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.models.AdminSessionRecord
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
import java.util.UUID
class AdminSessionServiceTest : FunSpec({
test("active session authenticates and mutation requires matching CSRF token") {
val fixture = sessionFixture()
fixture.service.authenticate(SESSION_TOKEN)?.sessionId shouldBe fixture.sessionId
fixture.service.authenticateMutation(SESSION_TOKEN, CSRF_TOKEN)?.sessionId shouldBe
fixture.sessionId
fixture.service.authenticateMutation(SESSION_TOKEN, "wrong-csrf") shouldBe null
}
test("session is expired at the exact expiry boundary") {
val fixture = sessionFixture()
fixture.clock.current = fixture.expiresAt
fixture.service.authenticate(SESSION_TOKEN) shouldBe null
}
test("revocation is idempotently denied after the first success") {
val fixture = sessionFixture()
fixture.service.revoke(SESSION_TOKEN, "wrong-csrf", "request-2") shouldBe false
fixture.service.revoke(SESSION_TOKEN, CSRF_TOKEN, "request-2") shouldBe true
fixture.service.authenticate(SESSION_TOKEN) shouldBe null
fixture.service.revoke(SESSION_TOKEN, CSRF_TOKEN, "request-2") shouldBe false
fixture.repository.audits.single().action shouldBe AdminAuditAction.SESSION_REVOKED
}
test("malformed token is rejected before repository lookup") {
val fixture = sessionFixture()
fixture.service.authenticate("") shouldBe null
fixture.service.authenticate("x".repeat(513)) shouldBe null
}
test("invalid external request ID is omitted when revoking a session") {
val fixture = sessionFixture()
fixture.service.revoke(
SESSION_TOKEN,
CSRF_TOKEN,
"invalid request id with spaces",
) shouldBe true
fixture.repository.audits.single().requestId shouldBe null
}
test("cleanup removes only sessions inactive beyond retention") {
val fixture = sessionFixture()
val oldToken = "expired-session-token"
fixture.repository.seedSession(
TokenHash.sha256(oldToken),
AdminSessionRecord(
id = UUID.randomUUID(),
operatorId = fixture.repository.operatorId,
normalizedUsername = "admin@example.com",
role = AdminRole.SUPPORT,
csrfTokenHash = TokenHash.sha256(CSRF_TOKEN),
expiresAt = fixture.clock.instant().minus(Duration.ofDays(8)),
),
)
fixture.service.cleanupInactive(Duration.ofDays(7), limit = 10) shouldBe 1
fixture.repository.sessions.size shouldBe 1
fixture.service.authenticate(SESSION_TOKEN)?.sessionId shouldBe fixture.sessionId
}
})
private data class SessionFixture(
val repository: InMemoryAdminRepository,
val service: AdminSessionService,
val clock: SessionTestClock,
val sessionId: UUID,
val expiresAt: Instant,
)
private suspend fun sessionFixture(): SessionFixture {
val operatorId = UUID.randomUUID()
val repository = InMemoryAdminRepository(
operatorId = operatorId,
encryptedTotpSecret = "not-used",
)
val now = Instant.parse("2026-08-16T00:00:00Z")
val clock = SessionTestClock(now)
val sessionId = UUID.randomUUID()
val expiresAt = now.plusSeconds(60)
repository.seedSession(
TokenHash.sha256(SESSION_TOKEN),
AdminSessionRecord(
id = sessionId,
operatorId = operatorId,
normalizedUsername = "admin@example.com",
role = AdminRole.SUPPORT,
csrfTokenHash = TokenHash.sha256(CSRF_TOKEN),
expiresAt = expiresAt,
),
)
return SessionFixture(
repository = repository,
service = AdminSessionService(repository, clock = clock),
clock = clock,
sessionId = sessionId,
expiresAt = expiresAt,
)
}
private class SessionTestClock(
var current: Instant,
) : Clock() {
override fun getZone(): ZoneId = ZoneOffset.UTC
override fun withZone(zone: ZoneId): Clock = this
override fun instant(): Instant = current
}
private const val SESSION_TOKEN = "opaque-session-token"
private const val CSRF_TOKEN = "opaque-csrf-token"
@@ -0,0 +1,71 @@
package com.osglab.account.features.admin.stats
import com.osglab.account.config.DatabaseConfig
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.admin.stats.repositories.AdminStatsRange
import com.osglab.account.features.admin.stats.repositories.ExposedAdminStatsRepository
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.longs.shouldBeExactly
import io.kotest.matchers.shouldBe
import org.opentest4j.TestAbortedException
import org.testcontainers.DockerClientFactory
import org.testcontainers.containers.MySQLContainer
import java.time.Instant
class AdminStatsRepositoryIntegrationTest : FunSpec({
test("MySQL executes every aggregate query without loading entity rows") {
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) {
StatsMySqlContainer("mysql:8.4")
.withDatabaseName("osg_admin_stats_test")
.withUsername("test")
.withPassword("test")
.also(StatsMySqlContainer::start)
} else {
null
}
val factory = DatabaseFactory(
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 = 2,
),
)
try {
factory.database
val snapshot = ExposedAdminStatsRepository(factory).load(
AdminStatsRange(
from = Instant.parse("2026-08-10T12:00:00Z"),
until = Instant.parse("2026-08-17T12:00:00Z"),
),
)
// A dedicated container starts empty, so every scalar and grouped aggregate is explicit.
if (mysql != null) {
snapshot.overview.totalUsers shouldBeExactly 0
snapshot.overview.totalCreditBalance shouldBeExactly 0
snapshot.overview.activeUsers shouldBeExactly 0
snapshot.referralFunnel.pendingBindings shouldBeExactly 0
snapshot.referralFunnel.ineligibleBindings shouldBeExactly 0
snapshot.registrationsByDate shouldBe emptyMap()
snapshot.referralRanking.shouldBeEmpty()
snapshot.usage.shouldBeEmpty()
}
} finally {
factory.close()
mysql?.stop()
}
}
})
private class StatsMySqlContainer(image: String) :
MySQLContainer<StatsMySqlContainer>(image)
@@ -0,0 +1,162 @@
package com.osglab.account.features.admin.stats
import com.osglab.account.features.admin.stats.models.AdminOverviewDto
import com.osglab.account.features.admin.stats.models.AdminReferralFunnelDto
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
import com.osglab.account.features.admin.stats.repositories.AdminStatsAggregates
import com.osglab.account.features.admin.stats.repositories.AdminStatsRange
import com.osglab.account.features.admin.stats.repositories.AdminStatsRepository
import com.osglab.account.features.admin.stats.repositories.AdminStatsSnapshot
import com.osglab.account.features.admin.stats.repositories.ReferralBindingAggregateRow
import com.osglab.account.features.admin.stats.repositories.assembleAdminStats
import com.osglab.account.features.admin.stats.repositories.toExactLong
import com.osglab.account.features.admin.stats.services.AdminStatsService
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.longs.shouldBeExactly
import io.kotest.matchers.shouldBe
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.math.BigDecimal
import java.time.Duration
import java.time.Instant
import java.time.LocalDate
class AdminStatsRepositoryTest : FunSpec({
val from = Instant.parse("2026-08-15T00:00:00Z")
val until = Instant.parse("2026-08-17T00:00:00Z")
test("aggregated rows preserve referral ranking and usage output") {
val snapshot = assembleAdminStats(
AdminStatsAggregates(
overview = AdminOverviewDto(
totalUsers = 20,
registrations = 4,
activeUsers = 3,
totalCreditBalance = 500,
issuedCredits = 130,
consumedCredits = 25,
),
registrationsByDate = mapOf(LocalDate.parse("2026-08-15") to 4),
issuedCreditsByDate = mapOf(LocalDate.parse("2026-08-15") to 130),
consumedCreditsByDate = mapOf(LocalDate.parse("2026-08-16") to 25),
referralFunnel = AdminReferralFunnelDto(
codesCreated = 5,
bindings = 7,
rewardedBindings = 4,
pendingBindings = 2,
ineligibleBindings = 1,
),
referralBindingsByInviter = listOf(
ReferralBindingAggregateRow("user-b", invitedUsers = 3, rewardedUsers = 1),
ReferralBindingAggregateRow("user-c", invitedUsers = 0, rewardedUsers = 1),
ReferralBindingAggregateRow("user-a", invitedUsers = 3, rewardedUsers = 2),
),
referralCreditsByInviter = mapOf(
"user-a" to 60,
"user-b" to 30,
"ledger-only-user" to 90,
),
usage = listOf(
AdminUsageAggregateDto(
kind = "LLM",
requests = 2,
chargedCredits = 20,
asrMillis = 0,
inputTokens = 100,
outputTokens = 50,
),
AdminUsageAggregateDto(
kind = "ASR",
requests = 1,
chargedCredits = 5,
asrMillis = 500,
inputTokens = 0,
outputTokens = 0,
),
),
),
)
snapshot.referralFunnel.pendingBindings shouldBeExactly 2
snapshot.referralFunnel.ineligibleBindings shouldBeExactly 1
snapshot.referralRanking.map { it.userId } shouldBe listOf("user-a", "user-b", "user-c")
snapshot.referralRanking.first().earnedCredits shouldBeExactly 60
snapshot.referralRanking.last().let {
it.invitedUsers shouldBeExactly 0
it.rewardedUsers shouldBeExactly 1
it.earnedCredits shouldBeExactly 0
}
snapshot.referralRanking.none { it.userId == "ledger-only-user" } shouldBe true
snapshot.usage.map { it.kind } shouldBe listOf("ASR", "LLM")
snapshot.usage.first().asrMillis shouldBeExactly 500
snapshot.usage.last().inputTokens shouldBeExactly 100
}
test("service keeps exact 7 30 and 90 day buckets and serializes new outputs") {
val snapshot = AdminStatsSnapshot(
overview = AdminOverviewDto(0, 0, 0, 0, 0, 0),
registrationsByDate = emptyMap(),
issuedCreditsByDate = emptyMap(),
consumedCreditsByDate = emptyMap(),
referralFunnel = AdminReferralFunnelDto(
codesCreated = 0,
bindings = 0,
rewardedBindings = 0,
pendingBindings = 3,
ineligibleBindings = 2,
),
referralRanking = emptyList(),
usage = listOf(
AdminUsageAggregateDto(
kind = "ASR",
requests = 4,
chargedCredits = 12,
asrMillis = 1_200,
inputTokens = 0,
outputTokens = 0,
),
),
)
val capturedRanges = mutableListOf<AdminStatsRange>()
val service = AdminStatsService(
AdminStatsRepository { range ->
capturedRanges += range
snapshot
},
)
listOf(7L, 30L, 90L).forEach { days ->
val result = service.get(until.minus(Duration.ofDays(days)), until)
result.registrationTrend shouldHaveSize days.toInt()
result.creditFlow shouldHaveSize days.toInt()
capturedRanges.last() shouldBe AdminStatsRange(
until.minus(Duration.ofDays(days)),
until,
)
}
val result = service.get(from, until)
val json = Json.encodeToJsonElement(result).jsonObject
json["referralFunnel"]!!.jsonObject["pendingBindings"]!!.jsonPrimitive.content shouldBe "3"
json["referralFunnel"]!!.jsonObject["ineligibleBindings"]!!.jsonPrimitive.content shouldBe "2"
json["usage"]!!.jsonArray.single().jsonObject["requests"]!!.jsonPrimitive.content shouldBe "4"
}
test("database decimal aggregates require an exact Long representation") {
BigDecimal.valueOf(Long.MAX_VALUE).toExactLong() shouldBeExactly Long.MAX_VALUE
BigDecimal.valueOf(Long.MIN_VALUE).toExactLong() shouldBeExactly Long.MIN_VALUE
shouldThrow<ArithmeticException> {
BigDecimal.valueOf(Long.MAX_VALUE).add(BigDecimal.ONE).toExactLong()
}
shouldThrow<ArithmeticException> {
BigDecimal("1.5").toExactLong()
}
}
})
@@ -0,0 +1,229 @@
package com.osglab.account.features.admin.users
import com.osglab.account.features.admin.users.models.AdminUserDetailDto
import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
import com.osglab.account.features.admin.users.models.AdminUserReferralDto
import com.osglab.account.features.admin.users.models.AdminUserSummaryDto
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
import com.osglab.account.features.admin.users.repositories.AdminUserCursor
import com.osglab.account.features.admin.users.repositories.AdminUserLedgerCursor
import com.osglab.account.features.admin.users.repositories.AdminUsersRepository
import com.osglab.account.features.admin.users.services.AdminUserNotFoundException
import com.osglab.account.features.admin.users.services.AdminUsersService
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import java.time.Instant
import java.util.UUID
class AdminUsersServiceTest : FunSpec({
test("detail preserves referral code usage referral and recent ledger data") {
val userId = UUID.randomUUID()
val detail = AdminUserDetailDto(
summary = summary(userId, Instant.parse("2026-08-15T00:00:00Z")),
referralCode = "OSG-REFERRAL",
usage = listOf(
AdminUsageAggregateDto(
kind = "LLM",
requests = 2,
chargedCredits = 8,
asrMillis = 0,
inputTokens = 20,
outputTokens = 10,
),
),
referral = AdminUserReferralDto(
inviterUserId = UUID.randomUUID().toString(),
invitedUsers = 3,
rewardedInvites = 2,
),
recentLedger = listOf(
ledgerEntry(
id = UUID.randomUUID(),
createdAt = Instant.parse("2026-08-15T01:00:00Z"),
),
),
)
val service = AdminUsersService(
PagingUsersRepository(
users = emptyList(),
details = mapOf(userId to detail),
),
)
service.detail(userId) shouldBe detail
}
test("ledger returns a page and continuation cursor") {
val userId = UUID.randomUUID()
val entries = listOf(
ledgerEntry(
UUID.fromString("ffffffff-ffff-ffff-ffff-ffffffffffff"),
Instant.parse("2026-08-15T03:00:00Z"),
),
ledgerEntry(
UUID.fromString("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"),
Instant.parse("2026-08-15T02:00:00Z"),
),
ledgerEntry(
UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd"),
Instant.parse("2026-08-15T01:00:00Z"),
),
)
val service = AdminUsersService(
PagingUsersRepository(emptyList(), ledger = mapOf(userId to entries)),
)
val page = service.ledger(userId, limit = 2)
page.items shouldBe entries.take(2)
page.nextCursor.shouldNotBeNull()
}
test("ledger cursor pagination is stable for equal timestamps") {
val createdAt = Instant.parse("2026-08-15T00:00:00Z")
val ids = listOf(
UUID.fromString("ffffffff-ffff-ffff-ffff-ffffffffffff"),
UUID.fromString("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"),
UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd"),
)
val userId = UUID.randomUUID()
val entries = ids.map { ledgerEntry(it, createdAt) }
val repository = PagingUsersRepository(
users = emptyList(),
ledger = mapOf(userId to entries),
)
val service = AdminUsersService(repository)
val first = service.ledger(userId, limit = 2)
val second = service.ledger(
userId,
limit = 2,
cursor = first.nextCursor.shouldNotBeNull(),
)
first.items.map { it.id } shouldBe ids.take(2).map(UUID::toString)
second.items.map { it.id } shouldBe listOf(ids.last().toString())
second.nextCursor shouldBe null
}
test("invalid ledger cursor throws a stable illegal argument exception") {
val service = AdminUsersService(PagingUsersRepository(emptyList()))
val failure = shouldThrow<IllegalArgumentException> {
service.ledger(UUID.randomUUID(), cursor = "not-a-cursor")
}
failure.message shouldBe "User ledger cursor is invalid"
}
test("ledger last page has no continuation cursor") {
val userId = UUID.randomUUID()
val entries = listOf(
ledgerEntry(UUID.randomUUID(), Instant.parse("2026-08-15T02:00:00Z")),
ledgerEntry(UUID.randomUUID(), Instant.parse("2026-08-15T01:00:00Z")),
)
val service = AdminUsersService(
PagingUsersRepository(emptyList(), ledger = mapOf(userId to entries)),
)
val page = service.ledger(userId, limit = 2)
page.items shouldHaveSize 2
page.nextCursor shouldBe null
}
test("ledger rejects a missing user instead of returning an empty page") {
val service = AdminUsersService(PagingUsersRepository(emptyList()))
shouldThrow<AdminUserNotFoundException> {
service.ledger(UUID.randomUUID())
}
}
test("invalid user cursor and missing detail fail without returning user data") {
val service = AdminUsersService(PagingUsersRepository(emptyList()))
shouldThrow<IllegalArgumentException> {
service.list(cursor = "not-a-cursor")
}
shouldThrow<AdminUserNotFoundException> {
service.detail(UUID.randomUUID())
}
}
})
private class PagingUsersRepository(
private val users: List<AdminUserSummaryDto>,
private val details: Map<UUID, AdminUserDetailDto> = emptyMap(),
private val ledger: Map<UUID, List<AdminUserLedgerEntryDto>> = emptyMap(),
) : AdminUsersRepository {
override suspend fun list(
limit: Int,
cursor: AdminUserCursor?,
): List<AdminUserSummaryDto> =
users.filter {
cursor == null ||
Instant.parse(it.createdAt) < cursor.createdAt ||
(
Instant.parse(it.createdAt) == cursor.createdAt &&
UUID.fromString(it.id).toString() < cursor.userId.toString()
)
}.take(limit)
override suspend fun findDetail(
userId: UUID,
ledgerLimit: Int,
): AdminUserDetailDto? = details[userId]
override suspend fun exists(userId: UUID): Boolean =
userId in details || userId in ledger || users.any { it.id == userId.toString() }
override suspend fun listLedger(
userId: UUID,
limit: Int,
cursor: AdminUserLedgerCursor?,
): List<AdminUserLedgerEntryDto> =
ledger[userId].orEmpty()
.filter {
val createdAt = Instant.parse(it.createdAt)
cursor == null ||
createdAt < cursor.createdAt ||
(
createdAt == cursor.createdAt &&
it.id < cursor.ledgerEntryId.toString()
)
}
.sortedWith(
compareByDescending<AdminUserLedgerEntryDto> { Instant.parse(it.createdAt) }
.thenByDescending(AdminUserLedgerEntryDto::id),
)
.take(limit)
}
private fun summary(id: UUID, createdAt: Instant) = AdminUserSummaryDto(
id = id.toString(),
createdAt = createdAt.toString(),
antiAbuseRestricted = false,
creditBalance = 0,
consumedCredits = 0,
manualGrantedCredits = 0,
usageRequests = 0,
lastActiveAt = null,
invitedUsers = 0,
rewardedInvites = 0,
)
private fun ledgerEntry(
id: UUID,
createdAt: Instant,
) = AdminUserLedgerEntryDto(
id = id.toString(),
type = "MANUAL_GRANT",
amountDelta = 10,
balanceAfter = 10,
referenceId = null,
createdAt = createdAt.toString(),
)
@@ -80,6 +80,161 @@ class CreditServiceTest : FunSpec({
store.ledger.filter { it.type == LedgerEntryType.SIGNUP_TRIAL } shouldHaveSize 1
}
test("manual grant appends one linked audit and ledger entry") {
val store = storeWithRates(now)
val service = service(store, now)
val userId = UUID.randomUUID()
store.registeredUsers += userId
val result = service.grantManual(
operatorId = TEST_OPERATOR_ID,
userId = userId,
credits = 250,
reason = "Customer support adjustment",
requestId = "support-ticket-1042",
idempotencyKey = "manual-grant-key-001",
)
result.replayed shouldBe false
result.balanceAfter shouldBeExactly 250
store.balance(userId) shouldBeExactly 250
store.manualGrants.single() shouldBe result.grant
store.adminAudits.single().id shouldBe result.grant.auditLogId
store.ledger.single { it.type == LedgerEntryType.MANUAL_GRANT }.let { ledger ->
ledger.referenceId shouldBe result.grant.id
ledger.id shouldBe result.grant.ledgerEntryId
}
}
test("manual grant replay is stable and rejects changed parameters") {
val store = storeWithRates(now)
val service = service(store, now)
val userId = UUID.randomUUID()
store.registeredUsers += userId
val first = service.grantManual(
TEST_OPERATOR_ID,
userId,
100,
"Retention credit",
"support-ticket-1043",
"manual-grant-key-002",
)
val replay = service.grantManual(
TEST_OPERATOR_ID,
userId,
100,
"Retention credit",
"support-ticket-1043",
"manual-grant-key-002",
)
replay.grant shouldBe first.grant
replay.replayed shouldBe true
shouldThrow<CreditConflict> {
service.grantManual(
TEST_OPERATOR_ID,
userId,
101,
"Retention credit",
"support-ticket-1043",
"manual-grant-key-002",
)
}
store.balance(userId) shouldBeExactly 100
store.manualGrants shouldHaveSize 1
store.ledger.filter { it.type == LedgerEntryType.MANUAL_GRANT } shouldHaveSize 1
}
test("manual grant validates positive amount and rolls back an audit failure") {
val store = storeWithRates(now)
val service = service(store, now)
val userId = UUID.randomUUID()
store.registeredUsers += userId
shouldThrow<InvalidCreditRequest> {
service.grantManual(
TEST_OPERATOR_ID,
userId,
0,
"Invalid adjustment",
"support-ticket-1044",
"manual-grant-key-003",
)
}
store.failNextManualGrantInsert = true
shouldThrow<IllegalStateException> {
service.grantManual(
TEST_OPERATOR_ID,
userId,
50,
"Rollback adjustment",
"support-ticket-1045",
"manual-grant-key-004",
)
}
store.balance(userId) shouldBeExactly 0
store.manualGrants shouldHaveSize 0
store.adminAudits shouldHaveSize 0
store.ledger.filter { it.type == LedgerEntryType.MANUAL_GRANT } shouldHaveSize 0
}
test("concurrent manual grant replay credits exactly once") {
val store = storeWithRates(now)
val service = service(store, now)
val userId = UUID.randomUUID()
store.registeredUsers += userId
val results = coroutineScope {
List(8) {
async(Dispatchers.Default) {
service.grantManual(
TEST_OPERATOR_ID,
userId,
75,
"Concurrent adjustment",
"support-ticket-1046",
"manual-grant-key-005",
)
}
}.awaitAll()
}
results.map { it.grant.id }.distinct() shouldHaveSize 1
store.balance(userId) shouldBeExactly 75
store.manualGrants shouldHaveSize 1
store.ledger.filter { it.type == LedgerEntryType.MANUAL_GRANT } shouldHaveSize 1
}
test("manual grant supports the maximum balance and rejects overflow") {
val store = storeWithRates(now)
val service = service(store, now)
val userId = UUID.randomUUID()
store.registeredUsers += userId
service.grantManual(
TEST_OPERATOR_ID,
userId,
Long.MAX_VALUE,
"Maximum supported adjustment",
"support-ticket-1047",
"manual-grant-key-006",
)
shouldThrow<InvalidCreditRequest> {
service.grantManual(
TEST_OPERATOR_ID,
userId,
1,
"Overflow adjustment",
"support-ticket-1048",
"manual-grant-key-007",
)
}
store.balance(userId) shouldBeExactly Long.MAX_VALUE
store.manualGrants shouldHaveSize 1
}
test("balance overflow rolls back without appending a ledger entry") {
val store = storeWithRates(now)
val service = service(store, now)
@@ -574,3 +729,5 @@ private fun llmRate(now: Instant) = CreditRateVersion(
outputCreditsNumerator = 3,
outputTokensDenominator = 1_000,
)
private val TEST_OPERATOR_ID = UUID.fromString("11111111-1111-1111-1111-111111111111")
@@ -1,11 +1,14 @@
package com.osglab.account.features.credits
import com.osglab.account.features.admin.grants.repositories.AdminCreditGrantRepository
import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.credits.domain.CreditAccount
import com.osglab.account.features.credits.domain.CreditNotFound
import com.osglab.account.features.credits.domain.CreditRateVersion
import com.osglab.account.features.credits.domain.CreditReservation
import com.osglab.account.features.credits.domain.CreditUsageRecord
import com.osglab.account.features.credits.domain.LedgerEntry
import com.osglab.account.features.credits.domain.ManualCreditGrant
import com.osglab.account.features.credits.domain.UsageKind
import com.osglab.account.features.credits.repositories.BillingTransactionRunner
import com.osglab.account.features.credits.repositories.BillingUnitOfWork
@@ -25,7 +28,10 @@ import kotlin.concurrent.withLock
class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
private val lock = ReentrantLock()
private val accounts = mutableMapOf<UUID, CreditAccount>()
val registeredUsers = mutableSetOf<UUID>()
val ledger = mutableListOf<LedgerEntry>()
val manualGrants = mutableListOf<ManualCreditGrant>()
val adminAudits = mutableListOf<NewAdminAuditEvent>()
val usageRecords = mutableListOf<CreditUsageRecord>()
val reservations = mutableMapOf<UUID, CreditReservation>()
val rates = mutableMapOf<UUID, CreditRateVersion>()
@@ -56,11 +62,17 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
override val credits: CreditsRepository = Credits()
override val referrals: ReferralsRepository = Referrals()
override val adminCreditGrants: AdminCreditGrantRepository = AdminCreditGrants()
var failNextManualGrantInsert = false
override suspend fun <T> inTransaction(block: (BillingUnitOfWork) -> T): T =
lock.withLock {
val accountSnapshot = accounts.toMap()
val registeredUserSnapshot = registeredUsers.toSet()
val ledgerSnapshot = ledger.toList()
val manualGrantSnapshot = manualGrants.toList()
val adminAuditSnapshot = adminAudits.toList()
val usageSnapshot = usageRecords.toList()
val reservationSnapshot = reservations.toMap()
val codeSnapshot = codes.toMap()
@@ -70,7 +82,10 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
block(this)
} catch (failure: Throwable) {
accounts.replaceWith(accountSnapshot)
registeredUsers.replaceWith(registeredUserSnapshot)
ledger.replaceWith(ledgerSnapshot)
manualGrants.replaceWith(manualGrantSnapshot)
adminAudits.replaceWith(adminAuditSnapshot)
usageRecords.replaceWith(usageSnapshot)
reservations.replaceWith(reservationSnapshot)
codes.replaceWith(codeSnapshot)
@@ -83,6 +98,9 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
fun balance(userId: UUID): Long = lock.withLock { accounts[userId]?.balance ?: 0 }
private inner class Credits : CreditsRepository {
override fun accountExists(userId: UUID): Boolean =
userId in registeredUsers || userId in accounts
override fun createAccountIfAbsent(userId: UUID, now: Instant) {
accounts.putIfAbsent(userId, CreditAccount(userId, 0, now))
}
@@ -157,6 +175,28 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
}
}
private inner class AdminCreditGrants : AdminCreditGrantRepository {
override fun findByIdempotencyKey(
idempotencyKey: String,
): ManualCreditGrant? =
manualGrants.singleOrNull { it.idempotencyKey == idempotencyKey }
override fun insertAudit(event: NewAdminAuditEvent) {
check(adminAudits.none { it.id == event.id })
adminAudits += event
}
override fun insert(grant: ManualCreditGrant) {
if (failNextManualGrantInsert) {
failNextManualGrantInsert = false
error("Simulated manual grant audit failure")
}
check(findByIdempotencyKey(grant.idempotencyKey) == null)
check(manualGrants.none { it.id == grant.id || it.ledgerEntryId == grant.ledgerEntryId })
manualGrants += grant
}
}
private inner class Referrals : ReferralsRepository {
override fun findCodeByOwner(ownerUserId: UUID, campaignId: UUID?): ReferralCode? =
codes.values
@@ -238,6 +278,11 @@ private fun <K, V> MutableMap<K, V>.replaceWith(snapshot: Map<K, V>) {
putAll(snapshot)
}
private fun <T> MutableSet<T>.replaceWith(snapshot: Set<T>) {
clear()
addAll(snapshot)
}
private fun <T> MutableList<T>.replaceWith(snapshot: List<T>) {
clear()
addAll(snapshot)
@@ -20,6 +20,7 @@ import com.osglab.account.features.referrals.services.UserRegistrationTimeProvid
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.assertions.withClue
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.ints.shouldBeExactly
import io.kotest.matchers.longs.shouldBeExactly
import io.kotest.matchers.nulls.shouldBeNull
@@ -208,8 +209,21 @@ class MySqlSecurityIntegrationTest : FunSpec({
ReferralRewardConfig(inviterCredits = 30, inviteeCredits = 30),
)
val concurrentUser = UUID.randomUUID()
val adminOperator = UUID.randomUUID()
connection().use {
insertAccount(it, concurrentUser, "concurrent-sub", identity.ofAppleSubject("concurrent-sub"))
it.createStatement().use { statement ->
statement.executeUpdate(
"""
INSERT INTO admin_operators (
id, username, password_hash, encrypted_totp_secret, role
) VALUES (
'$adminOperator', 'integration-admin', 'unused-hash',
'unused-secret', 'SUPER_ADMIN'
)
""".trimIndent(),
)
}
}
credits.grantSignupTrial(concurrentUser, 100, "integration-signup-concurrent")
val reservationResults = coroutineScope {
@@ -236,6 +250,34 @@ class MySqlSecurityIntegrationTest : FunSpec({
reservationResults.count { it.isSuccess } shouldBeExactly 1
}
credits.getAccount(concurrentUser).balance shouldBeExactly 40
val manualGrantResults = coroutineScope {
List(4) {
async(Dispatchers.Default) {
credits.grantManual(
operatorId = adminOperator,
userId = concurrentUser,
credits = 25,
reason = "Integration support adjustment",
requestId = "integration-audit-reference",
idempotencyKey = "integration-manual-grant",
)
}
}.awaitAll()
}
manualGrantResults.map { it.grant.id }.distinct() shouldHaveSize 1
credits.getAccount(concurrentUser).balance shouldBeExactly 65
connection().use { connection ->
count(
connection,
"admin_credit_grants",
"account_id = '$concurrentUser'",
) shouldBeExactly 1
count(
connection,
"credit_ledger",
"user_id = '$concurrentUser' AND entry_type = 'MANUAL_GRANT'",
) shouldBeExactly 1
}
val inviter = UUID.randomUUID()
val invitee = UUID.randomUUID()
@@ -0,0 +1,29 @@
package com.osglab.account.tools
import io.kotest.matchers.string.shouldContain
import java.nio.file.Files
import kotlin.test.Test
import kotlin.test.assertFailsWith
class AdminCredentialGeneratorTest {
@Test
fun `generator creates separate runtime and operator files without overwriting`() {
val directory = Files.createTempDirectory("admin-credentials-test")
val runtime = directory.resolve("runtime.env")
val handoff = directory.resolve("handoff.txt")
AdminCredentialGenerator.main(
arrayOf("Owner", runtime.toString(), handoff.toString()),
)
Files.readString(runtime) shouldContain "ADMIN_BOOTSTRAP_ENABLED=true"
Files.readString(runtime) shouldContain "ADMIN_BOOTSTRAP_USERNAME=owner"
Files.readString(runtime) shouldContain "ADMIN_BOOTSTRAP_PASSWORD_HASH='\$argon2id\$"
Files.readString(handoff) shouldContain "认证器 URIotpauth://totp/"
assertFailsWith<IllegalArgumentException> {
AdminCredentialGenerator.main(
arrayOf("owner", runtime.toString(), handoff.toString()),
)
}
}
}