use std::sync::Arc;
use axum::{
Json, Router,
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
};
use chrono::{DateTime, Utc};
use serde::Deserialize;
use serde_json::json;
use sqlx::{PgPool, Row as _};
use subtle::ConstantTimeEq as _;
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AdminPrincipal {
Platform,
Tenant(Uuid),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ForeignTenant;
impl AdminPrincipal {
pub fn scope(self, requested: Option<Uuid>) -> Result<Option<Uuid>, ForeignTenant> {
match (self, requested) {
(Self::Platform, requested) => Ok(requested),
(Self::Tenant(own), None) => Ok(Some(own)),
(Self::Tenant(own), Some(named)) if named == own => Ok(Some(own)),
(Self::Tenant(_), Some(_)) => Err(ForeignTenant),
}
}
#[must_use]
pub fn may_see(self, row_tenant: Option<Uuid>) -> bool {
match self {
Self::Platform => true,
Self::Tenant(own) => row_tenant == Some(own),
}
}
#[must_use]
pub const fn is_platform(self) -> bool {
matches!(self, Self::Platform)
}
}
#[must_use]
pub fn foreign_tenant_response() -> Response {
(
StatusCode::FORBIDDEN,
Json(json!({ "error": "this credential administers a different tenant" })),
)
.into_response()
}
const TOKEN_BYTES: usize = 32;
pub const TOKEN_PREFIX: &str = "fraiseql_ta_";
pub const SCHEMA_SQL: &str = r"
CREATE SCHEMA IF NOT EXISTS core;
-- Tenant admin credentials (#1089). Only sha256(token) is stored, so reading the table
-- cannot yield a usable credential. Every row is confined to one tenant: a deployment-wide
-- administrator is the admin_token, never a row here.
CREATE TABLE IF NOT EXISTS core.tb_admin_token (
pk_admin_token BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
id UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE,
token_hash TEXT NOT NULL UNIQUE,
tenant_id UUID NOT NULL,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_admin_token_tenant ON core.tb_admin_token (tenant_id);
-- Never world-readable; only the server's own role touches it.
REVOKE ALL ON core.tb_admin_token FROM PUBLIC;
";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdminTokenRecord {
pub id: Uuid,
pub tenant_id: Uuid,
pub description: Option<String>,
pub created_at: DateTime<Utc>,
pub last_used_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone)]
pub struct PgAdminTokenStore {
db: PgPool,
}
fn hash_token(token: &str) -> String {
use sha2::{Digest as _, Sha256};
hex::encode(Sha256::digest(token.as_bytes()))
}
impl PgAdminTokenStore {
#[must_use]
pub const fn new(db: PgPool) -> Self {
Self { db }
}
pub async fn ensure_schema(&self) -> Result<(), sqlx::Error> {
sqlx::raw_sql(SCHEMA_SQL).execute(&self.db).await.map(|_| ())
}
pub async fn mint(
&self,
tenant_id: Uuid,
description: Option<&str>,
) -> Result<(AdminTokenRecord, String), sqlx::Error> {
use base64::Engine as _;
use rand::RngCore as _;
let mut bytes = [0u8; TOKEN_BYTES];
rand::rng().fill_bytes(&mut bytes);
let token = format!(
"{TOKEN_PREFIX}{}",
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
);
let row = sqlx::query(
"INSERT INTO core.tb_admin_token (token_hash, tenant_id, description) \
VALUES ($1, $2, $3) \
RETURNING id, tenant_id, description, created_at, last_used_at",
)
.bind(hash_token(&token))
.bind(tenant_id)
.bind(description)
.fetch_one(&self.db)
.await?;
Ok((decode(&row), token))
}
pub async fn authenticate(&self, token: &str) -> Result<Option<Uuid>, sqlx::Error> {
if !token.starts_with(TOKEN_PREFIX) {
return Ok(None);
}
let presented = hash_token(token);
let Some(row) = sqlx::query(
"SELECT id, token_hash, tenant_id FROM core.tb_admin_token \
WHERE token_hash = $1 AND revoked_at IS NULL",
)
.bind(&presented)
.fetch_optional(&self.db)
.await?
else {
return Ok(None);
};
let stored: String = row.get("token_hash");
if stored.as_bytes().ct_eq(presented.as_bytes()).unwrap_u8() != 1 {
return Ok(None);
}
let id: Uuid = row.get("id");
let _ = sqlx::query("UPDATE core.tb_admin_token SET last_used_at = now() WHERE id = $1")
.bind(id)
.execute(&self.db)
.await;
Ok(Some(row.get("tenant_id")))
}
pub async fn list(
&self,
tenant_id: Option<Uuid>,
) -> Result<Vec<AdminTokenRecord>, sqlx::Error> {
let rows = sqlx::query(
"SELECT id, tenant_id, description, created_at, last_used_at \
FROM core.tb_admin_token \
WHERE revoked_at IS NULL AND ($1::uuid IS NULL OR tenant_id = $1) \
ORDER BY created_at",
)
.bind(tenant_id)
.fetch_all(&self.db)
.await?;
Ok(rows.iter().map(decode).collect())
}
pub async fn revoke(&self, id: Uuid) -> Result<bool, sqlx::Error> {
let affected = sqlx::query(
"UPDATE core.tb_admin_token SET revoked_at = now() \
WHERE id = $1 AND revoked_at IS NULL",
)
.bind(id)
.execute(&self.db)
.await?
.rows_affected();
Ok(affected > 0)
}
}
fn decode(row: &sqlx::postgres::PgRow) -> AdminTokenRecord {
AdminTokenRecord {
id: row.get("id"),
tenant_id: row.get("tenant_id"),
description: row.get("description"),
created_at: row.get("created_at"),
last_used_at: row.get("last_used_at"),
}
}
#[derive(Clone)]
pub struct AdminTokenManagementState {
pub tokens: Arc<PgAdminTokenStore>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MintAdminTokenRequest {
pub tenant_id: Uuid,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ListAdminTokensQuery {
#[serde(default)]
pub tenant_id: Option<Uuid>,
}
pub fn admin_token_management_router(state: AdminTokenManagementState) -> Router {
Router::new()
.route("/api/admin-tokens", axum::routing::post(mint).get(list))
.route("/api/admin-tokens/{id}", axum::routing::delete(revoke))
.with_state(Arc::new(state))
}
fn store_failure(context: &str, e: &sqlx::Error) -> Response {
tracing::error!(error = %e, "{context}");
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": context }))).into_response()
}
fn record_json(r: &AdminTokenRecord) -> serde_json::Value {
json!({
"id": r.id,
"tenant_id": r.tenant_id,
"description": r.description,
"created_at": r.created_at,
"last_used_at": r.last_used_at,
})
}
async fn mint(
State(state): State<Arc<AdminTokenManagementState>>,
Json(body): Json<MintAdminTokenRequest>,
) -> Response {
match state.tokens.mint(body.tenant_id, body.description.as_deref()).await {
Ok((record, token)) => {
let mut out = record_json(&record);
out["token"] = json!(token);
(StatusCode::CREATED, Json(out)).into_response()
},
Err(e) => store_failure("could not mint tenant admin token", &e),
}
}
async fn list(
State(state): State<Arc<AdminTokenManagementState>>,
axum::extract::Query(q): axum::extract::Query<ListAdminTokensQuery>,
) -> Response {
match state.tokens.list(q.tenant_id).await {
Ok(records) => Json(json!({
"total": records.len(),
"tokens": records.iter().map(record_json).collect::<Vec<_>>(),
}))
.into_response(),
Err(e) => store_failure("could not list tenant admin tokens", &e),
}
}
async fn revoke(
State(state): State<Arc<AdminTokenManagementState>>,
Path(id): Path<String>,
) -> Response {
let not_found =
|| (StatusCode::NOT_FOUND, Json(json!({ "error": "no such token" }))).into_response();
let Ok(id) = Uuid::parse_str(&id) else {
return not_found();
};
match state.tokens.revoke(id).await {
Ok(true) => StatusCode::NO_CONTENT.into_response(),
Ok(false) => not_found(),
Err(e) => store_failure("could not revoke tenant admin token", &e),
}
}
#[cfg(test)]
mod tests;