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"
}