use super::{ApiPermission, DatabaseManager};
use alloy::primitives::Address;
use chrono::{DateTime, Utc};
use serde_json::Value as JsonValue;
use sqlx::{postgres::PgRow, Row};
use std::collections::HashSet;
use tracing::{error, info};
use uuid::Uuid;
pub fn redact_bearer(key: &str) -> String {
let prefix_len = key.find('_').map(|p| p + 1).unwrap_or(0).min(key.len());
let prefix = &key[..prefix_len];
format!("{prefix}*** ({}c)", key.len())
}
#[derive(Clone)]
pub struct ApiKeyRecord {
pub id: Uuid,
pub user_id: Uuid,
pub address: Address,
pub api_key: String,
pub name: String,
pub permissions: HashSet<ApiPermission>,
pub rate_limit: Option<u32>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub is_active: bool,
pub expires_at: Option<DateTime<Utc>>,
pub description: Option<String>,
}
impl std::fmt::Debug for ApiKeyRecord {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ApiKeyRecord")
.field("id", &self.id)
.field("user_id", &self.user_id)
.field("address", &self.address)
.field("api_key", &redact_bearer(&self.api_key))
.field("name", &self.name)
.field("permissions", &self.permissions)
.field("rate_limit", &self.rate_limit)
.field("created_at", &self.created_at)
.field("updated_at", &self.updated_at)
.field("is_active", &self.is_active)
.field("expires_at", &self.expires_at)
.field("description", &self.description)
.finish()
}
}
impl ApiKeyRecord {
pub fn is_valid(&self) -> bool {
if !self.is_active {
return false;
}
if let Some(expires_at) = self.expires_at {
if expires_at < Utc::now() {
return false;
}
}
true
}
pub fn has_permission(&self, permission: &ApiPermission) -> bool {
self.permissions.iter().any(|p| p.implies(permission))
}
}
#[derive(Debug, Clone)]
pub struct ApiKeyRepository {
db: DatabaseManager,
}
#[derive(Debug, Default)]
pub struct ApiKeyUpdate {
pub name: Option<String>,
pub permissions: Option<HashSet<ApiPermission>>,
pub rate_limit: Option<Option<u32>>,
pub is_active: Option<bool>,
pub description: Option<Option<String>>,
pub expires_at: Option<Option<DateTime<Utc>>>,
}
impl ApiKeyRepository {
pub fn new(db: DatabaseManager) -> Self {
Self { db }
}
pub async fn get_by_key(&self, key: &str) -> sqlx::Result<Option<ApiKeyRecord>> {
let row = sqlx::query(
r#"
SELECT id, user_id, address, api_key, name, permissions, rate_limit, created_at, updated_at,
is_active, expires_at, description
FROM api_keys
WHERE api_key = $1
"#,
)
.bind(key)
.fetch_optional(self.db.pool())
.await?;
Ok(row.map(Self::row_to_record))
}
pub async fn get_all_active(&self) -> sqlx::Result<Vec<ApiKeyRecord>> {
let rows = sqlx::query(
r#"
SELECT id, user_id, address, api_key, name, permissions, rate_limit, created_at, updated_at,
is_active, expires_at, description
FROM api_keys
WHERE is_active = true
ORDER BY created_at DESC
"#,
)
.fetch_all(self.db.pool())
.await?;
Ok(rows.into_iter().map(Self::row_to_record).collect())
}
#[allow(clippy::too_many_arguments)]
pub async fn create(
&self,
user_id: Uuid,
address: Address,
api_key: String,
name: String,
permissions: HashSet<ApiPermission>,
rate_limit: Option<u32>,
description: Option<String>,
expires_at: Option<DateTime<Utc>>,
) -> sqlx::Result<ApiKeyRecord> {
let permissions_json = Self::permissions_to_json(&permissions);
let row = sqlx::query(
r#"
INSERT INTO api_keys (user_id, address, api_key, name, permissions, rate_limit, description, expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, user_id, address, api_key, name, permissions, rate_limit, created_at, updated_at,
is_active, expires_at, description
"#,
)
.bind(user_id)
.bind(address.as_slice())
.bind(&api_key)
.bind(&name)
.bind(permissions_json)
.bind(rate_limit.map(|r| r as i32))
.bind(description)
.bind(expires_at)
.fetch_one(self.db.pool())
.await?;
info!(
"Created new API key: name={name} bearer={} address={address}",
redact_bearer(&api_key)
);
Ok(Self::row_to_record(row))
}
pub async fn rotate(&self, old_api_key: &str, new_api_key: String, new_name: String) -> sqlx::Result<ApiKeyRecord> {
let mut tx = self.db.pool().begin().await?;
let old_row = sqlx::query(
r#"
UPDATE api_keys
SET is_active = false, updated_at = NOW()
WHERE api_key = $1 AND is_active = true
RETURNING id, user_id, address, api_key, name, permissions, rate_limit, created_at, updated_at,
is_active, expires_at, description
"#,
)
.bind(old_api_key)
.fetch_one(&mut *tx)
.await?;
let old = Self::row_to_record(old_row);
let permissions_json = Self::permissions_to_json(&old.permissions);
let new_row = sqlx::query(
r#"
INSERT INTO api_keys (user_id, address, api_key, name, permissions, rate_limit, description, expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, user_id, address, api_key, name, permissions, rate_limit, created_at, updated_at,
is_active, expires_at, description
"#,
)
.bind(old.user_id)
.bind(old.address.as_slice())
.bind(&new_api_key)
.bind(&new_name)
.bind(permissions_json)
.bind(old.rate_limit.map(|r| r as i32))
.bind(old.description.clone())
.bind::<Option<DateTime<Utc>>>(old.expires_at)
.fetch_one(&mut *tx)
.await?;
tx.commit().await?;
info!(
"Rotated API key: old_id={} old_bearer={} new_bearer={} address={}",
old.id,
redact_bearer(old_api_key),
redact_bearer(&new_api_key),
old.address
);
Ok(Self::row_to_record(new_row))
}
pub async fn update(&self, key: &str, update: ApiKeyUpdate) -> sqlx::Result<ApiKeyRecord> {
let mut query = String::from("UPDATE api_keys SET updated_at = NOW()");
let mut param_count = 1;
if update.name.is_some() {
param_count += 1;
query.push_str(&format!(", name = ${}", param_count));
}
if update.permissions.is_some() {
param_count += 1;
query.push_str(&format!(", permissions = ${}", param_count));
}
if update.rate_limit.is_some() {
param_count += 1;
query.push_str(&format!(", rate_limit = ${}", param_count));
}
if update.is_active.is_some() {
param_count += 1;
query.push_str(&format!(", is_active = ${}", param_count));
}
if update.description.is_some() {
param_count += 1;
query.push_str(&format!(", description = ${}", param_count));
}
if update.expires_at.is_some() {
param_count += 1;
query.push_str(&format!(", expires_at = ${}", param_count));
}
query.push_str(
" WHERE api_key = $1 RETURNING id, user_id, address, api_key, name, permissions, rate_limit, created_at, updated_at, is_active, expires_at, description",
);
let mut q = sqlx::query(&query).bind(key);
if let Some(n) = update.name {
q = q.bind(n);
}
if let Some(p) = update.permissions {
q = q.bind(Self::permissions_to_json(&p));
}
if let Some(r) = update.rate_limit {
q = q.bind(r.map(|v| v as i32));
}
if let Some(a) = update.is_active {
q = q.bind(a);
}
if let Some(d) = update.description {
q = q.bind(d);
}
if let Some(e) = update.expires_at {
q = q.bind(e);
}
let row = q.fetch_one(self.db.pool()).await?;
info!("Updated API key: bearer={}", redact_bearer(key));
Ok(Self::row_to_record(row))
}
pub async fn delete(&self, key: &str) -> sqlx::Result<bool> {
let result = sqlx::query("DELETE FROM api_keys WHERE api_key = $1")
.bind(key)
.execute(self.db.pool())
.await?;
let deleted = result.rows_affected() > 0;
if deleted {
info!("Deleted API key: bearer={}", redact_bearer(key));
}
Ok(deleted)
}
pub async fn deactivate(&self, key: &str) -> sqlx::Result<bool> {
let result = sqlx::query("UPDATE api_keys SET is_active = false, updated_at = NOW() WHERE api_key = $1")
.bind(key)
.execute(self.db.pool())
.await?;
let deactivated = result.rows_affected() > 0;
if deactivated {
info!("Deactivated API key: bearer={}", redact_bearer(key));
}
Ok(deactivated)
}
fn row_to_record(row: PgRow) -> ApiKeyRecord {
let permissions_json: JsonValue = row.get("permissions");
let permissions = Self::json_to_permissions(&permissions_json);
let address_bytes: Vec<u8> = row.get("address");
let address = Address::from_slice(&address_bytes);
ApiKeyRecord {
id: row.get("id"),
user_id: row.get("user_id"),
address,
api_key: row.get("api_key"),
name: row.get("name"),
permissions,
rate_limit: row.get::<Option<i32>, _>("rate_limit").map(|r| r as u32),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
is_active: row.get("is_active"),
expires_at: row.get("expires_at"),
description: row.get("description"),
}
}
fn permissions_to_json(permissions: &HashSet<ApiPermission>) -> JsonValue {
let perms: Vec<String> = permissions
.iter()
.filter_map(|p| serde_json::to_value(p).ok())
.filter_map(|v| v.as_str().map(String::from))
.collect();
JsonValue::Array(perms.into_iter().map(JsonValue::String).collect())
}
fn json_to_permissions(json: &JsonValue) -> HashSet<ApiPermission> {
match json {
JsonValue::Array(arr) => arr
.iter()
.filter_map(|v| v.as_str())
.filter_map(|s| serde_json::from_value(JsonValue::String(s.to_string())).ok())
.collect(),
_ => {
error!("Invalid permissions JSON format: {:?}", json);
HashSet::new()
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redact_keeps_prefix_drops_secret_body() {
let bearer = "nidx_abcdefghijklmnopqrstuvwxyz23456789";
let redacted = redact_bearer(bearer);
assert!(redacted.starts_with("nidx_***"), "missing prefix: {redacted:?}");
assert!(redacted.contains(&format!("({}c)", bearer.len())));
let tail = &bearer[5..];
assert!(!redacted.contains(tail), "redaction leaked the tail: {redacted:?}");
}
#[test]
fn redact_handles_legacy_no_prefix_keys() {
let bearer = "admin_key_CHANGE_ME_IN_PRODUCTION";
let redacted = redact_bearer(bearer);
assert!(redacted.starts_with("admin_***"), "got {redacted:?}");
assert!(!redacted.contains("CHANGE_ME"), "legacy key body leaked: {redacted:?}");
}
#[test]
fn redact_handles_no_underscore_keys() {
let bearer = "rawopaquetokenwithoutprefix";
let redacted = redact_bearer(bearer);
assert!(redacted.starts_with("***"), "got {redacted:?}");
assert!(!redacted.contains("rawopaque"), "raw body leaked: {redacted:?}");
}
#[test]
fn redact_handles_empty_bearer() {
let redacted = redact_bearer("");
assert_eq!(redacted, "*** (0c)");
}
}