maris-ai-master / backend-rust /src /camera_auth.rs
MarisUK's picture
Maris AI model sync
f440f03 verified
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(())
}