| use anyhow::Context; |
| use axum::http::HeaderMap; |
| use serde::Serialize; |
| use sha2::{Digest, Sha256}; |
| use sqlx::{PgPool, Row}; |
|
|
| use crate::credential_crypto::CredentialCrypto; |
|
|
| #[derive(Debug, Clone, Serialize)] |
| pub struct CameraPrincipal { |
| pub principal_type: String, |
| pub principal_id: String, |
| } |
|
|
| #[derive(Debug, Serialize)] |
| pub struct CameraAccessEntry { |
| pub id: String, |
| pub principal_type: String, |
| pub principal_id: String, |
| pub permission: String, |
| pub created_at: String, |
| pub expires_at: Option<String>, |
| } |
|
|
| #[derive(Debug, Clone, Serialize)] |
| pub struct StoredCameraCredential { |
| pub camera_id: String, |
| pub username: Option<String>, |
| pub password: Option<String>, |
| pub bearer_token: Option<String>, |
| pub headers: serde_json::Value, |
| pub encryption_key_id: Option<String>, |
| pub rotation_count: i32, |
| } |
|
|
| pub async fn resolve_principal( |
| headers: &HeaderMap, |
| pool: Option<&PgPool>, |
| ) -> anyhow::Result<Option<CameraPrincipal>> { |
| if let Some(user_id) = headers.get("x-user-id").and_then(|value| value.to_str().ok()) { |
| let trimmed = user_id.trim(); |
| if !trimmed.is_empty() { |
| return Ok(Some(CameraPrincipal { |
| principal_type: "user".to_string(), |
| principal_id: trimmed.to_string(), |
| })); |
| } |
| } |
|
|
| let api_key = match headers.get("x-api-key").and_then(|value| value.to_str().ok()) { |
| Some(value) if !value.trim().is_empty() => value.trim(), |
| _ => return Ok(None), |
| }; |
|
|
| let Some(pool) = pool else { |
| return Ok(None); |
| }; |
| let prefix: String = api_key.chars().take(8).collect(); |
| let hash = format!("{:x}", Sha256::digest(api_key.as_bytes())); |
| let row = sqlx::query( |
| r#" |
| SELECT id |
| FROM api_keys |
| WHERE key_prefix = $1 |
| AND key_hash = $2 |
| AND revoked_at IS NULL |
| AND (expires_at IS NULL OR expires_at > NOW()) |
| LIMIT 1 |
| "#, |
| ) |
| .bind(prefix) |
| .bind(hash) |
| .fetch_optional(pool) |
| .await |
| .context("Neizdevās validēt x-api-key")?; |
|
|
| Ok(row.map(|row| CameraPrincipal { |
| principal_type: "api_key".to_string(), |
| principal_id: row.get::<uuid::Uuid, _>("id").to_string(), |
| })) |
| } |
|
|
| pub async fn ensure_access( |
| pool: Option<&PgPool>, |
| camera_id: &str, |
| principal: Option<&CameraPrincipal>, |
| permission: &str, |
| auth_required: bool, |
| ) -> anyhow::Result<bool> { |
| let Some(pool) = pool else { |
| return Ok(!auth_required || principal.is_some()); |
| }; |
|
|
| let count: i64 = sqlx::query_scalar( |
| "SELECT COUNT(*) FROM vision_camera_access WHERE camera_id = $1", |
| ) |
| .bind(camera_id) |
| .fetch_one(pool) |
| .await |
| .context("Neizdevās nolasīt camera access count")?; |
|
|
| if count == 0 { |
| return Ok(!auth_required || principal.is_some()); |
| } |
|
|
| let Some(principal) = principal else { |
| return Ok(false); |
| }; |
|
|
| let row = sqlx::query( |
| r#" |
| SELECT 1 |
| FROM vision_camera_access |
| WHERE camera_id = $1 |
| AND principal_type = $2 |
| AND principal_id = $3 |
| AND permission = $4 |
| AND (expires_at IS NULL OR expires_at > NOW()) |
| LIMIT 1 |
| "#, |
| ) |
| .bind(camera_id) |
| .bind(&principal.principal_type) |
| .bind(&principal.principal_id) |
| .bind(permission) |
| .fetch_optional(pool) |
| .await |
| .context("Neizdevās pārbaudīt camera access")?; |
|
|
| Ok(row.is_some()) |
| } |
|
|
| pub async fn grant_access( |
| pool: &PgPool, |
| camera_id: &str, |
| principal_type: &str, |
| principal_id: &str, |
| permission: &str, |
| ) -> anyhow::Result<()> { |
| sqlx::query( |
| r#" |
| INSERT INTO vision_camera_access (camera_id, principal_type, principal_id, permission) |
| VALUES ($1, $2, $3, $4) |
| ON CONFLICT (camera_id, principal_type, principal_id, permission) DO NOTHING |
| "#, |
| ) |
| .bind(camera_id) |
| .bind(principal_type) |
| .bind(principal_id) |
| .bind(permission) |
| .execute(pool) |
| .await |
| .context("Neizdevās piešķirt camera access")?; |
| Ok(()) |
| } |
|
|
| pub async fn list_access(pool: &PgPool, camera_id: &str) -> anyhow::Result<Vec<CameraAccessEntry>> { |
| let rows = sqlx::query( |
| r#" |
| SELECT id, principal_type, principal_id, permission, created_at, expires_at |
| FROM vision_camera_access |
| WHERE camera_id = $1 |
| ORDER BY created_at DESC |
| "#, |
| ) |
| .bind(camera_id) |
| .fetch_all(pool) |
| .await |
| .context("Neizdevās nolasīt camera access sarakstu")?; |
|
|
| Ok(rows |
| .into_iter() |
| .map(|row| CameraAccessEntry { |
| id: row.get::<uuid::Uuid, _>("id").to_string(), |
| principal_type: row.get("principal_type"), |
| principal_id: row.get("principal_id"), |
| permission: row.get("permission"), |
| created_at: row |
| .get::<chrono::DateTime<chrono::Utc>, _>("created_at") |
| .to_rfc3339(), |
| expires_at: row |
| .get::<Option<chrono::DateTime<chrono::Utc>>, _>("expires_at") |
| .map(|value| value.to_rfc3339()), |
| }) |
| .collect()) |
| } |
|
|
| pub async fn revoke_access(pool: &PgPool, access_id: &str) -> anyhow::Result<()> { |
| let access_id = uuid::Uuid::parse_str(access_id).context("Nederīgs access_id")?; |
| sqlx::query("DELETE FROM vision_camera_access WHERE id = $1") |
| .bind(access_id) |
| .execute(pool) |
| .await |
| .context("Neizdevās noņemt camera access")?; |
| Ok(()) |
| } |
|
|
| pub async fn upsert_credentials( |
| pool: &PgPool, |
| crypto: &CredentialCrypto, |
| camera_id: &str, |
| username: Option<&str>, |
| password: Option<&str>, |
| bearer_token: Option<&str>, |
| headers: &serde_json::Value, |
| actor: Option<&CameraPrincipal>, |
| ) -> anyhow::Result<()> { |
| let encrypted_password = match password { |
| Some(value) if !value.is_empty() => Some(crypto.encrypt(value)?.0), |
| _ => None, |
| }; |
| let (encrypted_bearer, key_id) = match bearer_token { |
| Some(value) if !value.is_empty() => { |
| let (cipher, key) = crypto.encrypt(value)?; |
| (Some(cipher), Some(key.key_id)) |
| } |
| _ => (None, Some(crypto.current_key_id()?)), |
| }; |
| sqlx::query( |
| r#" |
| INSERT INTO vision_camera_credentials ( |
| camera_id, username, password_encrypted, bearer_token_encrypted, headers_json, encryption_key_id |
| ) |
| VALUES ($1, $2, $3, $4, $5, $6) |
| ON CONFLICT (camera_id) DO UPDATE SET |
| username = EXCLUDED.username, |
| password_encrypted = EXCLUDED.password_encrypted, |
| bearer_token_encrypted = EXCLUDED.bearer_token_encrypted, |
| headers_json = EXCLUDED.headers_json, |
| encryption_key_id = EXCLUDED.encryption_key_id |
| "#, |
| ) |
| .bind(camera_id) |
| .bind(username) |
| .bind(encrypted_password) |
| .bind(encrypted_bearer) |
| .bind(headers) |
| .bind(key_id) |
| .execute(pool) |
| .await |
| .context("Neizdevās saglabāt camera credentials")?; |
| write_credential_audit(pool, camera_id, "stored", actor, headers, crypto.current_key_id().ok().as_deref()) |
| .await?; |
| Ok(()) |
| } |
|
|
| pub async fn load_credentials( |
| pool: &PgPool, |
| crypto: &CredentialCrypto, |
| camera_id: &str, |
| actor: Option<&CameraPrincipal>, |
| ) -> anyhow::Result<Option<StoredCameraCredential>> { |
| let row = sqlx::query( |
| r#" |
| SELECT camera_id, username, password_encrypted, bearer_token_encrypted, headers_json, encryption_key_id, rotation_count |
| FROM vision_camera_credentials |
| WHERE camera_id = $1 |
| LIMIT 1 |
| "#, |
| ) |
| .bind(camera_id) |
| .fetch_optional(pool) |
| .await |
| .context("Neizdevās nolasīt camera credentials")?; |
|
|
| let result = row |
| .map(|row| -> anyhow::Result<StoredCameraCredential> { |
| let password = row |
| .get::<Option<String>, _>("password_encrypted") |
| .map(|value| crypto.decrypt(&value)) |
| .transpose()?; |
| let bearer_token = row |
| .get::<Option<String>, _>("bearer_token_encrypted") |
| .map(|value| crypto.decrypt(&value)) |
| .transpose()?; |
| Ok(StoredCameraCredential { |
| camera_id: row.get("camera_id"), |
| username: row.get("username"), |
| password, |
| bearer_token, |
| headers: row.get("headers_json"), |
| encryption_key_id: row.get("encryption_key_id"), |
| rotation_count: row.get("rotation_count"), |
| }) |
| }) |
| .transpose()?; |
| if result.is_some() { |
| write_credential_audit(pool, camera_id, "read", actor, &serde_json::json!({}), None).await?; |
| } |
| Ok(result) |
| } |
|
|
| pub async fn rotate_credentials( |
| pool: &PgPool, |
| crypto: &CredentialCrypto, |
| actor: Option<&CameraPrincipal>, |
| ) -> anyhow::Result<u64> { |
| let rows = sqlx::query( |
| r#" |
| SELECT camera_id, username, password_encrypted, bearer_token_encrypted, headers_json |
| FROM vision_camera_credentials |
| "#, |
| ) |
| .fetch_all(pool) |
| .await |
| .context("Neizdevās nolasīt credentials rotācijai")?; |
| let mut rotated = 0_u64; |
| for row in rows { |
| let camera_id: String = row.get("camera_id"); |
| let username: Option<String> = row.get("username"); |
| let password = row |
| .get::<Option<String>, _>("password_encrypted") |
| .map(|value| crypto.decrypt(&value)) |
| .transpose()?; |
| let bearer_token = row |
| .get::<Option<String>, _>("bearer_token_encrypted") |
| .map(|value| crypto.decrypt(&value)) |
| .transpose()?; |
| let headers: serde_json::Value = row.get("headers_json"); |
| let encrypted_password = match password.as_deref() { |
| Some(value) => Some(crypto.encrypt(value)?.0), |
| None => None, |
| }; |
| let encrypted_bearer = match bearer_token.as_deref() { |
| Some(value) => Some(crypto.encrypt(value)?.0), |
| None => None, |
| }; |
| sqlx::query( |
| r#" |
| UPDATE vision_camera_credentials |
| SET username = $2, |
| password_encrypted = $3, |
| bearer_token_encrypted = $4, |
| headers_json = $5, |
| encryption_key_id = $6, |
| rotation_count = rotation_count + 1 |
| WHERE camera_id = $1 |
| "#, |
| ) |
| .bind(&camera_id) |
| .bind(username) |
| .bind(encrypted_password) |
| .bind(encrypted_bearer) |
| .bind(headers) |
| .bind(crypto.current_key_id()?) |
| .execute(pool) |
| .await |
| .context("Neizdevās pāršifrēt camera credential")?; |
| write_credential_audit( |
| pool, |
| &camera_id, |
| "rotated", |
| actor, |
| &serde_json::json!({ "rotation_count_increment": 1 }), |
| crypto.current_key_id().ok().as_deref(), |
| ) |
| .await?; |
| rotated += 1; |
| } |
| Ok(rotated) |
| } |
|
|
| async fn write_credential_audit( |
| pool: &PgPool, |
| camera_id: &str, |
| action: &str, |
| actor: Option<&CameraPrincipal>, |
| metadata: &serde_json::Value, |
| encryption_key_id: Option<&str>, |
| ) -> anyhow::Result<()> { |
| sqlx::query( |
| r#" |
| INSERT INTO vision_camera_credential_audit ( |
| camera_id, action, actor_principal_type, actor_principal_id, encryption_key_id, metadata_json |
| ) |
| VALUES ($1, $2, $3, $4, $5, $6) |
| "#, |
| ) |
| .bind(camera_id) |
| .bind(action) |
| .bind(actor.map(|item| item.principal_type.as_str())) |
| .bind(actor.map(|item| item.principal_id.as_str())) |
| .bind(encryption_key_id) |
| .bind(metadata) |
| .execute(pool) |
| .await |
| .context("Neizdevās saglabāt credential audit")?; |
| Ok(()) |
| } |
|
|