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:
@@ -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,
|
||||
)
|
||||
+84
@@ -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],
|
||||
)
|
||||
+32
@@ -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>,
|
||||
)
|
||||
+334
@@ -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,
|
||||
)
|
||||
+332
@@ -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)) }
|
||||
+135
@@ -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.
|
||||
|
||||
+4
@@ -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 {
|
||||
|
||||
+16
@@ -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
|
||||
认证器 URI:otpauth://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"
|
||||
}
|
||||
@@ -30,6 +30,15 @@ app:
|
||||
antiAbuse:
|
||||
identityHmacKeyBase64: "$IDENTITY_HMAC_KEY"
|
||||
tombstoneRetentionDays: "$IDENTITY_TOMBSTONE_RETENTION_DAYS:365"
|
||||
admin:
|
||||
enabled: "$ADMIN_ENABLED:false"
|
||||
bootstrapEnabled: "$ADMIN_BOOTSTRAP_ENABLED:false"
|
||||
bootstrapOperatorId: "$ADMIN_BOOTSTRAP_OPERATOR_ID:"
|
||||
bootstrapUsername: "$ADMIN_BOOTSTRAP_USERNAME:"
|
||||
bootstrapPasswordHash: "$ADMIN_BOOTSTRAP_PASSWORD_HASH:"
|
||||
bootstrapTotpSecretBase32: "$ADMIN_BOOTSTRAP_TOTP_SECRET_BASE32:"
|
||||
sessionHours: "$ADMIN_SESSION_HOURS:8"
|
||||
maximumManualGrant: "$ADMIN_MAXIMUM_MANUAL_GRANT:100000"
|
||||
apple:
|
||||
teamId: "$APPLE_TEAM_ID:"
|
||||
keyId: "$APPLE_KEY_ID:"
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
CREATE TABLE admin_operators (
|
||||
id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
username VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
|
||||
password_hash VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
encrypted_totp_secret TEXT NOT NULL,
|
||||
role VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
failed_login_count INT NOT NULL DEFAULT 0,
|
||||
locked_until DATETIME(6) NULL,
|
||||
last_totp_counter BIGINT NULL,
|
||||
last_login_at DATETIME(6) NULL,
|
||||
disabled_at DATETIME(6) NULL,
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_admin_operators_username (username),
|
||||
INDEX idx_admin_operators_status_created (disabled_at, created_at),
|
||||
CONSTRAINT chk_admin_operators_role
|
||||
CHECK (role IN ('SUPER_ADMIN', 'SUPPORT', 'ANALYST')),
|
||||
CONSTRAINT chk_admin_operators_failed_logins
|
||||
CHECK (failed_login_count >= 0),
|
||||
CONSTRAINT chk_admin_operators_totp_counter
|
||||
CHECK (last_totp_counter IS NULL OR last_totp_counter >= 0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
CREATE TABLE admin_sessions (
|
||||
id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
operator_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
token_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
csrf_token_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
expires_at DATETIME(6) NOT NULL,
|
||||
revoked_at DATETIME(6) NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_admin_sessions_token_hash (token_hash),
|
||||
INDEX idx_admin_sessions_operator_active (operator_id, revoked_at, expires_at),
|
||||
INDEX idx_admin_sessions_expiry (expires_at),
|
||||
CONSTRAINT fk_admin_sessions_operator
|
||||
FOREIGN KEY (operator_id) REFERENCES admin_operators (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT chk_admin_sessions_expiry
|
||||
CHECK (expires_at > created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
CREATE TABLE admin_audit_log (
|
||||
id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
actor_operator_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,
|
||||
action VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
outcome VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
target_type VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NULL,
|
||||
target_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL,
|
||||
request_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NULL,
|
||||
occurred_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
PRIMARY KEY (id),
|
||||
INDEX idx_admin_audit_occurred (occurred_at, id),
|
||||
INDEX idx_admin_audit_action_occurred (action, occurred_at),
|
||||
INDEX idx_admin_audit_actor_occurred (actor_operator_id, occurred_at),
|
||||
INDEX idx_admin_audit_target (target_type, target_id, occurred_at),
|
||||
CONSTRAINT fk_admin_audit_actor
|
||||
FOREIGN KEY (actor_operator_id) REFERENCES admin_operators (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT chk_admin_audit_target_pair CHECK (
|
||||
(target_type IS NULL AND target_id IS NULL)
|
||||
OR (target_type IS NOT NULL AND target_id IS NOT NULL)
|
||||
)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
CREATE TABLE admin_credit_grants (
|
||||
id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
operator_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
account_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
amount BIGINT NOT NULL,
|
||||
reason VARCHAR(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||
idempotency_key VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||
ledger_entry_id CHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
|
||||
audit_log_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_admin_credit_grants_idempotency (idempotency_key),
|
||||
UNIQUE KEY uk_admin_credit_grants_ledger (ledger_entry_id),
|
||||
UNIQUE KEY uk_admin_credit_grants_audit (audit_log_id),
|
||||
INDEX idx_admin_credit_grants_account_created (account_id, created_at),
|
||||
INDEX idx_admin_credit_grants_operator_created (operator_id, created_at),
|
||||
INDEX idx_admin_credit_grants_created (created_at),
|
||||
CONSTRAINT fk_admin_credit_grants_operator
|
||||
FOREIGN KEY (operator_id) REFERENCES admin_operators (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_admin_credit_grants_ledger
|
||||
FOREIGN KEY (ledger_entry_id) REFERENCES credit_ledger (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_admin_credit_grants_audit
|
||||
FOREIGN KEY (audit_log_id) REFERENCES admin_audit_log (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT chk_admin_credit_grants_amount CHECK (amount > 0),
|
||||
CONSTRAINT chk_admin_credit_grants_reason
|
||||
CHECK (CHAR_LENGTH(TRIM(reason)) BETWEEN 1 AND 500),
|
||||
CONSTRAINT chk_admin_credit_grants_idempotency
|
||||
CHECK (CHAR_LENGTH(idempotency_key) BETWEEN 8 AND 128)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
-- account_id intentionally has no foreign key: account deletion must preserve
|
||||
-- the immutable, pseudonymized grant and ledger records.
|
||||
|
||||
-- Dashboard queries aggregate by creation time and state. These indexes avoid
|
||||
-- full scans without changing existing business data or ledger semantics.
|
||||
CREATE INDEX idx_accounts_created ON accounts (created_at);
|
||||
CREATE INDEX idx_credit_ledger_type_created ON credit_ledger (entry_type, created_at);
|
||||
CREATE INDEX idx_credit_usage_created ON credit_usage_records (created_at);
|
||||
CREATE INDEX idx_referral_bindings_status_bound
|
||||
ON referral_bindings (reward_status, bound_at);
|
||||
|
||||
-- admin_audit_log and admin_credit_grants are append-only. The production
|
||||
-- runtime database role must receive SELECT/INSERT only on these tables.
|
||||
@@ -34,6 +34,48 @@ class AppConfigTest : FunSpec({
|
||||
config.database.migrationUsername shouldBe "test_migrator"
|
||||
}
|
||||
|
||||
test("production accepts enabled admin bootstrap with Argon2 PHC hash") {
|
||||
val config = validProductionConfig().apply {
|
||||
put("app.admin.enabled", "true")
|
||||
put("app.admin.bootstrapEnabled", "true")
|
||||
put("app.admin.bootstrapOperatorId", "2c031def-4517-4fde-b592-5db3a3eefdf6")
|
||||
put("app.admin.bootstrapUsername", "owner")
|
||||
put(
|
||||
"app.admin.bootstrapPasswordHash",
|
||||
"\$argon2id\$v=19\$m=65536,t=3,p=1\$c2FsdHNhbHRzYWx0c2FsdA\$aGFzaGhhc2hoYXNoaGFzaGhhc2hoYXNoaGFzaA",
|
||||
)
|
||||
put("app.admin.bootstrapTotpSecretBase32", "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP")
|
||||
}
|
||||
|
||||
val admin = AppConfig.from(config).admin
|
||||
admin.enabled shouldBe true
|
||||
admin.bootstrapEnabled shouldBe true
|
||||
}
|
||||
|
||||
test("production accepts established admin without bootstrap credentials") {
|
||||
val config = validProductionConfig().apply {
|
||||
put("app.admin.enabled", "true")
|
||||
put("app.admin.bootstrapEnabled", "false")
|
||||
}
|
||||
|
||||
val admin = AppConfig.from(config).admin
|
||||
|
||||
admin.enabled shouldBe true
|
||||
admin.bootstrapEnabled shouldBe false
|
||||
admin.bootstrapTotpSecretBase32 shouldBe null
|
||||
}
|
||||
|
||||
test("admin bootstrap cannot be enabled while admin routes are disabled") {
|
||||
val config = validProductionConfig().apply {
|
||||
put("app.admin.enabled", "false")
|
||||
put("app.admin.bootstrapEnabled", "true")
|
||||
}
|
||||
|
||||
shouldThrow<IllegalArgumentException> {
|
||||
AppConfig.from(config)
|
||||
}.message.orEmpty() shouldContain "bootstrapEnabled requires"
|
||||
}
|
||||
|
||||
test("production fails fast when Apple signing credentials are missing") {
|
||||
val config = validProductionConfig().apply {
|
||||
put("app.apple.keyId", "")
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
package com.osglab.account.features.admin
|
||||
|
||||
import com.osglab.account.features.admin.models.AdminAuditCursor
|
||||
import com.osglab.account.features.admin.models.AdminAuditRecord
|
||||
import com.osglab.account.features.admin.models.AdminLockState
|
||||
import com.osglab.account.features.admin.models.AdminOperatorAuthRecord
|
||||
import com.osglab.account.features.admin.models.AdminOperatorCursor
|
||||
import com.osglab.account.features.admin.models.AdminOperatorMutationResult
|
||||
import com.osglab.account.features.admin.models.AdminOperatorRecord
|
||||
import com.osglab.account.features.admin.models.AdminRole
|
||||
import com.osglab.account.features.admin.models.AdminSessionRecord
|
||||
import com.osglab.account.features.admin.models.NewAdminAuditEvent
|
||||
import com.osglab.account.features.admin.models.NewAdminOperator
|
||||
import com.osglab.account.features.admin.models.NewAdminSession
|
||||
import com.osglab.account.features.admin.repositories.AdminRepository
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
internal class InMemoryAdminRepository(
|
||||
val operatorId: UUID = UUID.randomUUID(),
|
||||
var username: String = "admin@example.com",
|
||||
var passwordHash: String = "valid-password-hash",
|
||||
var encryptedTotpSecret: String,
|
||||
var role: AdminRole = AdminRole.SUPER_ADMIN,
|
||||
) : AdminRepository {
|
||||
private val mutex = Mutex()
|
||||
var lockState = AdminLockState(0, null)
|
||||
var disabledAt: Instant? = null
|
||||
var lastTotpCounter: Long? = null
|
||||
val sessions = mutableMapOf<String, AdminSessionRecord>()
|
||||
val revokedTokenHashes = mutableSetOf<String>()
|
||||
private val revokedAtByTokenHash = mutableMapOf<String, Instant>()
|
||||
val audits = mutableListOf<NewAdminAuditEvent>()
|
||||
private val additionalOperators = linkedMapOf<UUID, MutableOperator>()
|
||||
|
||||
override suspend fun createOperatorIfAbsent(operator: NewAdminOperator): Boolean =
|
||||
mutex.withLock {
|
||||
if (operator.normalizedUsername == username || usernameExists(operator.normalizedUsername)) {
|
||||
false
|
||||
} else {
|
||||
additionalOperators[operator.id] = MutableOperator(operator)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun createOperator(
|
||||
operator: NewAdminOperator,
|
||||
auditEvent: NewAdminAuditEvent,
|
||||
): AdminOperatorMutationResult = mutex.withLock {
|
||||
val result = if (operator.normalizedUsername == username || usernameExists(operator.normalizedUsername)) {
|
||||
AdminOperatorMutationResult.USERNAME_CONFLICT
|
||||
} else {
|
||||
additionalOperators[operator.id] = MutableOperator(operator)
|
||||
AdminOperatorMutationResult.SUCCESS
|
||||
}
|
||||
audits += auditEvent.forResult(result)
|
||||
result
|
||||
}
|
||||
|
||||
override suspend fun listOperators(): List<AdminOperatorRecord> = mutex.withLock {
|
||||
listOf(baseOperatorRecord()) + additionalOperators.values.map(MutableOperator::toRecord)
|
||||
}
|
||||
|
||||
override suspend fun listOperatorsPage(
|
||||
limit: Int,
|
||||
before: AdminOperatorCursor?,
|
||||
): List<AdminOperatorRecord> = mutex.withLock {
|
||||
require(limit in 1..101)
|
||||
(listOf(baseOperatorRecord()) + additionalOperators.values.map(MutableOperator::toRecord))
|
||||
.asSequence()
|
||||
.filter {
|
||||
before == null ||
|
||||
it.createdAt.isAfter(before.createdAt) ||
|
||||
(it.createdAt == before.createdAt && it.id.toString() > before.id.toString())
|
||||
}
|
||||
.sortedWith(
|
||||
compareBy<AdminOperatorRecord> { it.createdAt }
|
||||
.thenBy { it.id.toString() },
|
||||
)
|
||||
.take(limit)
|
||||
.toList()
|
||||
}
|
||||
|
||||
override suspend fun countActiveSessions(now: Instant): Long = mutex.withLock {
|
||||
sessions.count { (tokenHash, session) ->
|
||||
tokenHash !in revokedTokenHashes &&
|
||||
session.expiresAt.isAfter(now) &&
|
||||
operatorDisabledAt(session.operatorId) == null
|
||||
}.toLong()
|
||||
}
|
||||
|
||||
override suspend fun findOperator(operatorId: UUID): AdminOperatorRecord? = mutex.withLock {
|
||||
if (operatorId == this.operatorId) {
|
||||
baseOperatorRecord()
|
||||
} else {
|
||||
additionalOperators[operatorId]?.toRecord()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setOperatorEnabled(
|
||||
operatorId: UUID,
|
||||
enabled: Boolean,
|
||||
now: Instant,
|
||||
auditEvent: NewAdminAuditEvent,
|
||||
): AdminOperatorMutationResult = mutex.withLock {
|
||||
val targetRole = when (operatorId) {
|
||||
this.operatorId -> role
|
||||
else -> additionalOperators[operatorId]?.operator?.role
|
||||
}
|
||||
val result = when {
|
||||
targetRole == null -> AdminOperatorMutationResult.NOT_FOUND
|
||||
!enabled &&
|
||||
operatorDisabledAt(operatorId) == null &&
|
||||
targetRole == AdminRole.SUPER_ADMIN &&
|
||||
enabledSuperAdministrators() <= 1 ->
|
||||
AdminOperatorMutationResult.LAST_SUPER_ADMIN
|
||||
else -> {
|
||||
if (operatorId == this.operatorId) {
|
||||
disabledAt = if (enabled) null else now
|
||||
} else {
|
||||
additionalOperators.getValue(operatorId).apply {
|
||||
disabledAt = if (enabled) null else now
|
||||
updatedAt = now
|
||||
}
|
||||
}
|
||||
if (!enabled) revokeSessionsFor(operatorId, now)
|
||||
AdminOperatorMutationResult.SUCCESS
|
||||
}
|
||||
}
|
||||
audits += auditEvent.forResult(result)
|
||||
result
|
||||
}
|
||||
|
||||
override suspend fun unlockOperator(
|
||||
operatorId: UUID,
|
||||
now: Instant,
|
||||
auditEvent: NewAdminAuditEvent,
|
||||
): AdminOperatorMutationResult = mutex.withLock {
|
||||
val result = when (operatorId) {
|
||||
this.operatorId -> {
|
||||
lockState = AdminLockState(0, null)
|
||||
AdminOperatorMutationResult.SUCCESS
|
||||
}
|
||||
in additionalOperators -> {
|
||||
additionalOperators.getValue(operatorId).apply {
|
||||
lockState = AdminLockState(0, null)
|
||||
updatedAt = now
|
||||
}
|
||||
AdminOperatorMutationResult.SUCCESS
|
||||
}
|
||||
else -> AdminOperatorMutationResult.NOT_FOUND
|
||||
}
|
||||
audits += auditEvent.forResult(result)
|
||||
result
|
||||
}
|
||||
|
||||
override suspend fun resetOperatorCredentials(
|
||||
operatorId: UUID,
|
||||
passwordHash: String,
|
||||
encryptedTotpSecret: String,
|
||||
now: Instant,
|
||||
auditEvent: NewAdminAuditEvent,
|
||||
): AdminOperatorMutationResult = mutex.withLock {
|
||||
val result = when (operatorId) {
|
||||
this.operatorId -> {
|
||||
this.passwordHash = passwordHash
|
||||
this.encryptedTotpSecret = encryptedTotpSecret
|
||||
lockState = AdminLockState(0, null)
|
||||
lastTotpCounter = null
|
||||
revokeSessionsFor(operatorId, now)
|
||||
AdminOperatorMutationResult.SUCCESS
|
||||
}
|
||||
in additionalOperators -> {
|
||||
additionalOperators.getValue(operatorId).apply {
|
||||
this.passwordHash = passwordHash
|
||||
this.encryptedTotpSecret = encryptedTotpSecret
|
||||
lockState = AdminLockState(0, null)
|
||||
lastTotpCounter = null
|
||||
updatedAt = now
|
||||
}
|
||||
revokeSessionsFor(operatorId, now)
|
||||
AdminOperatorMutationResult.SUCCESS
|
||||
}
|
||||
else -> AdminOperatorMutationResult.NOT_FOUND
|
||||
}
|
||||
audits += auditEvent.forResult(result)
|
||||
result
|
||||
}
|
||||
|
||||
override suspend fun revokeOperatorSessions(
|
||||
operatorId: UUID,
|
||||
now: Instant,
|
||||
auditEvent: NewAdminAuditEvent,
|
||||
): AdminOperatorMutationResult = mutex.withLock {
|
||||
val result = if (operatorId == this.operatorId || operatorId in additionalOperators) {
|
||||
revokeSessionsFor(operatorId, now)
|
||||
AdminOperatorMutationResult.SUCCESS
|
||||
} else {
|
||||
AdminOperatorMutationResult.NOT_FOUND
|
||||
}
|
||||
audits += auditEvent.forResult(result)
|
||||
result
|
||||
}
|
||||
|
||||
override suspend fun findOperatorForAuthentication(
|
||||
normalizedUsername: String,
|
||||
): AdminOperatorAuthRecord? = mutex.withLock {
|
||||
if (normalizedUsername == username) {
|
||||
authRecord()
|
||||
} else {
|
||||
additionalOperators.values
|
||||
.firstOrNull { it.operator.normalizedUsername == normalizedUsername }
|
||||
?.toAuthRecord()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateLockState(
|
||||
operatorId: UUID,
|
||||
now: Instant,
|
||||
auditEvent: NewAdminAuditEvent,
|
||||
transform: (AdminLockState) -> AdminLockState,
|
||||
): AdminLockState? = mutex.withLock {
|
||||
if (operatorId != this.operatorId || disabledAt != null) return@withLock null
|
||||
transform(lockState).also {
|
||||
lockState = it
|
||||
audits += auditEvent
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun createSessionIfTotpCounterFresh(
|
||||
session: NewAdminSession,
|
||||
totpCounter: Long,
|
||||
now: Instant,
|
||||
auditEvent: NewAdminAuditEvent,
|
||||
): AdminSessionRecord? = mutex.withLock {
|
||||
if (session.operatorId != operatorId || disabledAt != null || lockState.isLockedAt(now)) {
|
||||
return@withLock null
|
||||
}
|
||||
if (lastTotpCounter?.let { it >= totpCounter } == true) return@withLock null
|
||||
lastTotpCounter = totpCounter
|
||||
lockState = AdminLockState(0, null)
|
||||
AdminSessionRecord(
|
||||
id = session.id,
|
||||
operatorId = operatorId,
|
||||
normalizedUsername = username,
|
||||
role = role,
|
||||
csrfTokenHash = session.csrfTokenHash,
|
||||
expiresAt = session.expiresAt,
|
||||
).also {
|
||||
sessions[session.tokenHash] = it
|
||||
audits += auditEvent
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun findActiveSessionByTokenHash(
|
||||
tokenHash: String,
|
||||
now: Instant,
|
||||
): AdminSessionRecord? = mutex.withLock {
|
||||
if (disabledAt != null || tokenHash in revokedTokenHashes) return@withLock null
|
||||
sessions[tokenHash]?.takeIf { it.expiresAt.isAfter(now) }
|
||||
}
|
||||
|
||||
override suspend fun revokeSessionByTokenHash(
|
||||
tokenHash: String,
|
||||
now: Instant,
|
||||
auditEvent: NewAdminAuditEvent,
|
||||
): AdminSessionRecord? = mutex.withLock {
|
||||
if (tokenHash in revokedTokenHashes) return@withLock null
|
||||
sessions[tokenHash]?.also {
|
||||
revokedTokenHashes += tokenHash
|
||||
revokedAtByTokenHash[tokenHash] = now
|
||||
audits += auditEvent
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun purgeInactiveSessions(cutoff: Instant, limit: Int): Int =
|
||||
mutex.withLock {
|
||||
require(limit in 1..1_000)
|
||||
val candidates = sessions.entries.asSequence()
|
||||
.filter { (tokenHash, session) ->
|
||||
!session.expiresAt.isAfter(cutoff) ||
|
||||
revokedAtByTokenHash[tokenHash]?.isAfter(cutoff) == false
|
||||
}
|
||||
.sortedWith(
|
||||
compareBy<Map.Entry<String, AdminSessionRecord>> { it.value.expiresAt }
|
||||
.thenBy { it.value.id.toString() },
|
||||
)
|
||||
.take(limit)
|
||||
.map(Map.Entry<String, AdminSessionRecord>::key)
|
||||
.toList()
|
||||
candidates.forEach {
|
||||
sessions.remove(it)
|
||||
revokedTokenHashes.remove(it)
|
||||
revokedAtByTokenHash.remove(it)
|
||||
}
|
||||
candidates.size
|
||||
}
|
||||
|
||||
override suspend fun appendAudit(event: NewAdminAuditEvent) {
|
||||
mutex.withLock {
|
||||
audits += event
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun listAudit(
|
||||
limit: Int,
|
||||
before: AdminAuditCursor?,
|
||||
): List<AdminAuditRecord> =
|
||||
mutex.withLock {
|
||||
audits.asSequence()
|
||||
.filter {
|
||||
before == null ||
|
||||
it.occurredAt.isBefore(before.occurredAt) ||
|
||||
(
|
||||
it.occurredAt == before.occurredAt &&
|
||||
it.id.toString() < before.id.toString()
|
||||
)
|
||||
}
|
||||
.sortedWith(
|
||||
compareByDescending<NewAdminAuditEvent> { it.occurredAt }
|
||||
.thenByDescending { it.id.toString() },
|
||||
)
|
||||
.take(limit)
|
||||
.map {
|
||||
AdminAuditRecord(
|
||||
id = it.id,
|
||||
actorOperatorId = it.actorOperatorId,
|
||||
action = it.action,
|
||||
outcome = it.outcome,
|
||||
targetType = it.targetType,
|
||||
targetId = it.targetId,
|
||||
requestId = it.requestId,
|
||||
occurredAt = it.occurredAt,
|
||||
)
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
|
||||
suspend fun seedSession(tokenHash: String, session: AdminSessionRecord) {
|
||||
mutex.withLock {
|
||||
sessions[tokenHash] = session
|
||||
}
|
||||
}
|
||||
|
||||
private fun authRecord() = AdminOperatorAuthRecord(
|
||||
id = operatorId,
|
||||
normalizedUsername = username,
|
||||
passwordHash = passwordHash,
|
||||
encryptedTotpSecret = encryptedTotpSecret,
|
||||
role = role,
|
||||
lockState = lockState,
|
||||
disabledAt = disabledAt,
|
||||
)
|
||||
|
||||
private fun baseOperatorRecord() = AdminOperatorRecord(
|
||||
id = operatorId,
|
||||
normalizedUsername = username,
|
||||
role = role,
|
||||
lockState = lockState,
|
||||
disabledAt = disabledAt,
|
||||
lastLoginAt = null,
|
||||
createdAt = Instant.EPOCH,
|
||||
updatedAt = Instant.EPOCH,
|
||||
)
|
||||
|
||||
private fun usernameExists(candidate: String): Boolean =
|
||||
additionalOperators.values.any { it.operator.normalizedUsername == candidate }
|
||||
|
||||
private fun enabledSuperAdministrators(): Int =
|
||||
(if (role == AdminRole.SUPER_ADMIN && disabledAt == null) 1 else 0) +
|
||||
additionalOperators.values.count {
|
||||
it.operator.role == AdminRole.SUPER_ADMIN && it.disabledAt == null
|
||||
}
|
||||
|
||||
private fun operatorDisabledAt(operatorId: UUID): Instant? =
|
||||
if (operatorId == this.operatorId) disabledAt else additionalOperators[operatorId]?.disabledAt
|
||||
|
||||
private fun revokeSessionsFor(operatorId: UUID, now: Instant) {
|
||||
sessions.filterValues { it.operatorId == operatorId }.keys.forEach {
|
||||
revokedTokenHashes += it
|
||||
revokedAtByTokenHash[it] = now
|
||||
}
|
||||
}
|
||||
|
||||
private class MutableOperator(
|
||||
val operator: NewAdminOperator,
|
||||
var passwordHash: String = operator.passwordHash,
|
||||
var encryptedTotpSecret: String = operator.encryptedTotpSecret,
|
||||
var lockState: AdminLockState = AdminLockState(0, null),
|
||||
var lastTotpCounter: Long? = null,
|
||||
var disabledAt: Instant? = null,
|
||||
var updatedAt: Instant = operator.createdAt,
|
||||
) {
|
||||
fun toAuthRecord() = AdminOperatorAuthRecord(
|
||||
id = operator.id,
|
||||
normalizedUsername = operator.normalizedUsername,
|
||||
passwordHash = passwordHash,
|
||||
encryptedTotpSecret = encryptedTotpSecret,
|
||||
role = operator.role,
|
||||
lockState = lockState,
|
||||
disabledAt = disabledAt,
|
||||
)
|
||||
|
||||
fun toRecord() = AdminOperatorRecord(
|
||||
id = operator.id,
|
||||
normalizedUsername = operator.normalizedUsername,
|
||||
role = operator.role,
|
||||
lockState = lockState,
|
||||
disabledAt = disabledAt,
|
||||
lastLoginAt = null,
|
||||
createdAt = operator.createdAt,
|
||||
updatedAt = updatedAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun NewAdminAuditEvent.forResult(result: AdminOperatorMutationResult): NewAdminAuditEvent =
|
||||
copy(
|
||||
outcome = if (result == AdminOperatorMutationResult.SUCCESS) {
|
||||
com.osglab.account.features.admin.models.AdminAuditOutcome.SUCCESS
|
||||
} else {
|
||||
com.osglab.account.features.admin.models.AdminAuditOutcome.DENIED
|
||||
},
|
||||
)
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
package com.osglab.account.features.admin.repositories
|
||||
|
||||
import com.osglab.account.config.DatabaseConfig
|
||||
import com.osglab.account.config.DatabaseFactory
|
||||
import com.osglab.account.features.admin.models.AdminAuditAction
|
||||
import com.osglab.account.features.admin.models.AdminAuditOutcome
|
||||
import com.osglab.account.features.admin.models.AdminOperatorMutationResult
|
||||
import com.osglab.account.features.admin.models.AdminRole
|
||||
import com.osglab.account.features.admin.models.NewAdminAuditEvent
|
||||
import com.osglab.account.features.admin.models.NewAdminOperator
|
||||
import com.osglab.account.features.admin.models.NewAdminSession
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import org.opentest4j.TestAbortedException
|
||||
import org.testcontainers.DockerClientFactory
|
||||
import org.testcontainers.containers.MySQLContainer
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
class AdminOperatorRepositoryIntegrationTest : FunSpec({
|
||||
test("row locks preserve one enabled super administrator under concurrent disables") {
|
||||
withAdminRepositories { first, second ->
|
||||
val now = Instant.parse("2026-08-17T00:00:00Z")
|
||||
val firstId = UUID.randomUUID()
|
||||
val secondId = UUID.randomUUID()
|
||||
first.createOperatorIfAbsent(
|
||||
newOperator(firstId, "owner-${firstId.toString().take(8)}", AdminRole.SUPER_ADMIN, now),
|
||||
)
|
||||
first.createOperatorIfAbsent(
|
||||
newOperator(secondId, "owner-${secondId.toString().take(8)}", AdminRole.SUPER_ADMIN, now),
|
||||
)
|
||||
|
||||
val results = coroutineScope {
|
||||
listOf(
|
||||
async {
|
||||
first.setOperatorEnabled(
|
||||
firstId,
|
||||
enabled = false,
|
||||
now = now,
|
||||
auditEvent = audit(secondId, AdminAuditAction.OPERATOR_DISABLED, firstId, now),
|
||||
)
|
||||
},
|
||||
async {
|
||||
second.setOperatorEnabled(
|
||||
secondId,
|
||||
enabled = false,
|
||||
now = now,
|
||||
auditEvent = audit(firstId, AdminAuditAction.OPERATOR_DISABLED, secondId, now),
|
||||
)
|
||||
},
|
||||
).awaitAll()
|
||||
}
|
||||
|
||||
results shouldContainExactlyInAnyOrder listOf(
|
||||
AdminOperatorMutationResult.SUCCESS,
|
||||
AdminOperatorMutationResult.LAST_SUPER_ADMIN,
|
||||
)
|
||||
first.listOperators().count {
|
||||
it.role == AdminRole.SUPER_ADMIN && it.disabledAt == null
|
||||
} shouldBe 1
|
||||
first.listAudit(10).count {
|
||||
it.action == AdminAuditAction.OPERATOR_DISABLED &&
|
||||
it.outcome == AdminAuditOutcome.DENIED
|
||||
} shouldBe 1
|
||||
}
|
||||
}
|
||||
|
||||
test("credential reset atomically revokes active sessions") {
|
||||
withAdminRepositories { repository, _ ->
|
||||
val now = Instant.parse("2026-08-17T00:00:00Z")
|
||||
val ownerId = UUID.randomUUID()
|
||||
val targetId = UUID.randomUUID()
|
||||
val ownerUsername = "owner-${ownerId.toString().take(8)}"
|
||||
val targetUsername = "target-${targetId.toString().take(8)}"
|
||||
repository.createOperatorIfAbsent(
|
||||
newOperator(ownerId, ownerUsername, AdminRole.SUPER_ADMIN, now),
|
||||
)
|
||||
repository.createOperatorIfAbsent(
|
||||
newOperator(targetId, targetUsername, AdminRole.SUPPORT, now),
|
||||
)
|
||||
val session = NewAdminSession(
|
||||
id = UUID.randomUUID(),
|
||||
operatorId = targetId,
|
||||
tokenHash = "a".repeat(64),
|
||||
csrfTokenHash = "b".repeat(64),
|
||||
createdAt = now,
|
||||
expiresAt = now.plus(Duration.ofHours(1)),
|
||||
)
|
||||
repository.createSessionIfTotpCounterFresh(
|
||||
session = session,
|
||||
totpCounter = 1,
|
||||
now = now,
|
||||
auditEvent = audit(targetId, AdminAuditAction.LOGIN_SUCCEEDED, targetId, now),
|
||||
)
|
||||
(repository.findActiveSessionByTokenHash(session.tokenHash, now) != null) shouldBe true
|
||||
|
||||
repository.resetOperatorCredentials(
|
||||
operatorId = targetId,
|
||||
passwordHash = "new-password-hash",
|
||||
encryptedTotpSecret = "new-encrypted-secret",
|
||||
now = now.plusSeconds(1),
|
||||
auditEvent = audit(
|
||||
ownerId,
|
||||
AdminAuditAction.OPERATOR_CREDENTIALS_RESET,
|
||||
targetId,
|
||||
now.plusSeconds(1),
|
||||
),
|
||||
) shouldBe AdminOperatorMutationResult.SUCCESS
|
||||
|
||||
repository.findActiveSessionByTokenHash(session.tokenHash, now.plusSeconds(1)) shouldBe null
|
||||
repository.findOperatorForAuthentication(targetUsername)?.run {
|
||||
passwordHash shouldBe "new-password-hash"
|
||||
encryptedTotpSecret shouldBe "new-encrypted-secret"
|
||||
lockState.failedLoginCount shouldBe 0
|
||||
lockState.lockedUntil shouldBe null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("inactive session cleanup is bounded and preserves active sessions") {
|
||||
withAdminRepositories { repository, _ ->
|
||||
val now = Instant.parse("2026-08-17T00:00:00Z")
|
||||
val operatorId = UUID.randomUUID()
|
||||
repository.createOperatorIfAbsent(
|
||||
newOperator(operatorId, "cleanup-${operatorId.toString().take(8)}", AdminRole.SUPPORT, now),
|
||||
)
|
||||
val oldTokens = (1L..3L).map { counter ->
|
||||
val tokenHash = counter.toString().repeat(64).take(64)
|
||||
repository.createSessionIfTotpCounterFresh(
|
||||
session = NewAdminSession(
|
||||
id = UUID.randomUUID(),
|
||||
operatorId = operatorId,
|
||||
tokenHash = tokenHash,
|
||||
csrfTokenHash = counter.plus(3).toString().repeat(64).take(64),
|
||||
createdAt = now.minus(Duration.ofDays(10)),
|
||||
expiresAt = now.minus(Duration.ofDays(9)),
|
||||
),
|
||||
totpCounter = counter,
|
||||
now = now.minus(Duration.ofDays(10)),
|
||||
auditEvent = audit(
|
||||
operatorId,
|
||||
AdminAuditAction.LOGIN_SUCCEEDED,
|
||||
operatorId,
|
||||
now.minus(Duration.ofDays(10)),
|
||||
),
|
||||
)
|
||||
tokenHash
|
||||
}
|
||||
val activeToken = "f".repeat(64)
|
||||
repository.createSessionIfTotpCounterFresh(
|
||||
session = NewAdminSession(
|
||||
id = UUID.randomUUID(),
|
||||
operatorId = operatorId,
|
||||
tokenHash = activeToken,
|
||||
csrfTokenHash = "e".repeat(64),
|
||||
createdAt = now,
|
||||
expiresAt = now.plus(Duration.ofHours(1)),
|
||||
),
|
||||
totpCounter = 4,
|
||||
now = now,
|
||||
auditEvent = audit(operatorId, AdminAuditAction.LOGIN_SUCCEEDED, operatorId, now),
|
||||
)
|
||||
|
||||
repository.purgeInactiveSessions(now.minus(Duration.ofDays(7)), limit = 2) shouldBe 2
|
||||
repository.purgeInactiveSessions(now.minus(Duration.ofDays(7)), limit = 2) shouldBe 1
|
||||
repository.purgeInactiveSessions(now.minus(Duration.ofDays(7)), limit = 2) shouldBe 0
|
||||
oldTokens.forEach {
|
||||
repository.findActiveSessionByTokenHash(it, now) shouldBe null
|
||||
}
|
||||
(repository.findActiveSessionByTokenHash(activeToken, now) != null) shouldBe true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
private suspend fun withAdminRepositories(
|
||||
block: suspend (ExposedAdminRepository, ExposedAdminRepository) -> Unit,
|
||||
) {
|
||||
val externalJdbcUrl = System.getenv("TEST_MYSQL_JDBC_URL")?.takeIf(String::isNotBlank)
|
||||
if (externalJdbcUrl == null && !DockerClientFactory.instance().isDockerAvailable) {
|
||||
throw TestAbortedException("Docker is unavailable; MySQL integration test skipped")
|
||||
}
|
||||
val mysql = if (externalJdbcUrl == null) {
|
||||
AdminMySqlContainer("mysql:8.4")
|
||||
.withDatabaseName("osg_admin_repository_test")
|
||||
.withUsername("test")
|
||||
.withPassword("test")
|
||||
.also(AdminMySqlContainer::start)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val config = DatabaseConfig(
|
||||
jdbcUrl = externalJdbcUrl ?: requireNotNull(mysql).jdbcUrl,
|
||||
username = System.getenv("TEST_MYSQL_USER")?.takeIf(String::isNotBlank)
|
||||
?: mysql?.username
|
||||
?: "root",
|
||||
password = System.getenv("TEST_MYSQL_PASSWORD") ?: mysql?.password ?: "",
|
||||
maximumPoolSize = 4,
|
||||
)
|
||||
val firstFactory = DatabaseFactory(config)
|
||||
val secondFactory = DatabaseFactory(config)
|
||||
try {
|
||||
firstFactory.database
|
||||
secondFactory.database
|
||||
block(ExposedAdminRepository(firstFactory), ExposedAdminRepository(secondFactory))
|
||||
} finally {
|
||||
secondFactory.close()
|
||||
firstFactory.close()
|
||||
mysql?.stop()
|
||||
}
|
||||
}
|
||||
|
||||
private fun newOperator(
|
||||
id: UUID,
|
||||
username: String,
|
||||
role: AdminRole,
|
||||
now: Instant,
|
||||
) = NewAdminOperator(
|
||||
id = id,
|
||||
normalizedUsername = username,
|
||||
passwordHash = "password-hash",
|
||||
encryptedTotpSecret = "encrypted-secret",
|
||||
role = role,
|
||||
createdAt = now,
|
||||
)
|
||||
|
||||
private fun audit(
|
||||
actorId: UUID,
|
||||
action: AdminAuditAction,
|
||||
targetId: UUID,
|
||||
now: Instant,
|
||||
) = NewAdminAuditEvent(
|
||||
actorOperatorId = actorId,
|
||||
action = action,
|
||||
outcome = AdminAuditOutcome.SUCCESS,
|
||||
targetType = "ADMIN_OPERATOR",
|
||||
targetId = targetId.toString(),
|
||||
occurredAt = now,
|
||||
)
|
||||
|
||||
private class AdminMySqlContainer(image: String) :
|
||||
MySQLContainer<AdminMySqlContainer>(image)
|
||||
@@ -0,0 +1,331 @@
|
||||
package com.osglab.account.features.admin.routes
|
||||
|
||||
import com.osglab.account.config.AdminConfig
|
||||
import com.osglab.account.config.AppConfig
|
||||
import com.osglab.account.features.admin.grants.services.AdminGrantService
|
||||
import com.osglab.account.features.admin.models.AdminPrincipal
|
||||
import com.osglab.account.features.admin.models.AdminRole
|
||||
import com.osglab.account.features.admin.services.AdminAuditService
|
||||
import com.osglab.account.features.admin.services.AdminAuthService
|
||||
import com.osglab.account.features.admin.services.AdminOperatorService
|
||||
import com.osglab.account.features.admin.services.AdminOperatorErrorCode
|
||||
import com.osglab.account.features.admin.services.AdminOperatorException
|
||||
import com.osglab.account.features.admin.services.AdminSessionService
|
||||
import com.osglab.account.features.admin.stats.services.AdminStatsService
|
||||
import com.osglab.account.features.admin.users.services.AdminUsersService
|
||||
import com.osglab.account.features.credits.domain.CreditConflict
|
||||
import com.osglab.account.features.credits.domain.CreditNotFound
|
||||
import com.osglab.account.features.credits.domain.InvalidCreditRequest
|
||||
import io.kotest.matchers.string.shouldContain
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.http.contentType
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import io.ktor.server.application.install
|
||||
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.server.plugins.ratelimit.RateLimit
|
||||
import io.ktor.server.plugins.ratelimit.RateLimitName
|
||||
import io.ktor.server.routing.routing
|
||||
import io.ktor.server.testing.testApplication
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class AdminRoutesTest {
|
||||
@Test
|
||||
fun `admin api is hidden without verified edge header`() = testApplication {
|
||||
application { installAdminTestRoutes() }
|
||||
|
||||
val response = client.get("/v1/admin/auth/session")
|
||||
|
||||
assertEquals(HttpStatusCode.NotFound, response.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verified edge can check anonymous session`() = testApplication {
|
||||
application { installAdminTestRoutes() }
|
||||
|
||||
val response = client.get("/v1/admin/auth/session") {
|
||||
header("X-OSG-mTLS-Verified", "SUCCESS")
|
||||
}
|
||||
|
||||
assertEquals(HttpStatusCode.OK, response.status)
|
||||
response.bodyAsText() shouldContain """"authenticated":false"""
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `authenticated session exposes role for client-side capability navigation`() = testApplication {
|
||||
application {
|
||||
installAdminTestRoutes(
|
||||
sessionService = sessionFixture(AdminRole.SUPER_ADMIN),
|
||||
)
|
||||
}
|
||||
|
||||
val response = client.get("/v1/admin/auth/session") {
|
||||
header("X-OSG-mTLS-Verified", "SUCCESS")
|
||||
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
|
||||
}
|
||||
|
||||
assertEquals(HttpStatusCode.OK, response.status)
|
||||
response.bodyAsText() shouldContain """"operatorName":"operator""""
|
||||
response.bodyAsText() shouldContain """"role":"SUPER_ADMIN""""
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `login rejects requests without exact same origin`() = testApplication {
|
||||
val authService = mockk<AdminAuthService>(relaxed = true)
|
||||
application { installAdminTestRoutes(authService) }
|
||||
|
||||
val response = client.post("/v1/admin/auth/login") {
|
||||
header("X-OSG-mTLS-Verified", "SUCCESS")
|
||||
header(HttpHeaders.Origin, "https://attacker.example")
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody("""{"username":"owner","password":"not-a-real-password","totpCode":"123456"}""")
|
||||
}
|
||||
|
||||
assertEquals(HttpStatusCode.Forbidden, response.status)
|
||||
coVerify(exactly = 0) { authService.login(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `admin web resources are embedded`() = testApplication {
|
||||
application {
|
||||
routing { adminWebRoutes() }
|
||||
}
|
||||
|
||||
val response = client.get("/admin/")
|
||||
|
||||
assertEquals(HttpStatusCode.OK, response.status)
|
||||
response.bodyAsText() shouldContain "OSG 运营后台"
|
||||
response.bodyAsText() shouldContain """/admin/assets/"""
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `manual grant maps missing user to stable admin error`() = testApplication {
|
||||
val fixture = grantRouteFixture(CreditNotFound("missing"))
|
||||
application {
|
||||
installAdminTestRoutes(
|
||||
sessionService = fixture.first,
|
||||
grantService = fixture.second,
|
||||
)
|
||||
}
|
||||
|
||||
val response = client.postGrant()
|
||||
|
||||
assertEquals(HttpStatusCode.NotFound, response.status)
|
||||
response.bodyAsText() shouldContain """"code":"USER_NOT_FOUND""""
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `manual grant maps idempotency conflict to conflict`() = testApplication {
|
||||
val fixture = grantRouteFixture(CreditConflict("conflict"))
|
||||
application {
|
||||
installAdminTestRoutes(
|
||||
sessionService = fixture.first,
|
||||
grantService = fixture.second,
|
||||
)
|
||||
}
|
||||
|
||||
val response = client.postGrant()
|
||||
|
||||
assertEquals(HttpStatusCode.Conflict, response.status)
|
||||
response.bodyAsText() shouldContain """"code":"IDEMPOTENCY_CONFLICT""""
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `manual grant maps invalid domain request to validation error`() = testApplication {
|
||||
val fixture = grantRouteFixture(InvalidCreditRequest("invalid"))
|
||||
application {
|
||||
installAdminTestRoutes(
|
||||
sessionService = fixture.first,
|
||||
grantService = fixture.second,
|
||||
)
|
||||
}
|
||||
|
||||
val response = client.postGrant()
|
||||
|
||||
assertEquals(HttpStatusCode.BadRequest, response.status)
|
||||
response.bodyAsText() shouldContain """"code":"VALIDATION_ERROR""""
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `operator list maps non-super authorization to stable forbidden response`() = testApplication {
|
||||
val sessionService = sessionFixture(AdminRole.SUPPORT)
|
||||
val operatorService = mockk<AdminOperatorService>()
|
||||
coEvery { operatorService.listPage(any(), any(), any()) } throws
|
||||
AdminOperatorException(AdminOperatorErrorCode.INSUFFICIENT_PERMISSION)
|
||||
application {
|
||||
installAdminTestRoutes(
|
||||
sessionService = sessionService,
|
||||
operatorService = operatorService,
|
||||
)
|
||||
}
|
||||
|
||||
val response = client.get("/v1/admin/operators") {
|
||||
header("X-OSG-mTLS-Verified", "SUCCESS")
|
||||
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
|
||||
}
|
||||
|
||||
assertEquals(HttpStatusCode.Forbidden, response.status)
|
||||
response.bodyAsText() shouldContain """"code":"INSUFFICIENT_PERMISSION""""
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `analyst cannot access user records`() = testApplication {
|
||||
application {
|
||||
installAdminTestRoutes(
|
||||
sessionService = sessionFixture(AdminRole.ANALYST),
|
||||
)
|
||||
}
|
||||
|
||||
val response = client.get("/v1/admin/users") {
|
||||
header("X-OSG-mTLS-Verified", "SUCCESS")
|
||||
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
|
||||
}
|
||||
|
||||
assertEquals(HttpStatusCode.Forbidden, response.status)
|
||||
response.bodyAsText() shouldContain """"code":"INSUFFICIENT_PERMISSION""""
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `operator creation maps normalized username conflict to 409`() = testApplication {
|
||||
val sessionService = sessionFixture(AdminRole.SUPER_ADMIN)
|
||||
val operatorService = mockk<AdminOperatorService>()
|
||||
coEvery {
|
||||
operatorService.create(any(), any(), any(), any(), any())
|
||||
} throws AdminOperatorException(AdminOperatorErrorCode.ADMIN_USERNAME_CONFLICT)
|
||||
application {
|
||||
installAdminTestRoutes(
|
||||
sessionService = sessionService,
|
||||
operatorService = operatorService,
|
||||
)
|
||||
}
|
||||
|
||||
val response = client.post("/v1/admin/operators") {
|
||||
header("X-OSG-mTLS-Verified", "SUCCESS")
|
||||
header(HttpHeaders.Origin, "https://account.osglab.com")
|
||||
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
|
||||
header("X-CSRF-Token", "csrf-token")
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(
|
||||
"""{"username":"owner","password":"long-enough-password","role":"SUPPORT"}""",
|
||||
)
|
||||
}
|
||||
|
||||
assertEquals(HttpStatusCode.Conflict, response.status)
|
||||
response.bodyAsText() shouldContain """"code":"ADMIN_USERNAME_CONFLICT""""
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `operator creation maps malformed body to stable validation error`() = testApplication {
|
||||
application {
|
||||
installAdminTestRoutes(
|
||||
sessionService = sessionFixture(AdminRole.SUPER_ADMIN),
|
||||
)
|
||||
}
|
||||
|
||||
val response = client.post("/v1/admin/operators") {
|
||||
header("X-OSG-mTLS-Verified", "SUCCESS")
|
||||
header(HttpHeaders.Origin, "https://account.osglab.com")
|
||||
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
|
||||
header("X-CSRF-Token", "csrf-token")
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody("""{"username":"owner","password":"long-enough-password"}""")
|
||||
}
|
||||
|
||||
assertEquals(HttpStatusCode.BadRequest, response.status)
|
||||
response.bodyAsText() shouldContain """"code":"VALIDATION_ERROR""""
|
||||
}
|
||||
}
|
||||
|
||||
private fun io.ktor.server.application.Application.installAdminTestRoutes(
|
||||
authService: AdminAuthService = mockk(relaxed = true),
|
||||
sessionService: AdminSessionService = mockk(relaxed = true),
|
||||
grantService: AdminGrantService = mockk(relaxed = true),
|
||||
operatorService: AdminOperatorService = mockk(relaxed = true),
|
||||
auditService: AdminAuditService = mockk(relaxed = true),
|
||||
) {
|
||||
install(ContentNegotiation) {
|
||||
json(Json { explicitNulls = false })
|
||||
}
|
||||
install(RateLimit) {
|
||||
register(RateLimitName("admin-auth")) {
|
||||
rateLimiter(limit = 20, refillPeriod = 1.minutes)
|
||||
}
|
||||
}
|
||||
val config = mockk<AppConfig> {
|
||||
every { publicBaseUrl } returns "https://account.osglab.com"
|
||||
every { isProduction } returns false
|
||||
every { admin } returns AdminConfig()
|
||||
}
|
||||
routing {
|
||||
adminApiRoutes(
|
||||
config = config,
|
||||
authService = authService,
|
||||
sessionService = sessionService,
|
||||
statsService = mockk<AdminStatsService>(relaxed = true),
|
||||
usersService = mockk<AdminUsersService>(relaxed = true),
|
||||
grantService = grantService,
|
||||
operatorService = operatorService,
|
||||
auditService = auditService,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun grantRouteFixture(
|
||||
failure: RuntimeException,
|
||||
): Pair<AdminSessionService, AdminGrantService> {
|
||||
val sessionService = mockk<AdminSessionService>()
|
||||
val grantService = mockk<AdminGrantService>()
|
||||
coEvery {
|
||||
sessionService.authenticateMutation("session-token", "csrf-token")
|
||||
} returns AdminPrincipal(
|
||||
operatorId = UUID.fromString("5d98fe09-da98-45b4-9466-f3f779dc1a4b"),
|
||||
sessionId = UUID.fromString("c0271d1b-e5c8-4ca4-8409-1ab7ed148a34"),
|
||||
normalizedUsername = "owner",
|
||||
role = AdminRole.SUPER_ADMIN,
|
||||
)
|
||||
coEvery { grantService.grant(any()) } throws failure
|
||||
return sessionService to grantService
|
||||
}
|
||||
|
||||
private fun sessionFixture(role: AdminRole): AdminSessionService =
|
||||
mockk<AdminSessionService>().also {
|
||||
coEvery { it.authenticate("session-token") } returns AdminPrincipal(
|
||||
operatorId = UUID.fromString("5d98fe09-da98-45b4-9466-f3f779dc1a4b"),
|
||||
sessionId = UUID.fromString("c0271d1b-e5c8-4ca4-8409-1ab7ed148a34"),
|
||||
normalizedUsername = "operator",
|
||||
role = role,
|
||||
)
|
||||
coEvery { it.authenticateMutation("session-token", "csrf-token") } returns AdminPrincipal(
|
||||
operatorId = UUID.fromString("5d98fe09-da98-45b4-9466-f3f779dc1a4b"),
|
||||
sessionId = UUID.fromString("c0271d1b-e5c8-4ca4-8409-1ab7ed148a34"),
|
||||
normalizedUsername = "operator",
|
||||
role = role,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun io.ktor.client.HttpClient.postGrant() =
|
||||
post("/v1/admin/credits/grants") {
|
||||
header("X-OSG-mTLS-Verified", "SUCCESS")
|
||||
header(HttpHeaders.Origin, "https://account.osglab.com")
|
||||
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
|
||||
header("X-CSRF-Token", "csrf-token")
|
||||
header("Idempotency-Key", "grant-request-1")
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(
|
||||
"""{"userId":"5a33af2f-a878-43c0-8315-31729402b7cd","amount":100,"reason":"support credit"}""",
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.osglab.account.features.admin.security
|
||||
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.string.shouldStartWith
|
||||
|
||||
class AdminPasswordHasherTest : FunSpec({
|
||||
val hasher = BouncyCastleArgon2idPasswordHasher(
|
||||
Argon2idConfig(memoryKb = 1_024, iterations = 2, parallelism = 1),
|
||||
)
|
||||
|
||||
test("Argon2id hashes verify the correct password and use unique salts") {
|
||||
val password = "correct horse battery staple".toCharArray()
|
||||
val first = hasher.hash(password)
|
||||
val second = hasher.hash(password)
|
||||
|
||||
first.shouldStartWith("\$argon2id\$v=19\$")
|
||||
(first != second) shouldBe true
|
||||
hasher.verify(password, first) shouldBe true
|
||||
hasher.verify("wrong password".toCharArray(), first) shouldBe false
|
||||
}
|
||||
|
||||
test("verification rejects malformed and excessive work factors") {
|
||||
hasher.verify("password".toCharArray(), "not-a-phc-hash") shouldBe false
|
||||
hasher.verify(
|
||||
"password".toCharArray(),
|
||||
"\$argon2id\$v=19\$m=999999999,t=3,p=1\$MTIzNDU2Nzg5MDEyMzQ1Ng\$" +
|
||||
"MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY",
|
||||
) shouldBe false
|
||||
}
|
||||
|
||||
test("hashing rejects empty and oversized passwords") {
|
||||
shouldThrow<IllegalArgumentException> { hasher.hash(charArrayOf()) }
|
||||
shouldThrow<IllegalArgumentException> { hasher.hash(CharArray(1_025) { 'a' }) }
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.osglab.account.features.admin.security
|
||||
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.string.shouldContain
|
||||
import io.kotest.matchers.string.shouldNotContain
|
||||
import java.time.Instant
|
||||
|
||||
class AdminTotpTest : FunSpec({
|
||||
val secret = "12345678901234567890".toByteArray()
|
||||
|
||||
test("TOTP generation matches RFC 6238 SHA-1 vectors") {
|
||||
val verifier = HmacTotpVerifier(digits = 8, allowedWindow = 0)
|
||||
|
||||
verifier.generate(secret, Instant.ofEpochSecond(59)) shouldBe "94287082"
|
||||
verifier.generate(secret, Instant.ofEpochSecond(1_111_111_109)) shouldBe "07081804"
|
||||
}
|
||||
|
||||
test("verification accepts only the configured adjacent time window") {
|
||||
val verifier = HmacTotpVerifier(digits = 6, allowedWindow = 1)
|
||||
val previousStep = Instant.ofEpochSecond(1_700_000_010)
|
||||
val currentStep = previousStep.plusSeconds(30)
|
||||
val code = verifier.generate(secret, previousStep)
|
||||
|
||||
verifier.verify(secret, code, currentStep) shouldBe previousStep.epochSecond / 30
|
||||
verifier.verify(secret, code, currentStep.plusSeconds(30)) shouldBe null
|
||||
}
|
||||
|
||||
test("verification rejects malformed codes and secrets") {
|
||||
val verifier = HmacTotpVerifier()
|
||||
|
||||
verifier.verify(secret, "12345x", Instant.EPOCH.plusSeconds(60)) shouldBe null
|
||||
verifier.verify(ByteArray(15), "123456", Instant.EPOCH.plusSeconds(60)) shouldBe null
|
||||
}
|
||||
|
||||
test("Base32 decoder accepts canonical secrets and rejects trailing bits") {
|
||||
Base32TotpSecret.decode("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ")
|
||||
.contentEquals(secret) shouldBe true
|
||||
shouldThrow<IllegalArgumentException> {
|
||||
Base32TotpSecret.decode("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQB")
|
||||
}
|
||||
}
|
||||
|
||||
test("provisioning generator returns a 160-bit secret without exposing it in logs") {
|
||||
val provisioning = SecureAdminTotpSecretGenerator().generate("support.agent")
|
||||
|
||||
provisioning.secretBase32.length shouldBe 32
|
||||
Base32TotpSecret.decode(provisioning.secretBase32).size shouldBe 20
|
||||
provisioning.otpauthUri shouldContain "otpauth://totp/OSGKeyboard%3Asupport.agent"
|
||||
provisioning.otpauthUri shouldContain "secret=${provisioning.secretBase32}"
|
||||
provisioning.toString() shouldNotContain provisioning.secretBase32
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.osglab.account.features.admin.services
|
||||
|
||||
import com.osglab.account.features.admin.models.AdminAuditAction
|
||||
import com.osglab.account.features.admin.models.AdminAuditOutcome
|
||||
import com.osglab.account.features.admin.models.AdminAuditRecord
|
||||
import com.osglab.account.features.admin.models.AdminPrincipal
|
||||
import com.osglab.account.features.admin.models.AdminRole
|
||||
import com.osglab.account.features.admin.repositories.AdminRepository
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
class AdminAuditServiceTest : FunSpec({
|
||||
test("page cursor continues after the final visible audit record") {
|
||||
val repository = mockk<AdminRepository>()
|
||||
val service = AdminAuditService(repository)
|
||||
val records = listOf(
|
||||
auditRecord("2026-08-17T00:00:03Z"),
|
||||
auditRecord("2026-08-17T00:00:02Z"),
|
||||
auditRecord("2026-08-17T00:00:01Z"),
|
||||
)
|
||||
coEvery { repository.listAudit(3, null) } returns records
|
||||
coEvery { repository.listOperators() } returns emptyList()
|
||||
|
||||
val firstPage = service.list(superAdministrator(), null, limit = 2)
|
||||
|
||||
firstPage.items.size shouldBe 2
|
||||
firstPage.nextCursor.isNullOrBlank() shouldBe false
|
||||
|
||||
coEvery { repository.listAudit(3, any()) } returns emptyList()
|
||||
service.list(superAdministrator(), firstPage.nextCursor, limit = 2)
|
||||
|
||||
coVerify {
|
||||
repository.listAudit(
|
||||
3,
|
||||
match {
|
||||
it.occurredAt == records[1].occurredAt &&
|
||||
it.id == records[1].id
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
test("malformed cursor is rejected before querying audit records") {
|
||||
val repository = mockk<AdminRepository>()
|
||||
val service = AdminAuditService(repository)
|
||||
|
||||
shouldThrow<AdminAuditCursorException> {
|
||||
service.list(superAdministrator(), "not-a-valid-cursor")
|
||||
}
|
||||
|
||||
coVerify(exactly = 0) { repository.listAudit(any(), any()) }
|
||||
}
|
||||
|
||||
test("non-super administrator cannot list audit records") {
|
||||
val repository = mockk<AdminRepository>()
|
||||
val service = AdminAuditService(repository)
|
||||
val support = superAdministrator().copy(role = AdminRole.SUPPORT)
|
||||
|
||||
shouldThrow<AdminOperatorException> {
|
||||
service.list(support, null)
|
||||
}.code shouldBe AdminOperatorErrorCode.INSUFFICIENT_PERMISSION
|
||||
}
|
||||
})
|
||||
|
||||
private fun auditRecord(occurredAt: String) = AdminAuditRecord(
|
||||
id = UUID.randomUUID(),
|
||||
actorOperatorId = UUID.randomUUID(),
|
||||
action = AdminAuditAction.LOGIN_SUCCEEDED,
|
||||
outcome = AdminAuditOutcome.SUCCESS,
|
||||
targetType = "ADMIN_OPERATOR",
|
||||
targetId = "target",
|
||||
requestId = "request-1",
|
||||
occurredAt = Instant.parse(occurredAt),
|
||||
)
|
||||
|
||||
private fun superAdministrator() = AdminPrincipal(
|
||||
operatorId = UUID.randomUUID(),
|
||||
sessionId = UUID.randomUUID(),
|
||||
normalizedUsername = "owner",
|
||||
role = AdminRole.SUPER_ADMIN,
|
||||
)
|
||||
@@ -0,0 +1,226 @@
|
||||
package com.osglab.account.features.admin.services
|
||||
|
||||
import com.osglab.account.common.security.FieldEncryptor
|
||||
import com.osglab.account.common.security.SecureTokenGenerator
|
||||
import com.osglab.account.common.security.TokenHash
|
||||
import com.osglab.account.features.admin.InMemoryAdminRepository
|
||||
import com.osglab.account.features.admin.models.AdminAuditAction
|
||||
import com.osglab.account.features.admin.models.AdminAuditOutcome
|
||||
import com.osglab.account.features.admin.models.AdminLockState
|
||||
import com.osglab.account.features.admin.models.AdminLoginResult
|
||||
import com.osglab.account.features.admin.security.AdminPasswordHasher
|
||||
import com.osglab.account.features.admin.security.HmacTotpVerifier
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import java.time.Clock
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class AdminAuthServiceTest : FunSpec({
|
||||
test("successful login persists only token hashes and records an audit event") {
|
||||
val fixture = authFixture()
|
||||
val code = fixture.totp.generate(TOTP_SECRET_BYTES, fixture.clock.instant())
|
||||
|
||||
val result = fixture.service.login(
|
||||
username = " ADMIN@EXAMPLE.COM ",
|
||||
password = CORRECT_PASSWORD.toCharArray(),
|
||||
totpCode = code,
|
||||
requestId = "request-1",
|
||||
) as AdminLoginResult.Authenticated
|
||||
|
||||
val sessionHash = TokenHash.sha256(result.credentials.sessionToken)
|
||||
fixture.repository.sessions.containsKey(sessionHash) shouldBe true
|
||||
fixture.repository.sessions.containsKey(result.credentials.sessionToken) shouldBe false
|
||||
TokenHash.matches(
|
||||
result.credentials.csrfToken,
|
||||
fixture.repository.sessions.getValue(sessionHash).csrfTokenHash,
|
||||
) shouldBe true
|
||||
fixture.repository.audits.single().action shouldBe AdminAuditAction.LOGIN_SUCCEEDED
|
||||
fixture.repository.audits.single().outcome shouldBe AdminAuditOutcome.SUCCESS
|
||||
result.credentials.toString().contains(result.credentials.sessionToken) shouldBe false
|
||||
}
|
||||
|
||||
test("same TOTP counter is accepted once under concurrent login") {
|
||||
val fixture = authFixture()
|
||||
val code = fixture.totp.generate(TOTP_SECRET_BYTES, fixture.clock.instant())
|
||||
|
||||
val results = coroutineScope {
|
||||
List(2) {
|
||||
async {
|
||||
fixture.service.login(
|
||||
username = "admin@example.com",
|
||||
password = CORRECT_PASSWORD.toCharArray(),
|
||||
totpCode = code,
|
||||
)
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
|
||||
results.count { it is AdminLoginResult.Authenticated } shouldBe 1
|
||||
results.count { it is AdminLoginResult.InvalidCredentials } shouldBe 1
|
||||
fixture.repository.sessions.size shouldBe 1
|
||||
}
|
||||
|
||||
test("failed credentials lock at the threshold") {
|
||||
val fixture = authFixture(
|
||||
lockPolicy = AdminLoginLockPolicy(
|
||||
maxFailedAttempts = 3,
|
||||
lockDuration = Duration.ofMinutes(10),
|
||||
),
|
||||
)
|
||||
|
||||
repeat(2) {
|
||||
fixture.service.login(
|
||||
"admin@example.com",
|
||||
"wrong".toCharArray(),
|
||||
"000000",
|
||||
) shouldBe AdminLoginResult.InvalidCredentials
|
||||
}
|
||||
val third = fixture.service.login(
|
||||
"admin@example.com",
|
||||
"wrong".toCharArray(),
|
||||
"000000",
|
||||
)
|
||||
|
||||
third shouldBe AdminLoginResult.Locked(fixture.clock.instant().plusSeconds(600))
|
||||
fixture.repository.lockState.failedLoginCount shouldBe 0
|
||||
}
|
||||
|
||||
test("lock expiry boundary starts a fresh failure sequence") {
|
||||
val policy = AdminLoginLockPolicy(
|
||||
maxFailedAttempts = 3,
|
||||
lockDuration = Duration.ofMinutes(10),
|
||||
)
|
||||
val lockEndsAt = Instant.parse("2026-08-16T01:00:00Z")
|
||||
val next = policy.afterFailure(
|
||||
AdminLockState(failedLoginCount = 0, lockedUntil = lockEndsAt),
|
||||
lockEndsAt,
|
||||
)
|
||||
|
||||
next shouldBe AdminLockState(failedLoginCount = 1, lockedUntil = null)
|
||||
}
|
||||
|
||||
test("unknown users execute dummy password verification") {
|
||||
val fixture = authFixture()
|
||||
var verifiedHash: String? = null
|
||||
val service = fixture.serviceWithHasher(
|
||||
object : AdminPasswordHasher {
|
||||
override fun hash(password: CharArray): String = error("Not used")
|
||||
|
||||
override fun verify(password: CharArray, encodedHash: String): Boolean {
|
||||
verifiedHash = encodedHash
|
||||
return false
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
service.login("unknown@example.com", "guess".toCharArray(), "000000") shouldBe
|
||||
AdminLoginResult.InvalidCredentials
|
||||
verifiedHash shouldBe DUMMY_HASH
|
||||
}
|
||||
|
||||
test("invalid external request ID is omitted instead of failing login") {
|
||||
val fixture = authFixture()
|
||||
val code = fixture.totp.generate(TOTP_SECRET_BYTES, fixture.clock.instant())
|
||||
|
||||
val result = fixture.service.login(
|
||||
username = "admin@example.com",
|
||||
password = CORRECT_PASSWORD.toCharArray(),
|
||||
totpCode = code,
|
||||
requestId = "invalid request id with spaces",
|
||||
)
|
||||
|
||||
(result is AdminLoginResult.Authenticated) shouldBe true
|
||||
fixture.repository.audits.single().requestId shouldBe null
|
||||
}
|
||||
})
|
||||
|
||||
private data class AuthFixture(
|
||||
val repository: InMemoryAdminRepository,
|
||||
val service: AdminAuthService,
|
||||
val totp: HmacTotpVerifier,
|
||||
val encryptor: FieldEncryptor,
|
||||
val clock: MutableClock,
|
||||
val lockPolicy: AdminLoginLockPolicy,
|
||||
val tokenGenerator: SecureTokenGenerator,
|
||||
) {
|
||||
fun serviceWithHasher(hasher: AdminPasswordHasher) = AdminAuthService(
|
||||
repository = repository,
|
||||
passwordHasher = hasher,
|
||||
dummyPasswordHash = DUMMY_HASH,
|
||||
totpVerifier = totp,
|
||||
fieldEncryptor = encryptor,
|
||||
sessionTtl = Duration.ofHours(1),
|
||||
lockPolicy = lockPolicy,
|
||||
tokenGenerator = tokenGenerator,
|
||||
clock = clock,
|
||||
)
|
||||
}
|
||||
|
||||
private fun authFixture(
|
||||
lockPolicy: AdminLoginLockPolicy = AdminLoginLockPolicy(),
|
||||
): AuthFixture {
|
||||
val operatorId = UUID.randomUUID()
|
||||
val encryptor = FieldEncryptor(ByteArray(32) { 7 })
|
||||
val encryptedSecret = encryptor.encrypt(TOTP_SECRET_BASE32, adminTotpContext(operatorId))
|
||||
val repository = InMemoryAdminRepository(
|
||||
operatorId = operatorId,
|
||||
encryptedTotpSecret = encryptedSecret,
|
||||
)
|
||||
val totp = HmacTotpVerifier()
|
||||
val clock = MutableClock(Instant.parse("2026-08-16T00:00:10Z"))
|
||||
val tokenGenerator = CountingTokenGenerator()
|
||||
val fixture = AuthFixture(
|
||||
repository = repository,
|
||||
service = AdminAuthService(
|
||||
repository = repository,
|
||||
passwordHasher = TestPasswordHasher,
|
||||
dummyPasswordHash = DUMMY_HASH,
|
||||
totpVerifier = totp,
|
||||
fieldEncryptor = encryptor,
|
||||
sessionTtl = Duration.ofHours(1),
|
||||
lockPolicy = lockPolicy,
|
||||
tokenGenerator = tokenGenerator,
|
||||
clock = clock,
|
||||
),
|
||||
totp = totp,
|
||||
encryptor = encryptor,
|
||||
clock = clock,
|
||||
lockPolicy = lockPolicy,
|
||||
tokenGenerator = tokenGenerator,
|
||||
)
|
||||
return fixture
|
||||
}
|
||||
|
||||
private data object TestPasswordHasher : AdminPasswordHasher {
|
||||
override fun hash(password: CharArray): String = error("Not used")
|
||||
|
||||
override fun verify(password: CharArray, encodedHash: String): Boolean =
|
||||
encodedHash == "valid-password-hash" && password.concatToString() == CORRECT_PASSWORD
|
||||
}
|
||||
|
||||
private class CountingTokenGenerator : SecureTokenGenerator {
|
||||
private val counter = AtomicInteger()
|
||||
|
||||
override fun newRefreshToken(): String = "test-token-${counter.incrementAndGet()}"
|
||||
}
|
||||
|
||||
private class MutableClock(
|
||||
var current: Instant,
|
||||
) : Clock() {
|
||||
override fun getZone(): ZoneId = ZoneOffset.UTC
|
||||
override fun withZone(zone: ZoneId): Clock = this
|
||||
override fun instant(): Instant = current
|
||||
}
|
||||
|
||||
private const val CORRECT_PASSWORD = "correct-password"
|
||||
private const val DUMMY_HASH = "dummy-password-hash"
|
||||
private const val TOTP_SECRET_BASE32 = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
|
||||
private val TOTP_SECRET_BYTES = "12345678901234567890".toByteArray()
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package com.osglab.account.features.admin.services
|
||||
|
||||
import com.osglab.account.common.security.FieldEncryptor
|
||||
import com.osglab.account.features.admin.models.NewAdminOperator
|
||||
import com.osglab.account.features.admin.repositories.AdminRepository
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import java.util.UUID
|
||||
import kotlin.test.Test
|
||||
|
||||
class AdminBootstrapServiceTest {
|
||||
@Test
|
||||
fun `bootstrap encrypts TOTP secret and creates owner idempotently`() = runBlocking {
|
||||
val repository = mockk<AdminRepository>()
|
||||
val operator = slot<NewAdminOperator>()
|
||||
coEvery { repository.createOperatorIfAbsent(capture(operator)) } returns true
|
||||
val encryptor = FieldEncryptor(ByteArray(32) { 9 })
|
||||
val service = AdminBootstrapService(
|
||||
repository,
|
||||
encryptor,
|
||||
Clock.fixed(Instant.parse("2026-08-16T00:00:00Z"), ZoneOffset.UTC),
|
||||
)
|
||||
val operatorId = UUID.fromString("2c031def-4517-4fde-b592-5db3a3eefdf6")
|
||||
|
||||
service.initialize(
|
||||
AdminBootstrapConfig(
|
||||
enabled = true,
|
||||
operatorId = operatorId,
|
||||
username = "Owner",
|
||||
passwordHash = VALID_PASSWORD_HASH,
|
||||
totpSecretBase32 = TOTP_SECRET,
|
||||
),
|
||||
) shouldBe true
|
||||
|
||||
operator.captured.normalizedUsername shouldBe "owner"
|
||||
operator.captured.passwordHash shouldBe VALID_PASSWORD_HASH
|
||||
encryptor.decrypt(
|
||||
operator.captured.encryptedTotpSecret,
|
||||
adminTotpContext(operatorId),
|
||||
) shouldBe TOTP_SECRET
|
||||
coVerify(exactly = 1) { repository.createOperatorIfAbsent(any()) }
|
||||
}
|
||||
}
|
||||
|
||||
private const val TOTP_SECRET = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP"
|
||||
private const val VALID_PASSWORD_HASH =
|
||||
"\$argon2id\$v=19\$m=65536,t=3,p=1\$c2FsdA\$aGFzaA"
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
package com.osglab.account.features.admin.services
|
||||
|
||||
import com.osglab.account.common.security.FieldEncryptor
|
||||
import com.osglab.account.features.admin.InMemoryAdminRepository
|
||||
import com.osglab.account.features.admin.models.AdminAuditAction
|
||||
import com.osglab.account.features.admin.models.AdminAuditOutcome
|
||||
import com.osglab.account.features.admin.models.AdminLockState
|
||||
import com.osglab.account.features.admin.models.AdminOperatorRecord
|
||||
import com.osglab.account.features.admin.models.AdminPrincipal
|
||||
import com.osglab.account.features.admin.models.AdminRole
|
||||
import com.osglab.account.features.admin.models.AdminSessionRecord
|
||||
import com.osglab.account.features.admin.models.NewAdminOperator
|
||||
import com.osglab.account.features.admin.security.AdminPasswordHasher
|
||||
import com.osglab.account.features.admin.security.AdminTotpProvisioning
|
||||
import com.osglab.account.features.admin.security.AdminTotpSecretGenerator
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.collections.shouldHaveSize
|
||||
import io.kotest.matchers.collections.shouldContainExactly
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.string.shouldNotContain
|
||||
import java.time.Clock
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import java.util.UUID
|
||||
|
||||
class AdminOperatorServiceTest : FunSpec({
|
||||
test("super administrator creates normalized operator with one-time encrypted TOTP") {
|
||||
val fixture = operatorFixture()
|
||||
val password = "long-enough-password".toCharArray()
|
||||
|
||||
val created = fixture.service.create(
|
||||
fixture.owner,
|
||||
" Support.Agent ",
|
||||
password,
|
||||
"SUPPORT",
|
||||
"operator-create-1",
|
||||
)
|
||||
|
||||
created.operator?.normalizedUsername shouldBe "support.agent"
|
||||
created.operator?.role shouldBe AdminRole.SUPPORT
|
||||
created.totpSecret shouldBe TOTP_SECRET
|
||||
created.toString() shouldNotContain TOTP_SECRET
|
||||
password.all { it == '\u0000' } shouldBe true
|
||||
val stored = fixture.repository.findOperatorForAuthentication("support.agent")
|
||||
fixture.encryptor.decrypt(
|
||||
requireNotNull(stored).encryptedTotpSecret,
|
||||
adminTotpContext(stored.id),
|
||||
) shouldBe TOTP_SECRET
|
||||
fixture.repository.audits.last().run {
|
||||
action shouldBe AdminAuditAction.OPERATOR_CREATED
|
||||
outcome shouldBe AdminAuditOutcome.SUCCESS
|
||||
}
|
||||
}
|
||||
|
||||
test("duplicate normalized username returns stable conflict and denied audit") {
|
||||
val fixture = operatorFixture()
|
||||
fixture.service.create(
|
||||
fixture.owner,
|
||||
"duplicate",
|
||||
"long-enough-password".toCharArray(),
|
||||
"ANALYST",
|
||||
)
|
||||
|
||||
shouldThrow<AdminOperatorException> {
|
||||
fixture.service.create(
|
||||
fixture.owner,
|
||||
" DUPLICATE ",
|
||||
"another-long-password".toCharArray(),
|
||||
"SUPPORT",
|
||||
)
|
||||
}.code shouldBe AdminOperatorErrorCode.ADMIN_USERNAME_CONFLICT
|
||||
fixture.repository.audits.last().outcome shouldBe AdminAuditOutcome.DENIED
|
||||
}
|
||||
|
||||
test("self disable is rejected and audited") {
|
||||
val fixture = operatorFixture()
|
||||
|
||||
shouldThrow<AdminOperatorException> {
|
||||
fixture.service.setEnabled(fixture.owner, fixture.owner.operatorId, enabled = false)
|
||||
}.code shouldBe AdminOperatorErrorCode.CANNOT_DISABLE_SELF
|
||||
|
||||
fixture.repository.disabledAt shouldBe null
|
||||
fixture.repository.audits.single().run {
|
||||
action shouldBe AdminAuditAction.OPERATOR_DISABLED
|
||||
outcome shouldBe AdminAuditOutcome.DENIED
|
||||
}
|
||||
}
|
||||
|
||||
test("repository rejects disabling the final enabled super administrator") {
|
||||
val fixture = operatorFixture()
|
||||
val secondSuper = fixture.seedOperator("second-owner", AdminRole.SUPER_ADMIN)
|
||||
fixture.service.setEnabled(fixture.owner, secondSuper, enabled = false)
|
||||
val disabledPrincipal = fixture.owner.copy(operatorId = secondSuper)
|
||||
|
||||
shouldThrow<AdminOperatorException> {
|
||||
fixture.service.setEnabled(disabledPrincipal, fixture.owner.operatorId, enabled = false)
|
||||
}.code shouldBe AdminOperatorErrorCode.LAST_SUPER_ADMIN_REQUIRED
|
||||
|
||||
fixture.repository.disabledAt shouldBe null
|
||||
fixture.repository.audits.last().outcome shouldBe AdminAuditOutcome.DENIED
|
||||
}
|
||||
|
||||
test("credential reset clears lock state and revokes every target session") {
|
||||
val fixture = operatorFixture()
|
||||
val targetId = fixture.seedOperator("reset-target", AdminRole.SUPPORT)
|
||||
val session = AdminSessionRecord(
|
||||
id = UUID.randomUUID(),
|
||||
operatorId = targetId,
|
||||
normalizedUsername = "reset-target",
|
||||
role = AdminRole.SUPPORT,
|
||||
csrfTokenHash = "csrf-hash",
|
||||
expiresAt = fixture.clock.instant().plus(Duration.ofHours(1)),
|
||||
)
|
||||
fixture.repository.seedSession("target-session-hash", session)
|
||||
val password = "replacement-password".toCharArray()
|
||||
|
||||
val credentials = fixture.service.resetCredentials(
|
||||
fixture.owner,
|
||||
targetId,
|
||||
password,
|
||||
"credentials-reset-1",
|
||||
)
|
||||
|
||||
credentials.totpSecret shouldBe TOTP_SECRET
|
||||
password.all { it == '\u0000' } shouldBe true
|
||||
fixture.repository.revokedTokenHashes shouldBe setOf("target-session-hash")
|
||||
fixture.repository.findOperatorForAuthentication("reset-target")?.lockState shouldBe
|
||||
AdminLockState(0, null)
|
||||
fixture.repository.audits.last().run {
|
||||
action shouldBe AdminAuditAction.OPERATOR_CREDENTIALS_RESET
|
||||
outcome shouldBe AdminAuditOutcome.SUCCESS
|
||||
}
|
||||
}
|
||||
|
||||
test("security summary counts enabled operators and active sessions") {
|
||||
val fixture = operatorFixture()
|
||||
val targetId = fixture.seedOperator("support-summary", AdminRole.SUPPORT)
|
||||
fixture.repository.seedSession(
|
||||
"summary-session-hash",
|
||||
AdminSessionRecord(
|
||||
id = UUID.randomUUID(),
|
||||
operatorId = targetId,
|
||||
normalizedUsername = "support-summary",
|
||||
role = AdminRole.SUPPORT,
|
||||
csrfTokenHash = "csrf-hash",
|
||||
expiresAt = fixture.clock.instant().plus(Duration.ofHours(1)),
|
||||
),
|
||||
)
|
||||
|
||||
val summary = fixture.service.summary(fixture.owner)
|
||||
|
||||
summary.enabledOperators shouldBe 2
|
||||
summary.lockedOperators shouldBe 0
|
||||
summary.activeSessions shouldBe 1
|
||||
}
|
||||
|
||||
test("non-super administrator cannot mutate operators and denial is audited") {
|
||||
val fixture = operatorFixture()
|
||||
val support = fixture.owner.copy(role = AdminRole.SUPPORT)
|
||||
|
||||
shouldThrow<AdminOperatorException> {
|
||||
fixture.service.unlock(support, fixture.owner.operatorId)
|
||||
}.code shouldBe AdminOperatorErrorCode.INSUFFICIENT_PERMISSION
|
||||
|
||||
fixture.repository.audits.single().outcome shouldBe AdminAuditOutcome.DENIED
|
||||
}
|
||||
|
||||
test("username role and minimum password boundaries are validated") {
|
||||
val fixture = operatorFixture()
|
||||
val invalidRequests = listOf(
|
||||
Triple("ab", "long-enough-password", "SUPPORT"),
|
||||
Triple("valid-name", "elevenchars", "SUPPORT"),
|
||||
Triple("valid-name", "long-enough-password", "OWNER"),
|
||||
)
|
||||
|
||||
invalidRequests.forEach { (username, password, role) ->
|
||||
shouldThrow<AdminOperatorException> {
|
||||
fixture.service.create(
|
||||
fixture.owner,
|
||||
username,
|
||||
password.toCharArray(),
|
||||
role,
|
||||
)
|
||||
}.code shouldBe AdminOperatorErrorCode.VALIDATION_ERROR
|
||||
}
|
||||
|
||||
fixture.repository.listOperators() shouldHaveSize 1
|
||||
fixture.repository.audits.all { it.outcome == AdminAuditOutcome.DENIED } shouldBe true
|
||||
}
|
||||
|
||||
test("operator cursor pagination is stable across equal timestamps") {
|
||||
val fixture = operatorFixture()
|
||||
fixture.seedOperator("operator-a", AdminRole.SUPPORT)
|
||||
fixture.seedOperator("operator-b", AdminRole.ANALYST)
|
||||
fixture.seedOperator("operator-c", AdminRole.SUPPORT)
|
||||
val expected = fixture.repository.listOperators()
|
||||
.sortedWith(compareBy<AdminOperatorRecord> { it.createdAt }.thenBy { it.id.toString() })
|
||||
|
||||
val first = fixture.service.listPage(fixture.owner, cursor = null, limit = 2)
|
||||
val second = fixture.service.listPage(
|
||||
fixture.owner,
|
||||
cursor = requireNotNull(first.nextCursor),
|
||||
limit = 2,
|
||||
)
|
||||
|
||||
(first.items + second.items).map { it.id } shouldContainExactly expected.map { it.id }
|
||||
second.nextCursor shouldBe null
|
||||
}
|
||||
|
||||
test("invalid operator cursor returns a stable cursor error") {
|
||||
val fixture = operatorFixture()
|
||||
|
||||
shouldThrow<AdminOperatorCursorException> {
|
||||
fixture.service.listPage(fixture.owner, cursor = "not-a-cursor")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
private data class OperatorFixture(
|
||||
val repository: InMemoryAdminRepository,
|
||||
val service: AdminOperatorService,
|
||||
val owner: AdminPrincipal,
|
||||
val encryptor: FieldEncryptor,
|
||||
val clock: Clock,
|
||||
) {
|
||||
suspend fun seedOperator(username: String, role: AdminRole): UUID {
|
||||
val id = UUID.randomUUID()
|
||||
repository.createOperatorIfAbsent(
|
||||
NewAdminOperator(
|
||||
id = id,
|
||||
normalizedUsername = username,
|
||||
passwordHash = "seed-hash",
|
||||
encryptedTotpSecret = "seed-encrypted-secret",
|
||||
role = role,
|
||||
createdAt = clock.instant(),
|
||||
),
|
||||
)
|
||||
return id
|
||||
}
|
||||
}
|
||||
|
||||
private fun operatorFixture(): OperatorFixture {
|
||||
val ownerId = UUID.randomUUID()
|
||||
val encryptor = FieldEncryptor(ByteArray(32) { 4 })
|
||||
val repository = InMemoryAdminRepository(
|
||||
operatorId = ownerId,
|
||||
encryptedTotpSecret = "owner-encrypted-secret",
|
||||
)
|
||||
val clock = Clock.fixed(Instant.parse("2026-08-17T00:00:00Z"), ZoneOffset.UTC)
|
||||
val generator = AdminTotpSecretGenerator {
|
||||
AdminTotpProvisioning(
|
||||
secretBase32 = TOTP_SECRET,
|
||||
otpauthUri = "otpauth://totp/OSGKeyboard:test?secret=$TOTP_SECRET",
|
||||
)
|
||||
}
|
||||
return OperatorFixture(
|
||||
repository = repository,
|
||||
service = AdminOperatorService(
|
||||
repository = repository,
|
||||
passwordHasher = RecordingPasswordHasher,
|
||||
fieldEncryptor = encryptor,
|
||||
totpSecretGenerator = generator,
|
||||
clock = clock,
|
||||
),
|
||||
owner = AdminPrincipal(
|
||||
operatorId = ownerId,
|
||||
sessionId = UUID.randomUUID(),
|
||||
normalizedUsername = "owner",
|
||||
role = AdminRole.SUPER_ADMIN,
|
||||
),
|
||||
encryptor = encryptor,
|
||||
clock = clock,
|
||||
)
|
||||
}
|
||||
|
||||
private data object RecordingPasswordHasher : AdminPasswordHasher {
|
||||
override fun hash(password: CharArray): String = "hash:${password.concatToString()}"
|
||||
|
||||
override fun verify(password: CharArray, encodedHash: String): Boolean = false
|
||||
}
|
||||
|
||||
private const val TOTP_SECRET = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP"
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
package com.osglab.account.features.admin.services
|
||||
|
||||
import com.osglab.account.common.security.TokenHash
|
||||
import com.osglab.account.features.admin.InMemoryAdminRepository
|
||||
import com.osglab.account.features.admin.models.AdminAuditAction
|
||||
import com.osglab.account.features.admin.models.AdminRole
|
||||
import com.osglab.account.features.admin.models.AdminSessionRecord
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.shouldBe
|
||||
import java.time.Clock
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.util.UUID
|
||||
|
||||
class AdminSessionServiceTest : FunSpec({
|
||||
test("active session authenticates and mutation requires matching CSRF token") {
|
||||
val fixture = sessionFixture()
|
||||
|
||||
fixture.service.authenticate(SESSION_TOKEN)?.sessionId shouldBe fixture.sessionId
|
||||
fixture.service.authenticateMutation(SESSION_TOKEN, CSRF_TOKEN)?.sessionId shouldBe
|
||||
fixture.sessionId
|
||||
fixture.service.authenticateMutation(SESSION_TOKEN, "wrong-csrf") shouldBe null
|
||||
}
|
||||
|
||||
test("session is expired at the exact expiry boundary") {
|
||||
val fixture = sessionFixture()
|
||||
fixture.clock.current = fixture.expiresAt
|
||||
|
||||
fixture.service.authenticate(SESSION_TOKEN) shouldBe null
|
||||
}
|
||||
|
||||
test("revocation is idempotently denied after the first success") {
|
||||
val fixture = sessionFixture()
|
||||
|
||||
fixture.service.revoke(SESSION_TOKEN, "wrong-csrf", "request-2") shouldBe false
|
||||
fixture.service.revoke(SESSION_TOKEN, CSRF_TOKEN, "request-2") shouldBe true
|
||||
fixture.service.authenticate(SESSION_TOKEN) shouldBe null
|
||||
fixture.service.revoke(SESSION_TOKEN, CSRF_TOKEN, "request-2") shouldBe false
|
||||
fixture.repository.audits.single().action shouldBe AdminAuditAction.SESSION_REVOKED
|
||||
}
|
||||
|
||||
test("malformed token is rejected before repository lookup") {
|
||||
val fixture = sessionFixture()
|
||||
|
||||
fixture.service.authenticate("") shouldBe null
|
||||
fixture.service.authenticate("x".repeat(513)) shouldBe null
|
||||
}
|
||||
|
||||
test("invalid external request ID is omitted when revoking a session") {
|
||||
val fixture = sessionFixture()
|
||||
|
||||
fixture.service.revoke(
|
||||
SESSION_TOKEN,
|
||||
CSRF_TOKEN,
|
||||
"invalid request id with spaces",
|
||||
) shouldBe true
|
||||
|
||||
fixture.repository.audits.single().requestId shouldBe null
|
||||
}
|
||||
|
||||
test("cleanup removes only sessions inactive beyond retention") {
|
||||
val fixture = sessionFixture()
|
||||
val oldToken = "expired-session-token"
|
||||
fixture.repository.seedSession(
|
||||
TokenHash.sha256(oldToken),
|
||||
AdminSessionRecord(
|
||||
id = UUID.randomUUID(),
|
||||
operatorId = fixture.repository.operatorId,
|
||||
normalizedUsername = "admin@example.com",
|
||||
role = AdminRole.SUPPORT,
|
||||
csrfTokenHash = TokenHash.sha256(CSRF_TOKEN),
|
||||
expiresAt = fixture.clock.instant().minus(Duration.ofDays(8)),
|
||||
),
|
||||
)
|
||||
|
||||
fixture.service.cleanupInactive(Duration.ofDays(7), limit = 10) shouldBe 1
|
||||
fixture.repository.sessions.size shouldBe 1
|
||||
fixture.service.authenticate(SESSION_TOKEN)?.sessionId shouldBe fixture.sessionId
|
||||
}
|
||||
})
|
||||
|
||||
private data class SessionFixture(
|
||||
val repository: InMemoryAdminRepository,
|
||||
val service: AdminSessionService,
|
||||
val clock: SessionTestClock,
|
||||
val sessionId: UUID,
|
||||
val expiresAt: Instant,
|
||||
)
|
||||
|
||||
private suspend fun sessionFixture(): SessionFixture {
|
||||
val operatorId = UUID.randomUUID()
|
||||
val repository = InMemoryAdminRepository(
|
||||
operatorId = operatorId,
|
||||
encryptedTotpSecret = "not-used",
|
||||
)
|
||||
val now = Instant.parse("2026-08-16T00:00:00Z")
|
||||
val clock = SessionTestClock(now)
|
||||
val sessionId = UUID.randomUUID()
|
||||
val expiresAt = now.plusSeconds(60)
|
||||
repository.seedSession(
|
||||
TokenHash.sha256(SESSION_TOKEN),
|
||||
AdminSessionRecord(
|
||||
id = sessionId,
|
||||
operatorId = operatorId,
|
||||
normalizedUsername = "admin@example.com",
|
||||
role = AdminRole.SUPPORT,
|
||||
csrfTokenHash = TokenHash.sha256(CSRF_TOKEN),
|
||||
expiresAt = expiresAt,
|
||||
),
|
||||
)
|
||||
return SessionFixture(
|
||||
repository = repository,
|
||||
service = AdminSessionService(repository, clock = clock),
|
||||
clock = clock,
|
||||
sessionId = sessionId,
|
||||
expiresAt = expiresAt,
|
||||
)
|
||||
}
|
||||
|
||||
private class SessionTestClock(
|
||||
var current: Instant,
|
||||
) : Clock() {
|
||||
override fun getZone(): ZoneId = ZoneOffset.UTC
|
||||
override fun withZone(zone: ZoneId): Clock = this
|
||||
override fun instant(): Instant = current
|
||||
}
|
||||
|
||||
private const val SESSION_TOKEN = "opaque-session-token"
|
||||
private const val CSRF_TOKEN = "opaque-csrf-token"
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package com.osglab.account.features.admin.stats
|
||||
|
||||
import com.osglab.account.config.DatabaseConfig
|
||||
import com.osglab.account.config.DatabaseFactory
|
||||
import com.osglab.account.features.admin.stats.repositories.AdminStatsRange
|
||||
import com.osglab.account.features.admin.stats.repositories.ExposedAdminStatsRepository
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.collections.shouldBeEmpty
|
||||
import io.kotest.matchers.longs.shouldBeExactly
|
||||
import io.kotest.matchers.shouldBe
|
||||
import org.opentest4j.TestAbortedException
|
||||
import org.testcontainers.DockerClientFactory
|
||||
import org.testcontainers.containers.MySQLContainer
|
||||
import java.time.Instant
|
||||
|
||||
class AdminStatsRepositoryIntegrationTest : FunSpec({
|
||||
test("MySQL executes every aggregate query without loading entity rows") {
|
||||
val externalJdbcUrl = System.getenv("TEST_MYSQL_JDBC_URL")?.takeIf(String::isNotBlank)
|
||||
if (externalJdbcUrl == null && !DockerClientFactory.instance().isDockerAvailable) {
|
||||
throw TestAbortedException("Docker is unavailable; MySQL integration test skipped")
|
||||
}
|
||||
|
||||
val mysql = if (externalJdbcUrl == null) {
|
||||
StatsMySqlContainer("mysql:8.4")
|
||||
.withDatabaseName("osg_admin_stats_test")
|
||||
.withUsername("test")
|
||||
.withPassword("test")
|
||||
.also(StatsMySqlContainer::start)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val factory = DatabaseFactory(
|
||||
DatabaseConfig(
|
||||
jdbcUrl = externalJdbcUrl ?: requireNotNull(mysql).jdbcUrl,
|
||||
username = System.getenv("TEST_MYSQL_USER")?.takeIf(String::isNotBlank)
|
||||
?: mysql?.username
|
||||
?: "root",
|
||||
password = System.getenv("TEST_MYSQL_PASSWORD") ?: mysql?.password ?: "",
|
||||
maximumPoolSize = 2,
|
||||
),
|
||||
)
|
||||
|
||||
try {
|
||||
factory.database
|
||||
val snapshot = ExposedAdminStatsRepository(factory).load(
|
||||
AdminStatsRange(
|
||||
from = Instant.parse("2026-08-10T12:00:00Z"),
|
||||
until = Instant.parse("2026-08-17T12:00:00Z"),
|
||||
),
|
||||
)
|
||||
|
||||
// A dedicated container starts empty, so every scalar and grouped aggregate is explicit.
|
||||
if (mysql != null) {
|
||||
snapshot.overview.totalUsers shouldBeExactly 0
|
||||
snapshot.overview.totalCreditBalance shouldBeExactly 0
|
||||
snapshot.overview.activeUsers shouldBeExactly 0
|
||||
snapshot.referralFunnel.pendingBindings shouldBeExactly 0
|
||||
snapshot.referralFunnel.ineligibleBindings shouldBeExactly 0
|
||||
snapshot.registrationsByDate shouldBe emptyMap()
|
||||
snapshot.referralRanking.shouldBeEmpty()
|
||||
snapshot.usage.shouldBeEmpty()
|
||||
}
|
||||
} finally {
|
||||
factory.close()
|
||||
mysql?.stop()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
private class StatsMySqlContainer(image: String) :
|
||||
MySQLContainer<StatsMySqlContainer>(image)
|
||||
@@ -0,0 +1,162 @@
|
||||
package com.osglab.account.features.admin.stats
|
||||
|
||||
import com.osglab.account.features.admin.stats.models.AdminOverviewDto
|
||||
import com.osglab.account.features.admin.stats.models.AdminReferralFunnelDto
|
||||
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
|
||||
import com.osglab.account.features.admin.stats.repositories.AdminStatsAggregates
|
||||
import com.osglab.account.features.admin.stats.repositories.AdminStatsRange
|
||||
import com.osglab.account.features.admin.stats.repositories.AdminStatsRepository
|
||||
import com.osglab.account.features.admin.stats.repositories.AdminStatsSnapshot
|
||||
import com.osglab.account.features.admin.stats.repositories.ReferralBindingAggregateRow
|
||||
import com.osglab.account.features.admin.stats.repositories.assembleAdminStats
|
||||
import com.osglab.account.features.admin.stats.repositories.toExactLong
|
||||
import com.osglab.account.features.admin.stats.services.AdminStatsService
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.collections.shouldHaveSize
|
||||
import io.kotest.matchers.longs.shouldBeExactly
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.encodeToJsonElement
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import java.math.BigDecimal
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
|
||||
class AdminStatsRepositoryTest : FunSpec({
|
||||
val from = Instant.parse("2026-08-15T00:00:00Z")
|
||||
val until = Instant.parse("2026-08-17T00:00:00Z")
|
||||
|
||||
test("aggregated rows preserve referral ranking and usage output") {
|
||||
val snapshot = assembleAdminStats(
|
||||
AdminStatsAggregates(
|
||||
overview = AdminOverviewDto(
|
||||
totalUsers = 20,
|
||||
registrations = 4,
|
||||
activeUsers = 3,
|
||||
totalCreditBalance = 500,
|
||||
issuedCredits = 130,
|
||||
consumedCredits = 25,
|
||||
),
|
||||
registrationsByDate = mapOf(LocalDate.parse("2026-08-15") to 4),
|
||||
issuedCreditsByDate = mapOf(LocalDate.parse("2026-08-15") to 130),
|
||||
consumedCreditsByDate = mapOf(LocalDate.parse("2026-08-16") to 25),
|
||||
referralFunnel = AdminReferralFunnelDto(
|
||||
codesCreated = 5,
|
||||
bindings = 7,
|
||||
rewardedBindings = 4,
|
||||
pendingBindings = 2,
|
||||
ineligibleBindings = 1,
|
||||
),
|
||||
referralBindingsByInviter = listOf(
|
||||
ReferralBindingAggregateRow("user-b", invitedUsers = 3, rewardedUsers = 1),
|
||||
ReferralBindingAggregateRow("user-c", invitedUsers = 0, rewardedUsers = 1),
|
||||
ReferralBindingAggregateRow("user-a", invitedUsers = 3, rewardedUsers = 2),
|
||||
),
|
||||
referralCreditsByInviter = mapOf(
|
||||
"user-a" to 60,
|
||||
"user-b" to 30,
|
||||
"ledger-only-user" to 90,
|
||||
),
|
||||
usage = listOf(
|
||||
AdminUsageAggregateDto(
|
||||
kind = "LLM",
|
||||
requests = 2,
|
||||
chargedCredits = 20,
|
||||
asrMillis = 0,
|
||||
inputTokens = 100,
|
||||
outputTokens = 50,
|
||||
),
|
||||
AdminUsageAggregateDto(
|
||||
kind = "ASR",
|
||||
requests = 1,
|
||||
chargedCredits = 5,
|
||||
asrMillis = 500,
|
||||
inputTokens = 0,
|
||||
outputTokens = 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
snapshot.referralFunnel.pendingBindings shouldBeExactly 2
|
||||
snapshot.referralFunnel.ineligibleBindings shouldBeExactly 1
|
||||
snapshot.referralRanking.map { it.userId } shouldBe listOf("user-a", "user-b", "user-c")
|
||||
snapshot.referralRanking.first().earnedCredits shouldBeExactly 60
|
||||
snapshot.referralRanking.last().let {
|
||||
it.invitedUsers shouldBeExactly 0
|
||||
it.rewardedUsers shouldBeExactly 1
|
||||
it.earnedCredits shouldBeExactly 0
|
||||
}
|
||||
snapshot.referralRanking.none { it.userId == "ledger-only-user" } shouldBe true
|
||||
snapshot.usage.map { it.kind } shouldBe listOf("ASR", "LLM")
|
||||
snapshot.usage.first().asrMillis shouldBeExactly 500
|
||||
snapshot.usage.last().inputTokens shouldBeExactly 100
|
||||
}
|
||||
|
||||
test("service keeps exact 7 30 and 90 day buckets and serializes new outputs") {
|
||||
val snapshot = AdminStatsSnapshot(
|
||||
overview = AdminOverviewDto(0, 0, 0, 0, 0, 0),
|
||||
registrationsByDate = emptyMap(),
|
||||
issuedCreditsByDate = emptyMap(),
|
||||
consumedCreditsByDate = emptyMap(),
|
||||
referralFunnel = AdminReferralFunnelDto(
|
||||
codesCreated = 0,
|
||||
bindings = 0,
|
||||
rewardedBindings = 0,
|
||||
pendingBindings = 3,
|
||||
ineligibleBindings = 2,
|
||||
),
|
||||
referralRanking = emptyList(),
|
||||
usage = listOf(
|
||||
AdminUsageAggregateDto(
|
||||
kind = "ASR",
|
||||
requests = 4,
|
||||
chargedCredits = 12,
|
||||
asrMillis = 1_200,
|
||||
inputTokens = 0,
|
||||
outputTokens = 0,
|
||||
),
|
||||
),
|
||||
)
|
||||
val capturedRanges = mutableListOf<AdminStatsRange>()
|
||||
val service = AdminStatsService(
|
||||
AdminStatsRepository { range ->
|
||||
capturedRanges += range
|
||||
snapshot
|
||||
},
|
||||
)
|
||||
|
||||
listOf(7L, 30L, 90L).forEach { days ->
|
||||
val result = service.get(until.minus(Duration.ofDays(days)), until)
|
||||
|
||||
result.registrationTrend shouldHaveSize days.toInt()
|
||||
result.creditFlow shouldHaveSize days.toInt()
|
||||
capturedRanges.last() shouldBe AdminStatsRange(
|
||||
until.minus(Duration.ofDays(days)),
|
||||
until,
|
||||
)
|
||||
}
|
||||
|
||||
val result = service.get(from, until)
|
||||
val json = Json.encodeToJsonElement(result).jsonObject
|
||||
json["referralFunnel"]!!.jsonObject["pendingBindings"]!!.jsonPrimitive.content shouldBe "3"
|
||||
json["referralFunnel"]!!.jsonObject["ineligibleBindings"]!!.jsonPrimitive.content shouldBe "2"
|
||||
json["usage"]!!.jsonArray.single().jsonObject["requests"]!!.jsonPrimitive.content shouldBe "4"
|
||||
}
|
||||
|
||||
test("database decimal aggregates require an exact Long representation") {
|
||||
BigDecimal.valueOf(Long.MAX_VALUE).toExactLong() shouldBeExactly Long.MAX_VALUE
|
||||
BigDecimal.valueOf(Long.MIN_VALUE).toExactLong() shouldBeExactly Long.MIN_VALUE
|
||||
|
||||
shouldThrow<ArithmeticException> {
|
||||
BigDecimal.valueOf(Long.MAX_VALUE).add(BigDecimal.ONE).toExactLong()
|
||||
}
|
||||
shouldThrow<ArithmeticException> {
|
||||
BigDecimal("1.5").toExactLong()
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,229 @@
|
||||
package com.osglab.account.features.admin.users
|
||||
|
||||
import com.osglab.account.features.admin.users.models.AdminUserDetailDto
|
||||
import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
|
||||
import com.osglab.account.features.admin.users.models.AdminUserReferralDto
|
||||
import com.osglab.account.features.admin.users.models.AdminUserSummaryDto
|
||||
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
|
||||
import com.osglab.account.features.admin.users.repositories.AdminUserCursor
|
||||
import com.osglab.account.features.admin.users.repositories.AdminUserLedgerCursor
|
||||
import com.osglab.account.features.admin.users.repositories.AdminUsersRepository
|
||||
import com.osglab.account.features.admin.users.services.AdminUserNotFoundException
|
||||
import com.osglab.account.features.admin.users.services.AdminUsersService
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.collections.shouldHaveSize
|
||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||
import io.kotest.matchers.shouldBe
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
class AdminUsersServiceTest : FunSpec({
|
||||
test("detail preserves referral code usage referral and recent ledger data") {
|
||||
val userId = UUID.randomUUID()
|
||||
val detail = AdminUserDetailDto(
|
||||
summary = summary(userId, Instant.parse("2026-08-15T00:00:00Z")),
|
||||
referralCode = "OSG-REFERRAL",
|
||||
usage = listOf(
|
||||
AdminUsageAggregateDto(
|
||||
kind = "LLM",
|
||||
requests = 2,
|
||||
chargedCredits = 8,
|
||||
asrMillis = 0,
|
||||
inputTokens = 20,
|
||||
outputTokens = 10,
|
||||
),
|
||||
),
|
||||
referral = AdminUserReferralDto(
|
||||
inviterUserId = UUID.randomUUID().toString(),
|
||||
invitedUsers = 3,
|
||||
rewardedInvites = 2,
|
||||
),
|
||||
recentLedger = listOf(
|
||||
ledgerEntry(
|
||||
id = UUID.randomUUID(),
|
||||
createdAt = Instant.parse("2026-08-15T01:00:00Z"),
|
||||
),
|
||||
),
|
||||
)
|
||||
val service = AdminUsersService(
|
||||
PagingUsersRepository(
|
||||
users = emptyList(),
|
||||
details = mapOf(userId to detail),
|
||||
),
|
||||
)
|
||||
|
||||
service.detail(userId) shouldBe detail
|
||||
}
|
||||
|
||||
test("ledger returns a page and continuation cursor") {
|
||||
val userId = UUID.randomUUID()
|
||||
val entries = listOf(
|
||||
ledgerEntry(
|
||||
UUID.fromString("ffffffff-ffff-ffff-ffff-ffffffffffff"),
|
||||
Instant.parse("2026-08-15T03:00:00Z"),
|
||||
),
|
||||
ledgerEntry(
|
||||
UUID.fromString("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"),
|
||||
Instant.parse("2026-08-15T02:00:00Z"),
|
||||
),
|
||||
ledgerEntry(
|
||||
UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd"),
|
||||
Instant.parse("2026-08-15T01:00:00Z"),
|
||||
),
|
||||
)
|
||||
val service = AdminUsersService(
|
||||
PagingUsersRepository(emptyList(), ledger = mapOf(userId to entries)),
|
||||
)
|
||||
|
||||
val page = service.ledger(userId, limit = 2)
|
||||
|
||||
page.items shouldBe entries.take(2)
|
||||
page.nextCursor.shouldNotBeNull()
|
||||
}
|
||||
|
||||
test("ledger cursor pagination is stable for equal timestamps") {
|
||||
val createdAt = Instant.parse("2026-08-15T00:00:00Z")
|
||||
val ids = listOf(
|
||||
UUID.fromString("ffffffff-ffff-ffff-ffff-ffffffffffff"),
|
||||
UUID.fromString("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"),
|
||||
UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd"),
|
||||
)
|
||||
val userId = UUID.randomUUID()
|
||||
val entries = ids.map { ledgerEntry(it, createdAt) }
|
||||
val repository = PagingUsersRepository(
|
||||
users = emptyList(),
|
||||
ledger = mapOf(userId to entries),
|
||||
)
|
||||
val service = AdminUsersService(repository)
|
||||
|
||||
val first = service.ledger(userId, limit = 2)
|
||||
val second = service.ledger(
|
||||
userId,
|
||||
limit = 2,
|
||||
cursor = first.nextCursor.shouldNotBeNull(),
|
||||
)
|
||||
|
||||
first.items.map { it.id } shouldBe ids.take(2).map(UUID::toString)
|
||||
second.items.map { it.id } shouldBe listOf(ids.last().toString())
|
||||
second.nextCursor shouldBe null
|
||||
}
|
||||
|
||||
test("invalid ledger cursor throws a stable illegal argument exception") {
|
||||
val service = AdminUsersService(PagingUsersRepository(emptyList()))
|
||||
|
||||
val failure = shouldThrow<IllegalArgumentException> {
|
||||
service.ledger(UUID.randomUUID(), cursor = "not-a-cursor")
|
||||
}
|
||||
|
||||
failure.message shouldBe "User ledger cursor is invalid"
|
||||
}
|
||||
|
||||
test("ledger last page has no continuation cursor") {
|
||||
val userId = UUID.randomUUID()
|
||||
val entries = listOf(
|
||||
ledgerEntry(UUID.randomUUID(), Instant.parse("2026-08-15T02:00:00Z")),
|
||||
ledgerEntry(UUID.randomUUID(), Instant.parse("2026-08-15T01:00:00Z")),
|
||||
)
|
||||
val service = AdminUsersService(
|
||||
PagingUsersRepository(emptyList(), ledger = mapOf(userId to entries)),
|
||||
)
|
||||
|
||||
val page = service.ledger(userId, limit = 2)
|
||||
|
||||
page.items shouldHaveSize 2
|
||||
page.nextCursor shouldBe null
|
||||
}
|
||||
|
||||
test("ledger rejects a missing user instead of returning an empty page") {
|
||||
val service = AdminUsersService(PagingUsersRepository(emptyList()))
|
||||
|
||||
shouldThrow<AdminUserNotFoundException> {
|
||||
service.ledger(UUID.randomUUID())
|
||||
}
|
||||
}
|
||||
|
||||
test("invalid user cursor and missing detail fail without returning user data") {
|
||||
val service = AdminUsersService(PagingUsersRepository(emptyList()))
|
||||
|
||||
shouldThrow<IllegalArgumentException> {
|
||||
service.list(cursor = "not-a-cursor")
|
||||
}
|
||||
shouldThrow<AdminUserNotFoundException> {
|
||||
service.detail(UUID.randomUUID())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
private class PagingUsersRepository(
|
||||
private val users: List<AdminUserSummaryDto>,
|
||||
private val details: Map<UUID, AdminUserDetailDto> = emptyMap(),
|
||||
private val ledger: Map<UUID, List<AdminUserLedgerEntryDto>> = emptyMap(),
|
||||
) : AdminUsersRepository {
|
||||
override suspend fun list(
|
||||
limit: Int,
|
||||
cursor: AdminUserCursor?,
|
||||
): List<AdminUserSummaryDto> =
|
||||
users.filter {
|
||||
cursor == null ||
|
||||
Instant.parse(it.createdAt) < cursor.createdAt ||
|
||||
(
|
||||
Instant.parse(it.createdAt) == cursor.createdAt &&
|
||||
UUID.fromString(it.id).toString() < cursor.userId.toString()
|
||||
)
|
||||
}.take(limit)
|
||||
|
||||
override suspend fun findDetail(
|
||||
userId: UUID,
|
||||
ledgerLimit: Int,
|
||||
): AdminUserDetailDto? = details[userId]
|
||||
|
||||
override suspend fun exists(userId: UUID): Boolean =
|
||||
userId in details || userId in ledger || users.any { it.id == userId.toString() }
|
||||
|
||||
override suspend fun listLedger(
|
||||
userId: UUID,
|
||||
limit: Int,
|
||||
cursor: AdminUserLedgerCursor?,
|
||||
): List<AdminUserLedgerEntryDto> =
|
||||
ledger[userId].orEmpty()
|
||||
.filter {
|
||||
val createdAt = Instant.parse(it.createdAt)
|
||||
cursor == null ||
|
||||
createdAt < cursor.createdAt ||
|
||||
(
|
||||
createdAt == cursor.createdAt &&
|
||||
it.id < cursor.ledgerEntryId.toString()
|
||||
)
|
||||
}
|
||||
.sortedWith(
|
||||
compareByDescending<AdminUserLedgerEntryDto> { Instant.parse(it.createdAt) }
|
||||
.thenByDescending(AdminUserLedgerEntryDto::id),
|
||||
)
|
||||
.take(limit)
|
||||
}
|
||||
|
||||
private fun summary(id: UUID, createdAt: Instant) = AdminUserSummaryDto(
|
||||
id = id.toString(),
|
||||
createdAt = createdAt.toString(),
|
||||
antiAbuseRestricted = false,
|
||||
creditBalance = 0,
|
||||
consumedCredits = 0,
|
||||
manualGrantedCredits = 0,
|
||||
usageRequests = 0,
|
||||
lastActiveAt = null,
|
||||
invitedUsers = 0,
|
||||
rewardedInvites = 0,
|
||||
)
|
||||
|
||||
private fun ledgerEntry(
|
||||
id: UUID,
|
||||
createdAt: Instant,
|
||||
) = AdminUserLedgerEntryDto(
|
||||
id = id.toString(),
|
||||
type = "MANUAL_GRANT",
|
||||
amountDelta = 10,
|
||||
balanceAfter = 10,
|
||||
referenceId = null,
|
||||
createdAt = createdAt.toString(),
|
||||
)
|
||||
@@ -80,6 +80,161 @@ class CreditServiceTest : FunSpec({
|
||||
store.ledger.filter { it.type == LedgerEntryType.SIGNUP_TRIAL } shouldHaveSize 1
|
||||
}
|
||||
|
||||
test("manual grant appends one linked audit and ledger entry") {
|
||||
val store = storeWithRates(now)
|
||||
val service = service(store, now)
|
||||
val userId = UUID.randomUUID()
|
||||
store.registeredUsers += userId
|
||||
|
||||
val result = service.grantManual(
|
||||
operatorId = TEST_OPERATOR_ID,
|
||||
userId = userId,
|
||||
credits = 250,
|
||||
reason = "Customer support adjustment",
|
||||
requestId = "support-ticket-1042",
|
||||
idempotencyKey = "manual-grant-key-001",
|
||||
)
|
||||
|
||||
result.replayed shouldBe false
|
||||
result.balanceAfter shouldBeExactly 250
|
||||
store.balance(userId) shouldBeExactly 250
|
||||
store.manualGrants.single() shouldBe result.grant
|
||||
store.adminAudits.single().id shouldBe result.grant.auditLogId
|
||||
store.ledger.single { it.type == LedgerEntryType.MANUAL_GRANT }.let { ledger ->
|
||||
ledger.referenceId shouldBe result.grant.id
|
||||
ledger.id shouldBe result.grant.ledgerEntryId
|
||||
}
|
||||
}
|
||||
|
||||
test("manual grant replay is stable and rejects changed parameters") {
|
||||
val store = storeWithRates(now)
|
||||
val service = service(store, now)
|
||||
val userId = UUID.randomUUID()
|
||||
store.registeredUsers += userId
|
||||
val first = service.grantManual(
|
||||
TEST_OPERATOR_ID,
|
||||
userId,
|
||||
100,
|
||||
"Retention credit",
|
||||
"support-ticket-1043",
|
||||
"manual-grant-key-002",
|
||||
)
|
||||
val replay = service.grantManual(
|
||||
TEST_OPERATOR_ID,
|
||||
userId,
|
||||
100,
|
||||
"Retention credit",
|
||||
"support-ticket-1043",
|
||||
"manual-grant-key-002",
|
||||
)
|
||||
|
||||
replay.grant shouldBe first.grant
|
||||
replay.replayed shouldBe true
|
||||
shouldThrow<CreditConflict> {
|
||||
service.grantManual(
|
||||
TEST_OPERATOR_ID,
|
||||
userId,
|
||||
101,
|
||||
"Retention credit",
|
||||
"support-ticket-1043",
|
||||
"manual-grant-key-002",
|
||||
)
|
||||
}
|
||||
store.balance(userId) shouldBeExactly 100
|
||||
store.manualGrants shouldHaveSize 1
|
||||
store.ledger.filter { it.type == LedgerEntryType.MANUAL_GRANT } shouldHaveSize 1
|
||||
}
|
||||
|
||||
test("manual grant validates positive amount and rolls back an audit failure") {
|
||||
val store = storeWithRates(now)
|
||||
val service = service(store, now)
|
||||
val userId = UUID.randomUUID()
|
||||
store.registeredUsers += userId
|
||||
|
||||
shouldThrow<InvalidCreditRequest> {
|
||||
service.grantManual(
|
||||
TEST_OPERATOR_ID,
|
||||
userId,
|
||||
0,
|
||||
"Invalid adjustment",
|
||||
"support-ticket-1044",
|
||||
"manual-grant-key-003",
|
||||
)
|
||||
}
|
||||
store.failNextManualGrantInsert = true
|
||||
shouldThrow<IllegalStateException> {
|
||||
service.grantManual(
|
||||
TEST_OPERATOR_ID,
|
||||
userId,
|
||||
50,
|
||||
"Rollback adjustment",
|
||||
"support-ticket-1045",
|
||||
"manual-grant-key-004",
|
||||
)
|
||||
}
|
||||
|
||||
store.balance(userId) shouldBeExactly 0
|
||||
store.manualGrants shouldHaveSize 0
|
||||
store.adminAudits shouldHaveSize 0
|
||||
store.ledger.filter { it.type == LedgerEntryType.MANUAL_GRANT } shouldHaveSize 0
|
||||
}
|
||||
|
||||
test("concurrent manual grant replay credits exactly once") {
|
||||
val store = storeWithRates(now)
|
||||
val service = service(store, now)
|
||||
val userId = UUID.randomUUID()
|
||||
store.registeredUsers += userId
|
||||
|
||||
val results = coroutineScope {
|
||||
List(8) {
|
||||
async(Dispatchers.Default) {
|
||||
service.grantManual(
|
||||
TEST_OPERATOR_ID,
|
||||
userId,
|
||||
75,
|
||||
"Concurrent adjustment",
|
||||
"support-ticket-1046",
|
||||
"manual-grant-key-005",
|
||||
)
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
|
||||
results.map { it.grant.id }.distinct() shouldHaveSize 1
|
||||
store.balance(userId) shouldBeExactly 75
|
||||
store.manualGrants shouldHaveSize 1
|
||||
store.ledger.filter { it.type == LedgerEntryType.MANUAL_GRANT } shouldHaveSize 1
|
||||
}
|
||||
|
||||
test("manual grant supports the maximum balance and rejects overflow") {
|
||||
val store = storeWithRates(now)
|
||||
val service = service(store, now)
|
||||
val userId = UUID.randomUUID()
|
||||
store.registeredUsers += userId
|
||||
service.grantManual(
|
||||
TEST_OPERATOR_ID,
|
||||
userId,
|
||||
Long.MAX_VALUE,
|
||||
"Maximum supported adjustment",
|
||||
"support-ticket-1047",
|
||||
"manual-grant-key-006",
|
||||
)
|
||||
|
||||
shouldThrow<InvalidCreditRequest> {
|
||||
service.grantManual(
|
||||
TEST_OPERATOR_ID,
|
||||
userId,
|
||||
1,
|
||||
"Overflow adjustment",
|
||||
"support-ticket-1048",
|
||||
"manual-grant-key-007",
|
||||
)
|
||||
}
|
||||
|
||||
store.balance(userId) shouldBeExactly Long.MAX_VALUE
|
||||
store.manualGrants shouldHaveSize 1
|
||||
}
|
||||
|
||||
test("balance overflow rolls back without appending a ledger entry") {
|
||||
val store = storeWithRates(now)
|
||||
val service = service(store, now)
|
||||
@@ -574,3 +729,5 @@ private fun llmRate(now: Instant) = CreditRateVersion(
|
||||
outputCreditsNumerator = 3,
|
||||
outputTokensDenominator = 1_000,
|
||||
)
|
||||
|
||||
private val TEST_OPERATOR_ID = UUID.fromString("11111111-1111-1111-1111-111111111111")
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package com.osglab.account.features.credits
|
||||
|
||||
import com.osglab.account.features.admin.grants.repositories.AdminCreditGrantRepository
|
||||
import com.osglab.account.features.admin.models.NewAdminAuditEvent
|
||||
import com.osglab.account.features.credits.domain.CreditAccount
|
||||
import com.osglab.account.features.credits.domain.CreditNotFound
|
||||
import com.osglab.account.features.credits.domain.CreditRateVersion
|
||||
import com.osglab.account.features.credits.domain.CreditReservation
|
||||
import com.osglab.account.features.credits.domain.CreditUsageRecord
|
||||
import com.osglab.account.features.credits.domain.LedgerEntry
|
||||
import com.osglab.account.features.credits.domain.ManualCreditGrant
|
||||
import com.osglab.account.features.credits.domain.UsageKind
|
||||
import com.osglab.account.features.credits.repositories.BillingTransactionRunner
|
||||
import com.osglab.account.features.credits.repositories.BillingUnitOfWork
|
||||
@@ -25,7 +28,10 @@ import kotlin.concurrent.withLock
|
||||
class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
|
||||
private val lock = ReentrantLock()
|
||||
private val accounts = mutableMapOf<UUID, CreditAccount>()
|
||||
val registeredUsers = mutableSetOf<UUID>()
|
||||
val ledger = mutableListOf<LedgerEntry>()
|
||||
val manualGrants = mutableListOf<ManualCreditGrant>()
|
||||
val adminAudits = mutableListOf<NewAdminAuditEvent>()
|
||||
val usageRecords = mutableListOf<CreditUsageRecord>()
|
||||
val reservations = mutableMapOf<UUID, CreditReservation>()
|
||||
val rates = mutableMapOf<UUID, CreditRateVersion>()
|
||||
@@ -56,11 +62,17 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
|
||||
|
||||
override val credits: CreditsRepository = Credits()
|
||||
override val referrals: ReferralsRepository = Referrals()
|
||||
override val adminCreditGrants: AdminCreditGrantRepository = AdminCreditGrants()
|
||||
|
||||
var failNextManualGrantInsert = false
|
||||
|
||||
override suspend fun <T> inTransaction(block: (BillingUnitOfWork) -> T): T =
|
||||
lock.withLock {
|
||||
val accountSnapshot = accounts.toMap()
|
||||
val registeredUserSnapshot = registeredUsers.toSet()
|
||||
val ledgerSnapshot = ledger.toList()
|
||||
val manualGrantSnapshot = manualGrants.toList()
|
||||
val adminAuditSnapshot = adminAudits.toList()
|
||||
val usageSnapshot = usageRecords.toList()
|
||||
val reservationSnapshot = reservations.toMap()
|
||||
val codeSnapshot = codes.toMap()
|
||||
@@ -70,7 +82,10 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
|
||||
block(this)
|
||||
} catch (failure: Throwable) {
|
||||
accounts.replaceWith(accountSnapshot)
|
||||
registeredUsers.replaceWith(registeredUserSnapshot)
|
||||
ledger.replaceWith(ledgerSnapshot)
|
||||
manualGrants.replaceWith(manualGrantSnapshot)
|
||||
adminAudits.replaceWith(adminAuditSnapshot)
|
||||
usageRecords.replaceWith(usageSnapshot)
|
||||
reservations.replaceWith(reservationSnapshot)
|
||||
codes.replaceWith(codeSnapshot)
|
||||
@@ -83,6 +98,9 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
|
||||
fun balance(userId: UUID): Long = lock.withLock { accounts[userId]?.balance ?: 0 }
|
||||
|
||||
private inner class Credits : CreditsRepository {
|
||||
override fun accountExists(userId: UUID): Boolean =
|
||||
userId in registeredUsers || userId in accounts
|
||||
|
||||
override fun createAccountIfAbsent(userId: UUID, now: Instant) {
|
||||
accounts.putIfAbsent(userId, CreditAccount(userId, 0, now))
|
||||
}
|
||||
@@ -157,6 +175,28 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
|
||||
}
|
||||
}
|
||||
|
||||
private inner class AdminCreditGrants : AdminCreditGrantRepository {
|
||||
override fun findByIdempotencyKey(
|
||||
idempotencyKey: String,
|
||||
): ManualCreditGrant? =
|
||||
manualGrants.singleOrNull { it.idempotencyKey == idempotencyKey }
|
||||
|
||||
override fun insertAudit(event: NewAdminAuditEvent) {
|
||||
check(adminAudits.none { it.id == event.id })
|
||||
adminAudits += event
|
||||
}
|
||||
|
||||
override fun insert(grant: ManualCreditGrant) {
|
||||
if (failNextManualGrantInsert) {
|
||||
failNextManualGrantInsert = false
|
||||
error("Simulated manual grant audit failure")
|
||||
}
|
||||
check(findByIdempotencyKey(grant.idempotencyKey) == null)
|
||||
check(manualGrants.none { it.id == grant.id || it.ledgerEntryId == grant.ledgerEntryId })
|
||||
manualGrants += grant
|
||||
}
|
||||
}
|
||||
|
||||
private inner class Referrals : ReferralsRepository {
|
||||
override fun findCodeByOwner(ownerUserId: UUID, campaignId: UUID?): ReferralCode? =
|
||||
codes.values
|
||||
@@ -238,6 +278,11 @@ private fun <K, V> MutableMap<K, V>.replaceWith(snapshot: Map<K, V>) {
|
||||
putAll(snapshot)
|
||||
}
|
||||
|
||||
private fun <T> MutableSet<T>.replaceWith(snapshot: Set<T>) {
|
||||
clear()
|
||||
addAll(snapshot)
|
||||
}
|
||||
|
||||
private fun <T> MutableList<T>.replaceWith(snapshot: List<T>) {
|
||||
clear()
|
||||
addAll(snapshot)
|
||||
|
||||
@@ -20,6 +20,7 @@ import com.osglab.account.features.referrals.services.UserRegistrationTimeProvid
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.assertions.withClue
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.collections.shouldHaveSize
|
||||
import io.kotest.matchers.ints.shouldBeExactly
|
||||
import io.kotest.matchers.longs.shouldBeExactly
|
||||
import io.kotest.matchers.nulls.shouldBeNull
|
||||
@@ -208,8 +209,21 @@ class MySqlSecurityIntegrationTest : FunSpec({
|
||||
ReferralRewardConfig(inviterCredits = 30, inviteeCredits = 30),
|
||||
)
|
||||
val concurrentUser = UUID.randomUUID()
|
||||
val adminOperator = UUID.randomUUID()
|
||||
connection().use {
|
||||
insertAccount(it, concurrentUser, "concurrent-sub", identity.ofAppleSubject("concurrent-sub"))
|
||||
it.createStatement().use { statement ->
|
||||
statement.executeUpdate(
|
||||
"""
|
||||
INSERT INTO admin_operators (
|
||||
id, username, password_hash, encrypted_totp_secret, role
|
||||
) VALUES (
|
||||
'$adminOperator', 'integration-admin', 'unused-hash',
|
||||
'unused-secret', 'SUPER_ADMIN'
|
||||
)
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
}
|
||||
credits.grantSignupTrial(concurrentUser, 100, "integration-signup-concurrent")
|
||||
val reservationResults = coroutineScope {
|
||||
@@ -236,6 +250,34 @@ class MySqlSecurityIntegrationTest : FunSpec({
|
||||
reservationResults.count { it.isSuccess } shouldBeExactly 1
|
||||
}
|
||||
credits.getAccount(concurrentUser).balance shouldBeExactly 40
|
||||
val manualGrantResults = coroutineScope {
|
||||
List(4) {
|
||||
async(Dispatchers.Default) {
|
||||
credits.grantManual(
|
||||
operatorId = adminOperator,
|
||||
userId = concurrentUser,
|
||||
credits = 25,
|
||||
reason = "Integration support adjustment",
|
||||
requestId = "integration-audit-reference",
|
||||
idempotencyKey = "integration-manual-grant",
|
||||
)
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
manualGrantResults.map { it.grant.id }.distinct() shouldHaveSize 1
|
||||
credits.getAccount(concurrentUser).balance shouldBeExactly 65
|
||||
connection().use { connection ->
|
||||
count(
|
||||
connection,
|
||||
"admin_credit_grants",
|
||||
"account_id = '$concurrentUser'",
|
||||
) shouldBeExactly 1
|
||||
count(
|
||||
connection,
|
||||
"credit_ledger",
|
||||
"user_id = '$concurrentUser' AND entry_type = 'MANUAL_GRANT'",
|
||||
) shouldBeExactly 1
|
||||
}
|
||||
|
||||
val inviter = UUID.randomUUID()
|
||||
val invitee = UUID.randomUUID()
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.osglab.account.tools
|
||||
|
||||
import io.kotest.matchers.string.shouldContain
|
||||
import java.nio.file.Files
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFailsWith
|
||||
|
||||
class AdminCredentialGeneratorTest {
|
||||
@Test
|
||||
fun `generator creates separate runtime and operator files without overwriting`() {
|
||||
val directory = Files.createTempDirectory("admin-credentials-test")
|
||||
val runtime = directory.resolve("runtime.env")
|
||||
val handoff = directory.resolve("handoff.txt")
|
||||
|
||||
AdminCredentialGenerator.main(
|
||||
arrayOf("Owner", runtime.toString(), handoff.toString()),
|
||||
)
|
||||
|
||||
Files.readString(runtime) shouldContain "ADMIN_BOOTSTRAP_ENABLED=true"
|
||||
Files.readString(runtime) shouldContain "ADMIN_BOOTSTRAP_USERNAME=owner"
|
||||
Files.readString(runtime) shouldContain "ADMIN_BOOTSTRAP_PASSWORD_HASH='\$argon2id\$"
|
||||
Files.readString(handoff) shouldContain "认证器 URI:otpauth://totp/"
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
AdminCredentialGenerator.main(
|
||||
arrayOf("owner", runtime.toString(), handoff.toString()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user