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, } #[derive(Debug, Clone, Serialize)] pub struct StoredCameraCredential { pub camera_id: String, pub username: Option, pub password: Option, pub bearer_token: Option, pub headers: serde_json::Value, pub encryption_key_id: Option, pub rotation_count: i32, } pub async fn resolve_principal( headers: &HeaderMap, pool: Option<&PgPool>, ) -> anyhow::Result> { 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::("id").to_string(), })) } pub async fn ensure_access( pool: Option<&PgPool>, camera_id: &str, principal: Option<&CameraPrincipal>, permission: &str, auth_required: bool, ) -> anyhow::Result { 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> { 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::("id").to_string(), principal_type: row.get("principal_type"), principal_id: row.get("principal_id"), permission: row.get("permission"), created_at: row .get::, _>("created_at") .to_rfc3339(), expires_at: row .get::>, _>("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> { 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 { let password = row .get::, _>("password_encrypted") .map(|value| crypto.decrypt(&value)) .transpose()?; let bearer_token = row .get::, _>("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 { 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 = row.get("username"); let password = row .get::, _>("password_encrypted") .map(|value| crypto.decrypt(&value)) .transpose()?; let bearer_token = row .get::, _>("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(()) }