diff --git a/migrations/postgres/20260429100000_create_delegated_accounts.sql b/migrations/postgres/20260429100000_create_delegated_accounts.sql new file mode 100644 index 0000000..b6cd282 --- /dev/null +++ b/migrations/postgres/20260429100000_create_delegated_accounts.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS delegated_accounts ( + account_did TEXT PRIMARY KEY, + linked_by TEXT NOT NULL, + api_client_id TEXT NOT NULL, + created_at TEXT NOT NULL +); diff --git a/migrations/postgres/20260429100001_create_account_delegates.sql b/migrations/postgres/20260429100001_create_account_delegates.sql new file mode 100644 index 0000000..10ecf71 --- /dev/null +++ b/migrations/postgres/20260429100001_create_account_delegates.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS account_delegates ( + account_did TEXT NOT NULL REFERENCES delegated_accounts(account_did) ON DELETE CASCADE, + user_did TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')), + granted_by TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (account_did, user_did) +); diff --git a/migrations/sqlite/20260429100000_create_delegated_accounts.sql b/migrations/sqlite/20260429100000_create_delegated_accounts.sql new file mode 100644 index 0000000..b6cd282 --- /dev/null +++ b/migrations/sqlite/20260429100000_create_delegated_accounts.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS delegated_accounts ( + account_did TEXT PRIMARY KEY, + linked_by TEXT NOT NULL, + api_client_id TEXT NOT NULL, + created_at TEXT NOT NULL +); diff --git a/migrations/sqlite/20260429100001_create_account_delegates.sql b/migrations/sqlite/20260429100001_create_account_delegates.sql new file mode 100644 index 0000000..10ecf71 --- /dev/null +++ b/migrations/sqlite/20260429100001_create_account_delegates.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS account_delegates ( + account_did TEXT NOT NULL REFERENCES delegated_accounts(account_did) ON DELETE CASCADE, + user_did TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')), + granted_by TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (account_did, user_did) +); diff --git a/src/delegation/add_delegate.rs b/src/delegation/add_delegate.rs new file mode 100644 index 0000000..a4ecf0f --- /dev/null +++ b/src/delegation/add_delegate.rs @@ -0,0 +1,103 @@ +use axum::Json; +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde::Deserialize; +use serde_json::json; + +use crate::AppState; +use crate::auth::XrpcClaims; +use crate::error::AppError; +use crate::event_log::{EventLog, Severity, log_event}; + +use super::DelegateRole; +use super::db; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AddDelegateInput { + pub account_did: String, + pub user_did: String, + pub role: String, +} + +pub async fn add_delegate( + State(state): State, + xrpc_claims: XrpcClaims, + Json(input): Json, +) -> Result { + let claims = xrpc_claims + .0 + .ok_or_else(|| AppError::Auth("addDelegate requires authentication".into()))?; + + let caller_did = claims.did().to_string(); + + super::verify_client_scope(&state, &claims, &input.account_did).await?; + + let caller_role = + db::get_delegate_role(&state.db, state.db_backend, &input.account_did, &caller_did) + .await? + .ok_or_else(|| AppError::Forbidden("you are not a delegate of this account".into()))?; + + if !caller_role.can_manage_members() { + return Err(AppError::Forbidden( + "only owners and admins can add delegates".into(), + )); + } + + let target_role = match input.role.as_str() { + "admin" => DelegateRole::Admin, + "member" => DelegateRole::Member, + "owner" => { + return Err(AppError::BadRequest( + "cannot add a second owner — use unlinkAccount and re-link instead".into(), + )); + } + _ => { + return Err(AppError::BadRequest( + "role must be 'admin' or 'member'".into(), + )); + } + }; + + let existing = db::get_delegate_role( + &state.db, + state.db_backend, + &input.account_did, + &input.user_did, + ) + .await?; + if existing.is_some() { + return Err(AppError::Conflict( + "user is already a delegate — remove them first to change role".into(), + )); + } + + db::add_delegate( + &state.db, + state.db_backend, + &input.account_did, + &input.user_did, + target_role, + &caller_did, + ) + .await?; + + log_event( + &state.db, + EventLog { + event_type: "delegation.delegate_added".to_string(), + severity: Severity::Info, + actor_did: Some(caller_did), + subject: Some(input.user_did.clone()), + detail: json!({ + "account_did": input.account_did, + "role": input.role, + }), + }, + state.db_backend, + ) + .await; + + Ok((StatusCode::CREATED, Json(json!({}))).into_response()) +} diff --git a/src/delegation/db.rs b/src/delegation/db.rs new file mode 100644 index 0000000..4127916 --- /dev/null +++ b/src/delegation/db.rs @@ -0,0 +1,223 @@ +use crate::db::{adapt_sql, now_rfc3339}; +use crate::error::AppError; +use sqlx::AnyPool; + +use super::{DelegateRole, DelegateView, DelegatedAccountView}; + +pub async fn create_delegated_account( + pool: &AnyPool, + backend: crate::db::DatabaseBackend, + account_did: &str, + linked_by: &str, + api_client_id: &str, +) -> Result<(), AppError> { + let now = now_rfc3339(); + let sql = adapt_sql( + "INSERT INTO delegated_accounts (account_did, linked_by, api_client_id, created_at) VALUES (?, ?, ?, ?)", + backend, + ); + sqlx::query(&sql) + .bind(account_did) + .bind(linked_by) + .bind(api_client_id) + .bind(&now) + .execute(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to create delegated account: {e}")))?; + Ok(()) +} + +pub async fn delete_delegated_account( + pool: &AnyPool, + backend: crate::db::DatabaseBackend, + account_did: &str, +) -> Result<(), AppError> { + let sql = adapt_sql( + "DELETE FROM delegated_accounts WHERE account_did = ?", + backend, + ); + sqlx::query(&sql) + .bind(account_did) + .execute(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to delete delegated account: {e}")))?; + Ok(()) +} + +pub async fn get_delegated_account_owner( + pool: &AnyPool, + backend: crate::db::DatabaseBackend, + account_did: &str, +) -> Result, AppError> { + let sql = adapt_sql( + "SELECT linked_by FROM delegated_accounts WHERE account_did = ?", + backend, + ); + let row: Option<(String,)> = sqlx::query_as(&sql) + .bind(account_did) + .fetch_optional(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to query delegated account: {e}")))?; + Ok(row.map(|r| r.0)) +} + +pub async fn is_account_linked( + pool: &AnyPool, + backend: crate::db::DatabaseBackend, + account_did: &str, +) -> Result { + let owner = get_delegated_account_owner(pool, backend, account_did).await?; + Ok(owner.is_some()) +} + +pub async fn get_api_client_id( + pool: &AnyPool, + backend: crate::db::DatabaseBackend, + account_did: &str, +) -> Result, AppError> { + let sql = adapt_sql( + "SELECT api_client_id FROM delegated_accounts WHERE account_did = ?", + backend, + ); + let row: Option<(String,)> = sqlx::query_as(&sql) + .bind(account_did) + .fetch_optional(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to query delegated account: {e}")))?; + Ok(row.map(|r| r.0)) +} + +pub async fn add_delegate( + pool: &AnyPool, + backend: crate::db::DatabaseBackend, + account_did: &str, + user_did: &str, + role: DelegateRole, + granted_by: &str, +) -> Result<(), AppError> { + let now = now_rfc3339(); + let sql = adapt_sql( + "INSERT INTO account_delegates (account_did, user_did, role, granted_by, created_at) VALUES (?, ?, ?, ?, ?)", + backend, + ); + sqlx::query(&sql) + .bind(account_did) + .bind(user_did) + .bind(role.as_str()) + .bind(granted_by) + .bind(&now) + .execute(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to add delegate: {e}")))?; + Ok(()) +} + +pub async fn remove_delegate( + pool: &AnyPool, + backend: crate::db::DatabaseBackend, + account_did: &str, + user_did: &str, +) -> Result<(), AppError> { + let sql = adapt_sql( + "DELETE FROM account_delegates WHERE account_did = ? AND user_did = ?", + backend, + ); + sqlx::query(&sql) + .bind(account_did) + .bind(user_did) + .execute(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to remove delegate: {e}")))?; + Ok(()) +} + +pub async fn get_delegate_role( + pool: &AnyPool, + backend: crate::db::DatabaseBackend, + account_did: &str, + user_did: &str, +) -> Result, AppError> { + let sql = adapt_sql( + "SELECT role FROM account_delegates WHERE account_did = ? AND user_did = ?", + backend, + ); + let row: Option<(String,)> = sqlx::query_as(&sql) + .bind(account_did) + .bind(user_did) + .fetch_optional(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to query delegate role: {e}")))?; + Ok(row.and_then(|r| DelegateRole::from_str(&r.0))) +} + +pub async fn list_accounts_for_user( + pool: &AnyPool, + backend: crate::db::DatabaseBackend, + user_did: &str, + api_client_id: &str, +) -> Result, AppError> { + let sql = adapt_sql( + "SELECT ad.account_did, ad.role, ad.created_at FROM account_delegates ad JOIN delegated_accounts da ON da.account_did = ad.account_did WHERE ad.user_did = ? AND da.api_client_id = ? ORDER BY ad.created_at DESC", + backend, + ); + let rows: Vec<(String, String, String)> = sqlx::query_as(&sql) + .bind(user_did) + .bind(api_client_id) + .fetch_all(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to list delegated accounts: {e}")))?; + + Ok(rows + .into_iter() + .map(|(did, role, created_at)| DelegatedAccountView { + did, + role, + created_at, + }) + .collect()) +} + +pub async fn get_account_for_user( + pool: &AnyPool, + backend: crate::db::DatabaseBackend, + account_did: &str, + user_did: &str, +) -> Result, AppError> { + let sql = adapt_sql( + "SELECT da.linked_by, ad.role, ad.created_at FROM delegated_accounts da JOIN account_delegates ad ON da.account_did = ad.account_did WHERE da.account_did = ? AND ad.user_did = ?", + backend, + ); + let row: Option<(String, String, String)> = sqlx::query_as(&sql) + .bind(account_did) + .bind(user_did) + .fetch_optional(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to get delegated account: {e}")))?; + Ok(row) +} + +pub async fn list_delegates( + pool: &AnyPool, + backend: crate::db::DatabaseBackend, + account_did: &str, +) -> Result, AppError> { + let sql = adapt_sql( + "SELECT user_did, role, granted_by, created_at FROM account_delegates WHERE account_did = ? ORDER BY created_at ASC", + backend, + ); + let rows: Vec<(String, String, String, String)> = sqlx::query_as(&sql) + .bind(account_did) + .fetch_all(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to list delegates: {e}")))?; + + Ok(rows + .into_iter() + .map(|(user_did, role, granted_by, created_at)| DelegateView { + user_did, + role, + granted_by, + created_at, + }) + .collect()) +} diff --git a/src/delegation/get_account.rs b/src/delegation/get_account.rs new file mode 100644 index 0000000..bc93b87 --- /dev/null +++ b/src/delegation/get_account.rs @@ -0,0 +1,46 @@ +use axum::Json; +use axum::extract::{Query, State}; +use axum::response::{IntoResponse, Response}; +use serde::Deserialize; +use serde_json::json; + +use crate::AppState; +use crate::auth::XrpcClaims; +use crate::error::AppError; + +use super::db; + +#[derive(Deserialize)] +pub struct GetAccountParams { + pub did: String, +} + +pub async fn get_account( + State(state): State, + xrpc_claims: XrpcClaims, + Query(params): Query, +) -> Result { + let claims = xrpc_claims + .0 + .ok_or_else(|| AppError::Auth("getAccount requires authentication".into()))?; + + super::verify_client_scope(&state, &claims, ¶ms.did).await?; + + let is_linked = db::is_account_linked(&state.db, state.db_backend, ¶ms.did).await?; + if !is_linked { + return Err(AppError::NotFound("delegated account not found".into())); + } + + let (linked_by, role, created_at) = + db::get_account_for_user(&state.db, state.db_backend, ¶ms.did, claims.did()) + .await? + .ok_or_else(|| AppError::NotFound("you are not a delegate of this account".into()))?; + + Ok(Json(json!({ + "did": params.did, + "role": role, + "linkedBy": linked_by, + "createdAt": created_at, + })) + .into_response()) +} diff --git a/src/delegation/link_account.rs b/src/delegation/link_account.rs new file mode 100644 index 0000000..0e80f44 --- /dev/null +++ b/src/delegation/link_account.rs @@ -0,0 +1,98 @@ +use axum::Json; +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde::Deserialize; +use serde_json::json; + +use crate::AppState; +use crate::auth::XrpcClaims; +use crate::error::AppError; +use crate::event_log::{EventLog, Severity, log_event}; + +use super::DelegateRole; +use super::db; + +#[derive(Deserialize)] +pub struct LinkAccountInput { + pub did: String, +} + +pub async fn link_account( + State(state): State, + xrpc_claims: XrpcClaims, + Json(input): Json, +) -> Result { + let claims = xrpc_claims + .0 + .ok_or_else(|| AppError::Auth("linkAccount requires authentication".into()))?; + + let caller_did = claims.did().to_string(); + let account_did = &input.did; + + if caller_did == *account_did { + return Err(AppError::BadRequest( + "cannot link your own account as a delegate".into(), + )); + } + + if db::is_account_linked(&state.db, state.db_backend, account_did).await? { + return Err(AppError::Conflict("account is already linked".into())); + } + + // Verify a DPoP session exists for the target DID + let client_key = claims + .client_key() + .ok_or_else(|| AppError::Auth("linkAccount requires DPoP authentication".into()))?; + let api_client_id = crate::repo::get_dpop_client_id(&state, client_key).await?; + + let session_check_sql = crate::db::adapt_sql( + "SELECT id FROM dpop_sessions WHERE api_client_id = ? AND user_did = ?", + state.db_backend, + ); + let session_exists: Option<(String,)> = sqlx::query_as(&session_check_sql) + .bind(&api_client_id) + .bind(account_did) + .fetch_optional(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to check session: {e}")))?; + + if session_exists.is_none() { + return Err(AppError::BadRequest( + "no DPoP session found for the target account — complete OAuth first".into(), + )); + } + + db::create_delegated_account( + &state.db, + state.db_backend, + account_did, + &caller_did, + &api_client_id, + ) + .await?; + db::add_delegate( + &state.db, + state.db_backend, + account_did, + &caller_did, + DelegateRole::Owner, + &caller_did, + ) + .await?; + + log_event( + &state.db, + EventLog { + event_type: "delegation.account_linked".to_string(), + severity: Severity::Info, + actor_did: Some(caller_did), + subject: Some(account_did.clone()), + detail: json!({}), + }, + state.db_backend, + ) + .await; + + Ok((StatusCode::CREATED, Json(json!({ "did": account_did }))).into_response()) +} diff --git a/src/delegation/list_accounts.rs b/src/delegation/list_accounts.rs new file mode 100644 index 0000000..96d2fb0 --- /dev/null +++ b/src/delegation/list_accounts.rs @@ -0,0 +1,27 @@ +use axum::Json; +use axum::extract::State; +use axum::response::{IntoResponse, Response}; +use serde_json::json; + +use crate::AppState; +use crate::auth::XrpcClaims; +use crate::error::AppError; + +use super::db; + +pub async fn list_accounts( + State(state): State, + xrpc_claims: XrpcClaims, +) -> Result { + let claims = xrpc_claims + .0 + .ok_or_else(|| AppError::Auth("listAccounts requires authentication".into()))?; + + let caller_client_id = super::resolve_caller_client_id(&state, &claims).await?; + + let accounts = + db::list_accounts_for_user(&state.db, state.db_backend, claims.did(), &caller_client_id) + .await?; + + Ok(Json(json!({ "accounts": accounts })).into_response()) +} diff --git a/src/delegation/list_delegates.rs b/src/delegation/list_delegates.rs new file mode 100644 index 0000000..5a0299f --- /dev/null +++ b/src/delegation/list_delegates.rs @@ -0,0 +1,48 @@ +use axum::Json; +use axum::extract::{Query, State}; +use axum::response::{IntoResponse, Response}; +use serde::Deserialize; +use serde_json::json; + +use crate::AppState; +use crate::auth::XrpcClaims; +use crate::error::AppError; + +use super::db; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListDelegatesParams { + pub account_did: String, +} + +pub async fn list_delegates( + State(state): State, + xrpc_claims: XrpcClaims, + Query(params): Query, +) -> Result { + let claims = xrpc_claims + .0 + .ok_or_else(|| AppError::Auth("listDelegates requires authentication".into()))?; + + super::verify_client_scope(&state, &claims, ¶ms.account_did).await?; + + let caller_role = db::get_delegate_role( + &state.db, + state.db_backend, + ¶ms.account_did, + claims.did(), + ) + .await? + .ok_or_else(|| AppError::Forbidden("you are not a delegate of this account".into()))?; + + if !caller_role.can_manage_members() { + return Err(AppError::Forbidden( + "only owners and admins can list delegates".into(), + )); + } + + let delegates = db::list_delegates(&state.db, state.db_backend, ¶ms.account_did).await?; + + Ok(Json(json!({ "delegates": delegates })).into_response()) +} diff --git a/src/delegation/mod.rs b/src/delegation/mod.rs new file mode 100644 index 0000000..f366ff3 --- /dev/null +++ b/src/delegation/mod.rs @@ -0,0 +1,91 @@ +pub mod add_delegate; +pub mod db; +pub mod get_account; +pub mod link_account; +pub mod list_accounts; +pub mod list_delegates; +pub mod remove_delegate; +pub mod unlink_account; + +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DelegatedAccountView { + pub did: String, + pub role: String, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DelegateView { + pub user_did: String, + pub role: String, + pub granted_by: String, + pub created_at: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DelegateRole { + Owner, + Admin, + Member, +} + +impl DelegateRole { + pub fn as_str(&self) -> &'static str { + match self { + DelegateRole::Owner => "owner", + DelegateRole::Admin => "admin", + DelegateRole::Member => "member", + } + } + + pub fn from_str(s: &str) -> Option { + match s { + "owner" => Some(DelegateRole::Owner), + "admin" => Some(DelegateRole::Admin), + "member" => Some(DelegateRole::Member), + _ => None, + } + } + + pub fn can_write(&self) -> bool { + matches!(self, DelegateRole::Owner | DelegateRole::Admin) + } + + pub fn can_manage_members(&self) -> bool { + matches!(self, DelegateRole::Owner | DelegateRole::Admin) + } +} + +pub(crate) async fn resolve_caller_client_id( + state: &crate::AppState, + claims: &crate::auth::Claims, +) -> Result { + let client_key = claims.client_key().ok_or_else(|| { + crate::error::AppError::Auth("delegation requires DPoP authentication".into()) + })?; + crate::repo::get_dpop_client_id(state, client_key).await +} + +pub(crate) async fn verify_client_scope( + state: &crate::AppState, + claims: &crate::auth::Claims, + account_did: &str, +) -> Result<(), crate::error::AppError> { + let caller_client_id = resolve_caller_client_id(state, claims).await?; + + let stored_client_id = db::get_api_client_id(&state.db, state.db_backend, account_did) + .await? + .ok_or_else(|| crate::error::AppError::NotFound("delegated account not found".into()))?; + + if caller_client_id != stored_client_id { + return Err(crate::error::AppError::Forbidden( + "delegation is scoped to a different application".into(), + )); + } + + Ok(()) +} diff --git a/src/delegation/remove_delegate.rs b/src/delegation/remove_delegate.rs new file mode 100644 index 0000000..456dd7f --- /dev/null +++ b/src/delegation/remove_delegate.rs @@ -0,0 +1,92 @@ +use axum::Json; +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde::Deserialize; +use serde_json::json; + +use crate::AppState; +use crate::auth::XrpcClaims; +use crate::error::AppError; +use crate::event_log::{EventLog, Severity, log_event}; + +use super::DelegateRole; +use super::db; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoveDelegateInput { + pub account_did: String, + pub user_did: String, +} + +pub async fn remove_delegate( + State(state): State, + xrpc_claims: XrpcClaims, + Json(input): Json, +) -> Result { + let claims = xrpc_claims + .0 + .ok_or_else(|| AppError::Auth("removeDelegate requires authentication".into()))?; + + let caller_did = claims.did().to_string(); + + super::verify_client_scope(&state, &claims, &input.account_did).await?; + + let caller_role = + db::get_delegate_role(&state.db, state.db_backend, &input.account_did, &caller_did) + .await? + .ok_or_else(|| AppError::Forbidden("you are not a delegate of this account".into()))?; + + if !caller_role.can_manage_members() { + return Err(AppError::Forbidden( + "only owners and admins can remove delegates".into(), + )); + } + + let target_role = db::get_delegate_role( + &state.db, + state.db_backend, + &input.account_did, + &input.user_did, + ) + .await? + .ok_or_else(|| AppError::NotFound("user is not a delegate of this account".into()))?; + + if target_role == DelegateRole::Owner { + return Err(AppError::Forbidden( + "cannot remove the owner — use unlinkAccount instead".into(), + )); + } + + if caller_role == DelegateRole::Admin && target_role == DelegateRole::Admin { + return Err(AppError::Forbidden( + "admins cannot remove other admins — only the owner can".into(), + )); + } + + db::remove_delegate( + &state.db, + state.db_backend, + &input.account_did, + &input.user_did, + ) + .await?; + + log_event( + &state.db, + EventLog { + event_type: "delegation.delegate_removed".to_string(), + severity: Severity::Info, + actor_did: Some(caller_did), + subject: Some(input.user_did.clone()), + detail: json!({ + "account_did": input.account_did, + }), + }, + state.db_backend, + ) + .await; + + Ok((StatusCode::OK, Json(json!({}))).into_response()) +} diff --git a/src/delegation/unlink_account.rs b/src/delegation/unlink_account.rs new file mode 100644 index 0000000..2d19477 --- /dev/null +++ b/src/delegation/unlink_account.rs @@ -0,0 +1,85 @@ +use axum::Json; +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde::Deserialize; +use serde_json::json; + +use crate::AppState; +use crate::auth::XrpcClaims; +use crate::error::AppError; +use crate::event_log::{EventLog, Severity, log_event}; + +use super::DelegateRole; +use super::db; + +#[derive(Deserialize)] +pub struct UnlinkAccountInput { + pub did: String, +} + +pub async fn unlink_account( + State(state): State, + xrpc_claims: XrpcClaims, + Json(input): Json, +) -> Result { + let claims = xrpc_claims + .0 + .ok_or_else(|| AppError::Auth("unlinkAccount requires authentication".into()))?; + + let caller_did = claims.did().to_string(); + let account_did = &input.did; + + super::verify_client_scope(&state, &claims, account_did).await?; + + let is_linked = db::is_account_linked(&state.db, state.db_backend, account_did).await?; + if !is_linked { + return Err(AppError::NotFound("delegated account not found".into())); + } + + let role = db::get_delegate_role(&state.db, state.db_backend, account_did, &caller_did) + .await? + .ok_or_else(|| AppError::Forbidden("you are not a delegate of this account".into()))?; + + if role != DelegateRole::Owner { + return Err(AppError::Forbidden( + "only the owner can unlink an account".into(), + )); + } + + // Look up the stored api_client_id before deleting the account + let stored_api_client_id = + db::get_api_client_id(&state.db, state.db_backend, account_did).await?; + + // Delete delegated account (CASCADE deletes all delegates) + db::delete_delegated_account(&state.db, state.db_backend, account_did).await?; + + // Delete the DPoP session for the target account using the stored api_client_id + if let Some(api_client_id) = stored_api_client_id { + if let Err(e) = crate::oauth::sessions::delete_dpop_session( + &state.db, + state.db_backend, + &api_client_id, + account_did, + ) + .await + { + tracing::warn!(account_did, %e, "failed to clean up DPoP session on unlink"); + } + } + + log_event( + &state.db, + EventLog { + event_type: "delegation.account_unlinked".to_string(), + severity: Severity::Info, + actor_did: Some(caller_did), + subject: Some(account_did.clone()), + detail: json!({}), + }, + state.db_backend, + ) + .await; + + Ok((StatusCode::OK, Json(json!({}))).into_response()) +} diff --git a/src/lib.rs b/src/lib.rs index ef26bcd..daf60e9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ pub mod admin; pub mod auth; pub mod config; pub mod db; +pub mod delegation; pub mod dev_happyview; pub mod dns; pub mod domain; diff --git a/src/lua/context.rs b/src/lua/context.rs index f63e0bc..b62a882 100644 --- a/src/lua/context.rs +++ b/src/lua/context.rs @@ -40,6 +40,7 @@ pub fn set_procedure_context( caller_did: &str, collection: &str, space: Option<&SpaceContext>, + delegate_did: Option<&str>, ) -> LuaResult<()> { let globals = lua.globals(); globals.set("method", method.to_string())?; @@ -47,6 +48,10 @@ pub fn set_procedure_context( globals.set("params", lua.to_value(params)?)?; globals.set("caller_did", caller_did.to_string())?; globals.set("collection", collection.to_string())?; + match delegate_did { + Some(did) => globals.set("delegate_did", did.to_string())?, + None => globals.set("delegate_did", mlua::Value::Nil)?, + } set_space_context(lua, space)?; Ok(()) } @@ -151,6 +156,7 @@ mod tests { "did:plc:test", "com.example.thing", None, + None, ) .unwrap(); @@ -164,6 +170,7 @@ mod tests { globals.get::("collection").unwrap(), "com.example.thing" ); + assert!(globals.get::("delegate_did").unwrap().is_nil()); let input_table: mlua::Table = globals.get("input").unwrap(); assert_eq!(input_table.get::("key").unwrap(), "val"); @@ -203,6 +210,34 @@ mod tests { assert_eq!(params_table.get::("cursor").unwrap(), "abc"); } + #[test] + fn procedure_context_with_delegate_did() { + let lua = create_sandbox().unwrap(); + let input = json!({"key": "val"}); + let params = HashMap::new(); + set_procedure_context( + &lua, + "com.example.doThing", + &input, + ¶ms, + "did:plc:caller", + "com.example.thing", + None, + Some("did:plc:delegate"), + ) + .unwrap(); + + let globals = lua.globals(); + assert_eq!( + globals.get::("delegate_did").unwrap(), + "did:plc:delegate" + ); + assert_eq!( + globals.get::("caller_did").unwrap(), + "did:plc:caller" + ); + } + #[test] fn env_context_sets_table() { let lua = create_sandbox().unwrap(); diff --git a/src/lua/execute.rs b/src/lua/execute.rs index 23199ee..51bdbb2 100644 --- a/src/lua/execute.rs +++ b/src/lua/execute.rs @@ -43,6 +43,7 @@ pub async fn execute_procedure_script( lexicon: &ParsedLexicon, script: &str, space_ctx: Option<&context::SpaceContext>, + delegate_did: Option<&str>, ) -> Result { let start = Instant::now(); let backend = state.db_backend; @@ -253,7 +254,13 @@ pub async fn execute_procedure_script( return Err(AppError::Internal(error_message)); } - if let Err(e) = record::register_record_api(&lua, state_arc, claims_arc, pds_auth_arc) { + if let Err(e) = record::register_record_api( + &lua, + state_arc, + claims_arc, + pds_auth_arc, + delegate_did.map(|s| s.to_string()), + ) { let error_message = format!("failed to register Record API: {e}"); log_event( &state.db, @@ -285,6 +292,7 @@ pub async fn execute_procedure_script( claims.did(), collection, space_ctx, + delegate_did, ) { let error_message = format!("failed to set context: {e}"); log_event( diff --git a/src/lua/record.rs b/src/lua/record.rs index 601be7b..654c68a 100644 --- a/src/lua/record.rs +++ b/src/lua/record.rs @@ -23,11 +23,15 @@ const INTERNAL_FIELDS: &[&str] = &[ /// Register the `Record` global constructor and static methods. /// Only registered for procedure scripts (not queries). +/// +/// When `delegate_did` is `Some`, record writes default to the delegate's repo +/// instead of the caller's DID. Scripts can still override via `record:set_repo()`. pub fn register_record_api( lua: &Lua, state: Arc, claims: Arc, pds_auth: Arc, + delegate_did: Option, ) -> LuaResult<()> { // -- methods table (shared by all Record instances) -- let methods = lua.create_table()?; @@ -37,16 +41,21 @@ pub fn register_record_api( let state = state.clone(); let claims = claims.clone(); let pds_auth = pds_auth.clone(); + let delegate_did = delegate_did.clone(); let save_fn = lua.create_async_function(move |lua, this: mlua::Table| { let state = state.clone(); let claims = claims.clone(); let pds_auth = pds_auth.clone(); + let delegate_did = delegate_did.clone(); async move { let backend = state.db_backend; let collection: String = this.raw_get("_collection")?; let schema: mlua::Value = this.raw_get("_schema")?; let repo_override: Option = this.raw_get("_repo_override")?; - let repo = repo_override.as_deref().unwrap_or_else(|| claims.did()); + let repo = repo_override + .as_deref() + .or(delegate_did.as_deref()) + .unwrap_or_else(|| claims.did()); // Validate required fields against schema if let mlua::Value::Table(ref schema_table) = schema { @@ -207,10 +216,12 @@ pub fn register_record_api( let state = state.clone(); let claims = claims.clone(); let pds_auth = pds_auth.clone(); + let delegate_did = delegate_did.clone(); let delete_fn = lua.create_async_function(move |_lua, this: mlua::Table| { let state = state.clone(); let claims = claims.clone(); let pds_auth = pds_auth.clone(); + let delegate_did = delegate_did.clone(); async move { let backend = state.db_backend; let uri: String = this.raw_get::>("_uri")?.ok_or_else(|| { @@ -218,7 +229,10 @@ pub fn register_record_api( })?; let collection: String = this.raw_get("_collection")?; let repo_override: Option = this.raw_get("_repo_override")?; - let repo = repo_override.as_deref().unwrap_or_else(|| claims.did()); + let repo = repo_override + .as_deref() + .or(delegate_did.as_deref()) + .unwrap_or_else(|| claims.did()); let rkey = uri .split('/') @@ -439,11 +453,13 @@ pub fn register_record_api( let state = state.clone(); let claims = claims.clone(); let pds_auth = pds_auth.clone(); + let delegate_did = delegate_did.clone(); let save_all_fn = lua.create_async_function(move |lua, records_table: mlua::Table| { let state = state.clone(); let claims = claims.clone(); let pds_auth = pds_auth.clone(); + let delegate_did = delegate_did.clone(); async move { let backend = state.db_backend; // Extract save data from each record (sync) @@ -472,13 +488,17 @@ pub fn register_record_api( let state = state.clone(); let claims = claims.clone(); let pds_auth = pds_auth.clone(); + let delegate_did = delegate_did.clone(); let collection = collection.clone(); let existing_uri = existing_uri.clone(); let rkey = rkey.clone(); let repo_override = repo_override.clone(); let data = data.clone(); async move { - let repo = repo_override.as_deref().unwrap_or_else(|| claims.did()); + let repo = repo_override + .as_deref() + .or(delegate_did.as_deref()) + .unwrap_or_else(|| claims.did()); if let Some(ref uri) = existing_uri { let rkey = uri .split('/') diff --git a/src/server.rs b/src/server.rs index 1b86227..942d1b1 100644 --- a/src/server.rs +++ b/src/server.rs @@ -89,6 +89,35 @@ pub fn router(state: AppState) -> Router { "/xrpc/dev.happyview.deleteApiClient", post(crate::dev_happyview::delete_api_client), ) + // Delegation + .route( + "/xrpc/dev.happyview.delegation.linkAccount", + post(crate::delegation::link_account::link_account), + ) + .route( + "/xrpc/dev.happyview.delegation.unlinkAccount", + post(crate::delegation::unlink_account::unlink_account), + ) + .route( + "/xrpc/dev.happyview.delegation.addDelegate", + post(crate::delegation::add_delegate::add_delegate), + ) + .route( + "/xrpc/dev.happyview.delegation.removeDelegate", + post(crate::delegation::remove_delegate::remove_delegate), + ) + .route( + "/xrpc/dev.happyview.delegation.listAccounts", + get(crate::delegation::list_accounts::list_accounts), + ) + .route( + "/xrpc/dev.happyview.delegation.getAccount", + get(crate::delegation::get_account::get_account), + ) + .route( + "/xrpc/dev.happyview.delegation.listDelegates", + get(crate::delegation::list_delegates::list_delegates), + ) // Catch-all for dynamically registered lexicons .route("/xrpc/{method}", get(xrpc::xrpc_get).post(xrpc::xrpc_post)) .route("/config", get(config_endpoint)) diff --git a/src/xrpc/procedure.rs b/src/xrpc/procedure.rs index 1bf9879..13850d0 100644 --- a/src/xrpc/procedure.rs +++ b/src/xrpc/procedure.rs @@ -19,8 +19,61 @@ pub(crate) async fn handle_procedure( lexicon: &crate::lexicon::ParsedLexicon, ) -> Result { if let Some(ref script) = lexicon.script { + let delegate_did = input + .get("delegateDid") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + if let Some(ref did) = delegate_did { + let client_key = claims + .client_key() + .ok_or_else(|| AppError::Auth("delegation requires DPoP authentication".into()))?; + let api_client_id = repo::get_dpop_client_id(state, client_key).await?; + + let role = crate::delegation::db::get_delegate_role( + &state.db, + state.db_backend, + did, + claims.did(), + ) + .await? + .ok_or_else(|| AppError::Forbidden("you are not a delegate of this account".into()))?; + + if !role.can_write() { + return Err(AppError::Forbidden( + "your role does not have write access to this account".into(), + )); + } + + let stored_client_id = + crate::delegation::db::get_api_client_id(&state.db, state.db_backend, did) + .await? + .ok_or_else(|| { + AppError::Internal("delegated account missing api_client_id".into()) + })?; + + if api_client_id != stored_client_id { + return Err(AppError::Forbidden( + "delegation is scoped to a different application".into(), + )); + } + } + + let mut script_input = input.clone(); + if let Some(obj) = script_input.as_object_mut() { + obj.remove("delegateDid"); + } + return crate::lua::execute_procedure_script( - state, method, claims, input, params, lexicon, script, None, + state, + method, + claims, + &script_input, + params, + lexicon, + script, + None, + delegate_did.as_deref(), ) .await; } @@ -40,6 +93,11 @@ pub(crate) async fn handle_procedure( let api_client_id = repo::get_dpop_client_id(state, client_key).await?; + let delegate_did = input + .get("delegateDid") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + return handle_dpop_procedure( state, claims, @@ -48,6 +106,7 @@ pub(crate) async fn handle_procedure( &lexicon.action, &api_client_id, encryption_key, + delegate_did.as_deref(), ) .await; } @@ -295,18 +354,62 @@ async fn handle_dpop_procedure( action: &ProcedureAction, api_client_id: &str, encryption_key: &[u8; 32], + delegate_did: Option<&str>, ) -> Result { + // If delegating, verify the caller has write access and resolve the + // api_client_id that owns the delegated session. + let (target_did, effective_api_client_id) = if let Some(did) = delegate_did { + let role = crate::delegation::db::get_delegate_role( + &state.db, + state.db_backend, + did, + claims.did(), + ) + .await? + .ok_or_else(|| AppError::Forbidden("you are not a delegate of this account".into()))?; + + if !role.can_write() { + return Err(AppError::Forbidden( + "your role does not have write access to this account".into(), + )); + } + + let stored_client_id = + crate::delegation::db::get_api_client_id(&state.db, state.db_backend, did) + .await? + .ok_or_else(|| { + AppError::Internal("delegated account missing api_client_id".into()) + })?; + + if api_client_id != stored_client_id { + return Err(AppError::Forbidden( + "delegation is scoped to a different application".into(), + )); + } + + (did, stored_client_id) + } else { + (claims.did(), api_client_id.to_string()) + }; + + // Strip delegateDid from input — it's a control field, not record data + let mut input = input.clone(); + if let Some(obj) = input.as_object_mut() { + obj.remove("delegateDid"); + } + let (xrpc_method, pds_body) = match action { ProcedureAction::Create => { let mut record = input.clone(); if let Some(obj) = record.as_object_mut() { obj.insert("$type".to_string(), json!(collection)); obj.remove("shouldPublish"); + obj.remove("delegateDid"); } ( "com.atproto.repo.createRecord", json!({ - "repo": claims.did(), + "repo": target_did, "collection": collection, "record": record, }), @@ -326,11 +429,12 @@ async fn handle_dpop_procedure( obj.insert("$type".to_string(), json!(collection)); obj.remove("uri"); obj.remove("shouldPublish"); + obj.remove("delegateDid"); } ( "com.atproto.repo.putRecord", json!({ - "repo": claims.did(), + "repo": target_did, "collection": collection, "rkey": rkey, "record": record, @@ -349,7 +453,7 @@ async fn handle_dpop_procedure( ( "com.atproto.repo.deleteRecord", json!({ - "repo": claims.did(), + "repo": target_did, "collection": collection, "rkey": rkey, }), @@ -368,11 +472,12 @@ async fn handle_dpop_procedure( obj.insert("$type".to_string(), json!(collection)); obj.remove("uri"); obj.remove("shouldPublish"); + obj.remove("delegateDid"); } ( "com.atproto.repo.putRecord", json!({ - "repo": claims.did(), + "repo": target_did, "collection": collection, "rkey": rkey, "record": record, @@ -383,11 +488,12 @@ async fn handle_dpop_procedure( if let Some(obj) = record.as_object_mut() { obj.insert("$type".to_string(), json!(collection)); obj.remove("shouldPublish"); + obj.remove("delegateDid"); } ( "com.atproto.repo.createRecord", json!({ - "repo": claims.did(), + "repo": target_did, "collection": collection, "record": record, }), @@ -403,8 +509,8 @@ async fn handle_dpop_procedure( encryption_key, &state.oauth, &state.config.plc_url, - api_client_id, - claims.did(), + &effective_api_client_id, + target_did, xrpc_method, &pds_body, ) diff --git a/tests/common/db.rs b/tests/common/db.rs index 5b2d63b..d4fc0df 100644 --- a/tests/common/db.rs +++ b/tests/common/db.rs @@ -20,7 +20,7 @@ pub async fn truncate_all(pool: &AnyPool) { match backend { DatabaseBackend::Postgres => { sqlx::query( - "TRUNCATE records, lexicons, backfill_jobs, users, user_permissions, api_keys, event_logs, script_variables, dead_letter_hooks, record_refs, labeler_subscriptions, labels, instance_settings, domains, dpop_sessions, dpop_keys, api_clients RESTART IDENTITY CASCADE", + "TRUNCATE records, lexicons, backfill_jobs, users, user_permissions, api_keys, event_logs, script_variables, dead_letter_hooks, record_refs, labeler_subscriptions, labels, instance_settings, domains, dpop_sessions, dpop_keys, api_clients, delegated_accounts, account_delegates RESTART IDENTITY CASCADE", ) .execute(pool) .await @@ -28,6 +28,8 @@ pub async fn truncate_all(pool: &AnyPool) { } DatabaseBackend::Sqlite => { let tables = [ + "account_delegates", + "delegated_accounts", "dpop_sessions", "dpop_keys", "api_clients", diff --git a/tests/e2e_delegation.rs b/tests/e2e_delegation.rs new file mode 100644 index 0000000..f1316eb --- /dev/null +++ b/tests/e2e_delegation.rs @@ -0,0 +1,1534 @@ +mod common; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use happyview::db::adapt_sql; +use happyview::oauth::pds_write::generate_dpop_proof; +use http_body_util::BodyExt; +use serde_json::{Value, json}; +use serial_test::serial; +use tower::ServiceExt; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, ResponseTemplate}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async fn response_json(resp: axum::response::Response) -> Value { + let body = resp.into_body().collect().await.unwrap().to_bytes(); + serde_json::from_slice(&body).unwrap_or(json!(null)) +} + +fn post_json_with_headers(uri: &str, body: &Value, headers: Vec<(&str, &str)>) -> Request { + let mut builder = Request::builder() + .method("POST") + .uri(uri) + .header("content-type", "application/json") + .header("host", "127.0.0.1:0"); + for (name, value) in headers { + builder = builder.header(name, value); + } + builder + .body(Body::from(serde_json::to_vec(body).unwrap())) + .unwrap() +} + +fn get_with_headers(uri: &str, headers: Vec<(&str, &str)>) -> Request { + let mut builder = Request::builder() + .method("GET") + .uri(uri) + .header("host", "127.0.0.1:0"); + for (name, value) in headers { + builder = builder.header(name, value); + } + builder.body(Body::empty()).unwrap() +} + +/// Set up a full DPoP session and return `(client_key, dpop_key, access_token)`. +async fn setup_dpop_session(app: &common::app::TestApp, user_did: &str) -> (String, Value, String) { + let (client_key, client_secret, _id) = app.create_api_client("confidential", None).await; + + let key_req = post_json_with_headers( + "/oauth/dpop-keys", + &json!({}), + vec![ + ("x-client-key", &client_key), + ("x-client-secret", &client_secret), + ], + ); + let key_resp = app.router.clone().oneshot(key_req).await.unwrap(); + assert_eq!(key_resp.status(), StatusCode::CREATED); + let key_body = response_json(key_resp).await; + let provision_id = key_body["provision_id"].as_str().unwrap().to_string(); + let dpop_key = key_body["dpop_key"].clone(); + + let access_token = format!("test-access-{}", uuid::Uuid::new_v4()); + let session_req = post_json_with_headers( + "/oauth/sessions", + &json!({ + "provision_id": provision_id, + "did": user_did, + "access_token": &access_token, + "scopes": "atproto", + "pds_url": "https://pds.example.com", + }), + vec![ + ("x-client-key", &client_key), + ("x-client-secret", &client_secret), + ], + ); + let session_resp = app.router.clone().oneshot(session_req).await.unwrap(); + assert_eq!(session_resp.status(), StatusCode::CREATED); + + (client_key, dpop_key, access_token) +} + +/// Build DPoP auth headers for a request. +fn dpop_auth_headers<'a>( + client_key: &'a str, + dpop_key: &Value, + access_token: &'a str, + method: &str, + url: &str, +) -> Vec<(&'static str, String)> { + // DPoP htu must not include query/fragment + let htu = url.split('?').next().unwrap_or(url); + let proof = generate_dpop_proof(dpop_key, method, htu, access_token, None) + .expect("failed to generate DPoP proof"); + vec![ + ("x-client-key", client_key.to_string()), + ("authorization", format!("DPoP {}", access_token)), + ("dpop", proof), + ] +} + +/// Make an authenticated POST request. +async fn dpop_post( + app: &common::app::TestApp, + path: &str, + body: &Value, + client_key: &str, + dpop_key: &Value, + access_token: &str, +) -> axum::response::Response { + let url = format!("http://127.0.0.1:0{}", path); + let headers = dpop_auth_headers(client_key, dpop_key, access_token, "POST", &url); + let str_headers: Vec<(&str, &str)> = headers.iter().map(|(k, v)| (*k, v.as_str())).collect(); + let req = post_json_with_headers(path, body, str_headers); + app.router.clone().oneshot(req).await.unwrap() +} + +/// Make an authenticated GET request. +async fn dpop_get( + app: &common::app::TestApp, + path: &str, + client_key: &str, + dpop_key: &Value, + access_token: &str, +) -> axum::response::Response { + let url = format!("http://127.0.0.1:0{}", path); + let headers = dpop_auth_headers(client_key, dpop_key, access_token, "GET", &url); + let str_headers: Vec<(&str, &str)> = headers.iter().map(|(k, v)| (*k, v.as_str())).collect(); + let req = get_with_headers(path, str_headers); + app.router.clone().oneshot(req).await.unwrap() +} + +/// Register a DPoP session for a target DID using the same API client. +/// This simulates the client completing OAuth for the target account. +async fn register_target_session( + app: &common::app::TestApp, + client_key: &str, + client_secret: &str, + target_did: &str, +) { + // Look up the client secret from the DB — we need it for the dpop-keys endpoint. + // Actually, setup_dpop_session already provisions a key, but we need a separate session + // for the target DID under the same api_client. + // We can reuse the same provision_id (DPoP key) — register another session with a + // different DID. + + // Provision a new DPoP key for this target session + let key_req = post_json_with_headers( + "/oauth/dpop-keys", + &json!({}), + vec![ + ("x-client-key", client_key), + ("x-client-secret", client_secret), + ], + ); + let key_resp = app.router.clone().oneshot(key_req).await.unwrap(); + assert_eq!(key_resp.status(), StatusCode::CREATED); + let key_body = response_json(key_resp).await; + let provision_id = key_body["provision_id"].as_str().unwrap().to_string(); + + let access_token = format!("test-target-access-{}", uuid::Uuid::new_v4()); + let session_req = post_json_with_headers( + "/oauth/sessions", + &json!({ + "provision_id": provision_id, + "did": target_did, + "access_token": &access_token, + "scopes": "atproto", + "pds_url": "https://pds.example.com", + }), + vec![ + ("x-client-key", client_key), + ("x-client-secret", client_secret), + ], + ); + let session_resp = app.router.clone().oneshot(session_req).await.unwrap(); + assert_eq!( + session_resp.status(), + StatusCode::CREATED, + "failed to register target session" + ); +} + +/// Full setup: create an API client, register DPoP sessions for both the owner +/// and the target account, then call linkAccount. +/// Returns `(client_key, client_secret, dpop_key, access_token)`. +async fn setup_linked_account( + app: &common::app::TestApp, + owner_did: &str, + target_did: &str, +) -> (String, String, Value, String) { + let (client_key, client_secret, _id) = app.create_api_client("confidential", None).await; + + // Provision DPoP key + session for the owner + let key_req = post_json_with_headers( + "/oauth/dpop-keys", + &json!({}), + vec![ + ("x-client-key", &client_key), + ("x-client-secret", &client_secret), + ], + ); + let key_resp = app.router.clone().oneshot(key_req).await.unwrap(); + assert_eq!(key_resp.status(), StatusCode::CREATED); + let key_body = response_json(key_resp).await; + let provision_id = key_body["provision_id"].as_str().unwrap().to_string(); + let dpop_key = key_body["dpop_key"].clone(); + + let access_token = format!("test-owner-access-{}", uuid::Uuid::new_v4()); + let session_req = post_json_with_headers( + "/oauth/sessions", + &json!({ + "provision_id": provision_id, + "did": owner_did, + "access_token": &access_token, + "scopes": "atproto", + "pds_url": "https://pds.example.com", + }), + vec![ + ("x-client-key", &client_key), + ("x-client-secret", &client_secret), + ], + ); + let session_resp = app.router.clone().oneshot(session_req).await.unwrap(); + assert_eq!(session_resp.status(), StatusCode::CREATED); + + // Register a session for the target account under the same API client + register_target_session(app, &client_key, &client_secret, target_did).await; + + // Link the account + let resp = dpop_post( + app, + "/xrpc/dev.happyview.delegation.linkAccount", + &json!({ "did": target_did }), + &client_key, + &dpop_key, + &access_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED, "linkAccount failed"); + + (client_key, client_secret, dpop_key, access_token) +} + +/// Provision a DPoP session for a user under an existing API client. +/// Returns `(dpop_key, access_token)` — use the shared `client_key` for requests. +async fn setup_session_for_client( + app: &common::app::TestApp, + client_key: &str, + client_secret: &str, + user_did: &str, +) -> (Value, String) { + let key_req = post_json_with_headers( + "/oauth/dpop-keys", + &json!({}), + vec![ + ("x-client-key", client_key), + ("x-client-secret", client_secret), + ], + ); + let key_resp = app.router.clone().oneshot(key_req).await.unwrap(); + assert_eq!(key_resp.status(), StatusCode::CREATED); + let key_body = response_json(key_resp).await; + let provision_id = key_body["provision_id"].as_str().unwrap().to_string(); + let dpop_key = key_body["dpop_key"].clone(); + + let access_token = format!("test-access-{}", uuid::Uuid::new_v4()); + let session_req = post_json_with_headers( + "/oauth/sessions", + &json!({ + "provision_id": provision_id, + "did": user_did, + "access_token": &access_token, + "scopes": "atproto", + "pds_url": "https://pds.example.com", + }), + vec![ + ("x-client-key", client_key), + ("x-client-secret", client_secret), + ], + ); + let session_resp = app.router.clone().oneshot(session_req).await.unwrap(); + assert_eq!(session_resp.status(), StatusCode::CREATED); + + (dpop_key, access_token) +} + +// --------------------------------------------------------------------------- +// linkAccount +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn link_account_success() { + let app = common::app::TestApp::new_with_encryption().await; + let owner_did = "did:plc:owner1"; + let target_did = "did:plc:studio1"; + + let (client_key, _client_secret, dpop_key, access_token) = + setup_linked_account(&app, owner_did, target_did).await; + + // Verify via getAccount + let resp = dpop_get( + &app, + &format!( + "/xrpc/dev.happyview.delegation.getAccount?did={}", + target_did + ), + &client_key, + &dpop_key, + &access_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let body = response_json(resp).await; + assert_eq!(body["did"], target_did); + assert_eq!(body["role"], "owner"); + assert_eq!(body["linkedBy"], owner_did); +} + +#[tokio::test] +#[serial] +async fn link_account_already_linked() { + let app = common::app::TestApp::new_with_encryption().await; + let owner_did = "did:plc:owner2"; + let target_did = "did:plc:studio2"; + + let (client_key, _client_secret, dpop_key, access_token) = + setup_linked_account(&app, owner_did, target_did).await; + + // Try to link again + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.linkAccount", + &json!({ "did": target_did }), + &client_key, + &dpop_key, + &access_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::CONFLICT); +} + +#[tokio::test] +#[serial] +async fn link_account_self_link_rejected() { + let app = common::app::TestApp::new_with_encryption().await; + let user_did = "did:plc:selflinker"; + + let (client_key, dpop_key, access_token) = setup_dpop_session(&app, user_did).await; + + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.linkAccount", + &json!({ "did": user_did }), + &client_key, + &dpop_key, + &access_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +#[serial] +async fn link_account_no_session_for_target() { + let app = common::app::TestApp::new_with_encryption().await; + let owner_did = "did:plc:owner3"; + let target_did = "did:plc:nosession"; + + let (client_key, dpop_key, access_token) = setup_dpop_session(&app, owner_did).await; + + // Don't register a session for target_did — should fail + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.linkAccount", + &json!({ "did": target_did }), + &client_key, + &dpop_key, + &access_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +// --------------------------------------------------------------------------- +// unlinkAccount +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn unlink_account_success() { + let app = common::app::TestApp::new_with_encryption().await; + let owner_did = "did:plc:unlink_owner"; + let target_did = "did:plc:unlink_studio"; + + let (client_key, _client_secret, dpop_key, access_token) = + setup_linked_account(&app, owner_did, target_did).await; + + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.unlinkAccount", + &json!({ "did": target_did }), + &client_key, + &dpop_key, + &access_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + + // Verify account is gone + let resp = dpop_get( + &app, + &format!( + "/xrpc/dev.happyview.delegation.getAccount?did={}", + target_did + ), + &client_key, + &dpop_key, + &access_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +#[serial] +async fn unlink_account_non_owner_rejected() { + let app = common::app::TestApp::new_with_encryption().await; + let owner_did = "did:plc:unlink_owner2"; + let admin_did = "did:plc:unlink_admin2"; + let target_did = "did:plc:unlink_studio2"; + + let (owner_key, owner_secret, owner_dpop, owner_token) = + setup_linked_account(&app, owner_did, target_did).await; + + // Add an admin + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.addDelegate", + &json!({ "accountDid": target_did, "userDid": admin_did, "role": "admin" }), + &owner_key, + &owner_dpop, + &owner_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + + // Admin tries to unlink — should be rejected (owner-only) + let (admin_dpop, admin_token) = + setup_session_for_client(&app, &owner_key, &owner_secret, admin_did).await; + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.unlinkAccount", + &json!({ "did": target_did }), + &owner_key, + &admin_dpop, + &admin_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +// --------------------------------------------------------------------------- +// addDelegate +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn add_delegate_success() { + let app = common::app::TestApp::new_with_encryption().await; + let owner_did = "did:plc:add_owner"; + let member_did = "did:plc:add_member"; + let target_did = "did:plc:add_studio"; + + let (client_key, _client_secret, dpop_key, access_token) = + setup_linked_account(&app, owner_did, target_did).await; + + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.addDelegate", + &json!({ "accountDid": target_did, "userDid": member_did, "role": "member" }), + &client_key, + &dpop_key, + &access_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + + // Verify via listDelegates + let resp = dpop_get( + &app, + &format!( + "/xrpc/dev.happyview.delegation.listDelegates?accountDid={}", + target_did + ), + &client_key, + &dpop_key, + &access_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let body = response_json(resp).await; + let delegates = body["delegates"].as_array().unwrap(); + assert_eq!(delegates.len(), 2); // owner + member + let member = delegates + .iter() + .find(|d| d["userDid"] == member_did) + .unwrap(); + assert_eq!(member["role"], "member"); + assert_eq!(member["grantedBy"], owner_did); +} + +#[tokio::test] +#[serial] +async fn add_delegate_owner_role_rejected() { + let app = common::app::TestApp::new_with_encryption().await; + let owner_did = "did:plc:add_owner2"; + let target_did = "did:plc:add_studio2"; + + let (client_key, _client_secret, dpop_key, access_token) = + setup_linked_account(&app, owner_did, target_did).await; + + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.addDelegate", + &json!({ "accountDid": target_did, "userDid": "did:plc:someone", "role": "owner" }), + &client_key, + &dpop_key, + &access_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +#[serial] +async fn add_delegate_already_exists() { + let app = common::app::TestApp::new_with_encryption().await; + let owner_did = "did:plc:add_owner3"; + let member_did = "did:plc:add_member3"; + let target_did = "did:plc:add_studio3"; + + let (client_key, _client_secret, dpop_key, access_token) = + setup_linked_account(&app, owner_did, target_did).await; + + // Add member + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.addDelegate", + &json!({ "accountDid": target_did, "userDid": member_did, "role": "member" }), + &client_key, + &dpop_key, + &access_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + + // Try to add again + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.addDelegate", + &json!({ "accountDid": target_did, "userDid": member_did, "role": "admin" }), + &client_key, + &dpop_key, + &access_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::CONFLICT); +} + +#[tokio::test] +#[serial] +async fn add_delegate_member_cannot_add() { + let app = common::app::TestApp::new_with_encryption().await; + let owner_did = "did:plc:add_owner4"; + let member_did = "did:plc:add_member4"; + let target_did = "did:plc:add_studio4"; + + let (owner_key, owner_secret, owner_dpop, owner_token) = + setup_linked_account(&app, owner_did, target_did).await; + + // Add a member + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.addDelegate", + &json!({ "accountDid": target_did, "userDid": member_did, "role": "member" }), + &owner_key, + &owner_dpop, + &owner_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + + // Member tries to add another delegate — should fail (members can't manage) + let (member_dpop, member_token) = + setup_session_for_client(&app, &owner_key, &owner_secret, member_did).await; + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.addDelegate", + &json!({ "accountDid": target_did, "userDid": "did:plc:someone", "role": "member" }), + &owner_key, + &member_dpop, + &member_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +// --------------------------------------------------------------------------- +// removeDelegate +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn remove_delegate_success() { + let app = common::app::TestApp::new_with_encryption().await; + let owner_did = "did:plc:rm_owner"; + let member_did = "did:plc:rm_member"; + let target_did = "did:plc:rm_studio"; + + let (client_key, _client_secret, dpop_key, access_token) = + setup_linked_account(&app, owner_did, target_did).await; + + // Add then remove a member + dpop_post( + &app, + "/xrpc/dev.happyview.delegation.addDelegate", + &json!({ "accountDid": target_did, "userDid": member_did, "role": "member" }), + &client_key, + &dpop_key, + &access_token, + ) + .await; + + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.removeDelegate", + &json!({ "accountDid": target_did, "userDid": member_did }), + &client_key, + &dpop_key, + &access_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + + // Verify only owner remains + let resp = dpop_get( + &app, + &format!( + "/xrpc/dev.happyview.delegation.listDelegates?accountDid={}", + target_did + ), + &client_key, + &dpop_key, + &access_token, + ) + .await; + let body = response_json(resp).await; + let delegates = body["delegates"].as_array().unwrap(); + assert_eq!(delegates.len(), 1); + assert_eq!(delegates[0]["role"], "owner"); +} + +#[tokio::test] +#[serial] +async fn remove_delegate_cannot_remove_owner() { + let app = common::app::TestApp::new_with_encryption().await; + let owner_did = "did:plc:rm_owner2"; + let target_did = "did:plc:rm_studio2"; + + let (client_key, _client_secret, dpop_key, access_token) = + setup_linked_account(&app, owner_did, target_did).await; + + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.removeDelegate", + &json!({ "accountDid": target_did, "userDid": owner_did }), + &client_key, + &dpop_key, + &access_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +#[serial] +async fn remove_delegate_admin_cannot_remove_admin() { + let app = common::app::TestApp::new_with_encryption().await; + let owner_did = "did:plc:rm_owner3"; + let admin1_did = "did:plc:rm_admin3a"; + let admin2_did = "did:plc:rm_admin3b"; + let target_did = "did:plc:rm_studio3"; + + let (owner_key, owner_secret, owner_dpop, owner_token) = + setup_linked_account(&app, owner_did, target_did).await; + + // Add two admins + dpop_post( + &app, + "/xrpc/dev.happyview.delegation.addDelegate", + &json!({ "accountDid": target_did, "userDid": admin1_did, "role": "admin" }), + &owner_key, + &owner_dpop, + &owner_token, + ) + .await; + dpop_post( + &app, + "/xrpc/dev.happyview.delegation.addDelegate", + &json!({ "accountDid": target_did, "userDid": admin2_did, "role": "admin" }), + &owner_key, + &owner_dpop, + &owner_token, + ) + .await; + + // Admin1 tries to remove admin2 + let (a1_dpop, a1_token) = + setup_session_for_client(&app, &owner_key, &owner_secret, admin1_did).await; + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.removeDelegate", + &json!({ "accountDid": target_did, "userDid": admin2_did }), + &owner_key, + &a1_dpop, + &a1_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +// --------------------------------------------------------------------------- +// listAccounts +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn list_accounts_returns_linked_accounts() { + let app = common::app::TestApp::new_with_encryption().await; + let owner_did = "did:plc:list_owner"; + let studio1 = "did:plc:list_studio1"; + let studio2 = "did:plc:list_studio2"; + + let (client_key, client_secret, dpop_key, access_token) = + setup_linked_account(&app, owner_did, studio1).await; + + // Link a second account under the same API client + register_target_session(&app, &client_key, &client_secret, studio2).await; + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.linkAccount", + &json!({ "did": studio2 }), + &client_key, + &dpop_key, + &access_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + + // List accounts — should include both + let resp = dpop_get( + &app, + "/xrpc/dev.happyview.delegation.listAccounts", + &client_key, + &dpop_key, + &access_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let body = response_json(resp).await; + let accounts = body["accounts"].as_array().unwrap(); + assert_eq!(accounts.len(), 2); +} + +#[tokio::test] +#[serial] +async fn list_accounts_empty() { + let app = common::app::TestApp::new_with_encryption().await; + let user_did = "did:plc:no_accounts"; + + let (client_key, dpop_key, access_token) = setup_dpop_session(&app, user_did).await; + + let resp = dpop_get( + &app, + "/xrpc/dev.happyview.delegation.listAccounts", + &client_key, + &dpop_key, + &access_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let body = response_json(resp).await; + let accounts = body["accounts"].as_array().unwrap(); + assert!(accounts.is_empty()); +} + +// --------------------------------------------------------------------------- +// getAccount +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn get_account_not_a_delegate() { + let app = common::app::TestApp::new_with_encryption().await; + let owner_did = "did:plc:ga_owner"; + let target_did = "did:plc:ga_studio"; + + let (owner_key, owner_secret, _owner_dpop, _owner_token) = + setup_linked_account(&app, owner_did, target_did).await; + + // Different user (same app, but not a delegate) tries to get account details + let outsider_did = "did:plc:ga_outsider"; + let (out_dpop, out_token) = + setup_session_for_client(&app, &owner_key, &owner_secret, outsider_did).await; + let resp = dpop_get( + &app, + &format!( + "/xrpc/dev.happyview.delegation.getAccount?did={}", + target_did + ), + &owner_key, + &out_dpop, + &out_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +// --------------------------------------------------------------------------- +// listDelegates +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn list_delegates_member_cannot_list() { + let app = common::app::TestApp::new_with_encryption().await; + let owner_did = "did:plc:ld_owner"; + let member_did = "did:plc:ld_member"; + let target_did = "did:plc:ld_studio"; + + let (owner_key, owner_secret, owner_dpop, owner_token) = + setup_linked_account(&app, owner_did, target_did).await; + + // Add a member + dpop_post( + &app, + "/xrpc/dev.happyview.delegation.addDelegate", + &json!({ "accountDid": target_did, "userDid": member_did, "role": "member" }), + &owner_key, + &owner_dpop, + &owner_token, + ) + .await; + + // Member tries to list delegates (same client, but member role can't list) + let (member_dpop, member_token) = + setup_session_for_client(&app, &owner_key, &owner_secret, member_did).await; + let resp = dpop_get( + &app, + &format!( + "/xrpc/dev.happyview.delegation.listDelegates?accountDid={}", + target_did + ), + &owner_key, + &member_dpop, + &member_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +// --------------------------------------------------------------------------- +// DelegateRole unit tests (no TestApp needed) +// --------------------------------------------------------------------------- + +#[test] +fn delegate_role_from_str_roundtrip() { + use happyview::delegation::DelegateRole; + for role in &[ + DelegateRole::Owner, + DelegateRole::Admin, + DelegateRole::Member, + ] { + let s = role.as_str(); + assert_eq!(DelegateRole::from_str(s), Some(*role)); + } + assert_eq!(DelegateRole::from_str("invalid"), None); +} + +#[test] +fn delegate_role_can_write() { + use happyview::delegation::DelegateRole; + assert!(DelegateRole::Owner.can_write()); + assert!(DelegateRole::Admin.can_write()); + assert!(!DelegateRole::Member.can_write()); +} + +#[test] +fn delegate_role_can_manage_members() { + use happyview::delegation::DelegateRole; + assert!(DelegateRole::Owner.can_manage_members()); + assert!(DelegateRole::Admin.can_manage_members()); + assert!(!DelegateRole::Member.can_manage_members()); +} + +// --------------------------------------------------------------------------- +// Helpers for delegated write tests +// --------------------------------------------------------------------------- + +fn admin_post_request( + uri: &str, + cookie: (axum::http::HeaderName, axum::http::HeaderValue), + body: &Value, +) -> Request { + Request::builder() + .method("POST") + .uri(uri) + .header(cookie.0, cookie.1) + .header("content-type", "application/json") + .header("host", "127.0.0.1:0") + .body(Body::from(serde_json::to_vec(body).unwrap())) + .unwrap() +} + +async fn seed_procedure_lexicon(app: &common::app::TestApp) { + let resp = app + .router + .clone() + .oneshot(admin_post_request( + "/admin/lexicons", + app.admin_cookie(), + &json!({ + "lexicon_json": common::fixtures::create_game_procedure_lexicon(), + "target_collection": "games.gamesgamesgamesgames.game" + }), + )) + .await + .unwrap(); + assert!( + resp.status().is_success(), + "failed to seed procedure lexicon: {}", + resp.status() + ); +} + +async fn update_session_pds_url(app: &common::app::TestApp, user_did: &str, pds_url: &str) { + let sql = adapt_sql( + "UPDATE dpop_sessions SET pds_url = ? WHERE user_did = ?", + app.state.db_backend, + ); + sqlx::query(&sql) + .bind(pds_url) + .bind(user_did) + .execute(&app.state.db) + .await + .expect("failed to update session pds_url"); +} + +// --------------------------------------------------------------------------- +// Delegated writes — auth gates +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn delegated_write_non_delegate_rejected() { + let app = common::app::TestApp::new_with_encryption().await; + seed_procedure_lexicon(&app).await; + + let owner_did = "did:plc:dw_owner1"; + let target_did = "did:plc:dw_studio1"; + setup_linked_account(&app, owner_did, target_did).await; + + // Outsider (not a delegate) tries a delegated write + let outsider_did = "did:plc:dw_outsider1"; + let (out_key, out_dpop, out_token) = setup_dpop_session(&app, outsider_did).await; + + let resp = dpop_post( + &app, + "/xrpc/games.gamesgamesgamesgames.createGame", + &json!({ "title": "Hacked Game", "delegateDid": target_did }), + &out_key, + &out_dpop, + &out_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +#[serial] +async fn delegated_write_member_rejected() { + let app = common::app::TestApp::new_with_encryption().await; + seed_procedure_lexicon(&app).await; + + let owner_did = "did:plc:dw_owner2"; + let member_did = "did:plc:dw_member2"; + let target_did = "did:plc:dw_studio2"; + + let (owner_key, owner_secret, owner_dpop, owner_token) = + setup_linked_account(&app, owner_did, target_did).await; + + // Add a member (cannot write) + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.addDelegate", + &json!({ "accountDid": target_did, "userDid": member_did, "role": "member" }), + &owner_key, + &owner_dpop, + &owner_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + + // Member tries a delegated write (same client, but member role can't write) + let (mem_dpop, mem_token) = + setup_session_for_client(&app, &owner_key, &owner_secret, member_did).await; + let resp = dpop_post( + &app, + "/xrpc/games.gamesgamesgamesgames.createGame", + &json!({ "title": "Member Game", "delegateDid": target_did }), + &owner_key, + &mem_dpop, + &mem_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +// --------------------------------------------------------------------------- +// Delegated writes — happy path +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn delegated_write_owner_success() { + let app = common::app::TestApp::new_with_encryption().await; + seed_procedure_lexicon(&app).await; + + let owner_did = "did:plc:dw_owner3"; + let target_did = "did:plc:dw_studio3"; + + let (owner_key, _owner_secret, owner_dpop, owner_token) = + setup_linked_account(&app, owner_did, target_did).await; + + // Point the target's DPoP session at the mock server so dpop_pds_post + // reaches wiremock instead of a real PDS. + let mock_url = app.mock_server.uri(); + update_session_pds_url(&app, target_did, &mock_url).await; + + // Mock PDS createRecord + Mock::given(method("POST")) + .and(path("/xrpc/com.atproto.repo.createRecord")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "uri": format!("at://{target_did}/games.gamesgamesgamesgames.game/abc123"), + "cid": "bafytest123" + }))) + .expect(1) + .mount(&app.mock_server) + .await; + + let resp = dpop_post( + &app, + "/xrpc/games.gamesgamesgamesgames.createGame", + &json!({ "title": "Studio Game", "delegateDid": target_did }), + &owner_key, + &owner_dpop, + &owner_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + + let body = response_json(resp).await; + assert_eq!( + body["uri"], + format!("at://{target_did}/games.gamesgamesgamesgames.game/abc123") + ); + assert_eq!(body["cid"], "bafytest123"); +} + +#[tokio::test] +#[serial] +async fn delegated_write_admin_success() { + let app = common::app::TestApp::new_with_encryption().await; + seed_procedure_lexicon(&app).await; + + let owner_did = "did:plc:dw_owner4"; + let admin_did = "did:plc:dw_admin4"; + let target_did = "did:plc:dw_studio4"; + + let (owner_key, owner_secret, owner_dpop, owner_token) = + setup_linked_account(&app, owner_did, target_did).await; + + // Add an admin + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.addDelegate", + &json!({ "accountDid": target_did, "userDid": admin_did, "role": "admin" }), + &owner_key, + &owner_dpop, + &owner_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + + // Point the target's DPoP session at the mock server + let mock_url = app.mock_server.uri(); + update_session_pds_url(&app, target_did, &mock_url).await; + + // Mock PDS createRecord + Mock::given(method("POST")) + .and(path("/xrpc/com.atproto.repo.createRecord")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "uri": format!("at://{target_did}/games.gamesgamesgamesgames.game/def456"), + "cid": "bafyadmin456" + }))) + .expect(1) + .mount(&app.mock_server) + .await; + + // Admin does a delegated write (same client) + let (adm_dpop, adm_token) = + setup_session_for_client(&app, &owner_key, &owner_secret, admin_did).await; + let resp = dpop_post( + &app, + "/xrpc/games.gamesgamesgamesgames.createGame", + &json!({ "title": "Admin Game", "delegateDid": target_did }), + &owner_key, + &adm_dpop, + &adm_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + + let body = response_json(resp).await; + assert_eq!(body["cid"], "bafyadmin456"); +} + +// --------------------------------------------------------------------------- +// Positive-path coverage for admin / member operations +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn admin_can_add_delegate() { + let app = common::app::TestApp::new_with_encryption().await; + + let owner_did = "did:plc:acd_owner"; + let admin_did = "did:plc:acd_admin"; + let new_member_did = "did:plc:acd_newmember"; + let target_did = "did:plc:acd_studio"; + + let (owner_key, owner_secret, owner_dpop, owner_token) = + setup_linked_account(&app, owner_did, target_did).await; + + // Owner adds admin + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.addDelegate", + &json!({ "accountDid": target_did, "userDid": admin_did, "role": "admin" }), + &owner_key, + &owner_dpop, + &owner_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + + // Admin adds a member (same client) + let (adm_dpop, adm_token) = + setup_session_for_client(&app, &owner_key, &owner_secret, admin_did).await; + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.addDelegate", + &json!({ "accountDid": target_did, "userDid": new_member_did, "role": "member" }), + &owner_key, + &adm_dpop, + &adm_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + + // Verify the member exists via listDelegates (as owner) + let resp = dpop_get( + &app, + &format!( + "/xrpc/dev.happyview.delegation.listDelegates?accountDid={}", + target_did + ), + &owner_key, + &owner_dpop, + &owner_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let body = response_json(resp).await; + let delegates = body["delegates"].as_array().unwrap(); + assert_eq!(delegates.len(), 3); // owner + admin + member + let member = delegates + .iter() + .find(|d| d["userDid"] == new_member_did) + .unwrap(); + assert_eq!(member["role"], "member"); + assert_eq!(member["grantedBy"], admin_did); +} + +#[tokio::test] +#[serial] +async fn admin_can_remove_member() { + let app = common::app::TestApp::new_with_encryption().await; + + let owner_did = "did:plc:arm_owner"; + let admin_did = "did:plc:arm_admin"; + let member_did = "did:plc:arm_member"; + let target_did = "did:plc:arm_studio"; + + let (owner_key, owner_secret, owner_dpop, owner_token) = + setup_linked_account(&app, owner_did, target_did).await; + + // Owner adds admin and member + dpop_post( + &app, + "/xrpc/dev.happyview.delegation.addDelegate", + &json!({ "accountDid": target_did, "userDid": admin_did, "role": "admin" }), + &owner_key, + &owner_dpop, + &owner_token, + ) + .await; + dpop_post( + &app, + "/xrpc/dev.happyview.delegation.addDelegate", + &json!({ "accountDid": target_did, "userDid": member_did, "role": "member" }), + &owner_key, + &owner_dpop, + &owner_token, + ) + .await; + + // Admin removes member (same client) + let (adm_dpop, adm_token) = + setup_session_for_client(&app, &owner_key, &owner_secret, admin_did).await; + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.removeDelegate", + &json!({ "accountDid": target_did, "userDid": member_did }), + &owner_key, + &adm_dpop, + &adm_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + + // Verify member is gone + let resp = dpop_get( + &app, + &format!( + "/xrpc/dev.happyview.delegation.listDelegates?accountDid={}", + target_did + ), + &owner_key, + &owner_dpop, + &owner_token, + ) + .await; + let body = response_json(resp).await; + let delegates = body["delegates"].as_array().unwrap(); + assert_eq!(delegates.len(), 2); // owner + admin only + assert!(delegates.iter().all(|d| d["userDid"] != member_did)); +} + +#[tokio::test] +#[serial] +async fn admin_can_list_delegates() { + let app = common::app::TestApp::new_with_encryption().await; + + let owner_did = "did:plc:ald_owner"; + let admin_did = "did:plc:ald_admin"; + let target_did = "did:plc:ald_studio"; + + let (owner_key, owner_secret, owner_dpop, owner_token) = + setup_linked_account(&app, owner_did, target_did).await; + + // Owner adds admin + dpop_post( + &app, + "/xrpc/dev.happyview.delegation.addDelegate", + &json!({ "accountDid": target_did, "userDid": admin_did, "role": "admin" }), + &owner_key, + &owner_dpop, + &owner_token, + ) + .await; + + // Admin lists delegates (same client) + let (adm_dpop, adm_token) = + setup_session_for_client(&app, &owner_key, &owner_secret, admin_did).await; + let resp = dpop_get( + &app, + &format!( + "/xrpc/dev.happyview.delegation.listDelegates?accountDid={}", + target_did + ), + &owner_key, + &adm_dpop, + &adm_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let body = response_json(resp).await; + let delegates = body["delegates"].as_array().unwrap(); + assert_eq!(delegates.len(), 2); // owner + admin +} + +#[tokio::test] +#[serial] +async fn member_can_view_account() { + let app = common::app::TestApp::new_with_encryption().await; + + let owner_did = "did:plc:mva_owner"; + let member_did = "did:plc:mva_member"; + let target_did = "did:plc:mva_studio"; + + let (owner_key, owner_secret, owner_dpop, owner_token) = + setup_linked_account(&app, owner_did, target_did).await; + + // Owner adds member + dpop_post( + &app, + "/xrpc/dev.happyview.delegation.addDelegate", + &json!({ "accountDid": target_did, "userDid": member_did, "role": "member" }), + &owner_key, + &owner_dpop, + &owner_token, + ) + .await; + + // Member calls getAccount (same client) + let (mem_dpop, mem_token) = + setup_session_for_client(&app, &owner_key, &owner_secret, member_did).await; + let resp = dpop_get( + &app, + &format!( + "/xrpc/dev.happyview.delegation.getAccount?did={}", + target_did + ), + &owner_key, + &mem_dpop, + &mem_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let body = response_json(resp).await; + assert_eq!(body["did"], target_did); + assert_eq!(body["role"], "member"); +} + +#[tokio::test] +#[serial] +async fn owner_can_remove_admin() { + let app = common::app::TestApp::new_with_encryption().await; + + let owner_did = "did:plc:ora_owner"; + let admin_did = "did:plc:ora_admin"; + let target_did = "did:plc:ora_studio"; + + let (owner_key, _owner_secret, owner_dpop, owner_token) = + setup_linked_account(&app, owner_did, target_did).await; + + // Owner adds admin + dpop_post( + &app, + "/xrpc/dev.happyview.delegation.addDelegate", + &json!({ "accountDid": target_did, "userDid": admin_did, "role": "admin" }), + &owner_key, + &owner_dpop, + &owner_token, + ) + .await; + + // Owner removes admin + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.removeDelegate", + &json!({ "accountDid": target_did, "userDid": admin_did }), + &owner_key, + &owner_dpop, + &owner_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + + // Verify only owner remains + let resp = dpop_get( + &app, + &format!( + "/xrpc/dev.happyview.delegation.listDelegates?accountDid={}", + target_did + ), + &owner_key, + &owner_dpop, + &owner_token, + ) + .await; + let body = response_json(resp).await; + let delegates = body["delegates"].as_array().unwrap(); + assert_eq!(delegates.len(), 1); + assert_eq!(delegates[0]["role"], "owner"); +} + +// --------------------------------------------------------------------------- +// Cross-client scoping — operations from a different API client are rejected +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn cross_client_get_account_rejected() { + let app = common::app::TestApp::new_with_encryption().await; + let owner_did = "did:plc:xc_ga_owner"; + let target_did = "did:plc:xc_ga_studio"; + + setup_linked_account(&app, owner_did, target_did).await; + + // Different API client tries to access the account + let (other_key, other_dpop, other_token) = setup_dpop_session(&app, owner_did).await; + let resp = dpop_get( + &app, + &format!( + "/xrpc/dev.happyview.delegation.getAccount?did={}", + target_did + ), + &other_key, + &other_dpop, + &other_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +#[serial] +async fn cross_client_add_delegate_rejected() { + let app = common::app::TestApp::new_with_encryption().await; + let owner_did = "did:plc:xc_ad_owner"; + let target_did = "did:plc:xc_ad_studio"; + + setup_linked_account(&app, owner_did, target_did).await; + + // Different API client tries to add a delegate + let (other_key, other_dpop, other_token) = setup_dpop_session(&app, owner_did).await; + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.addDelegate", + &json!({ "accountDid": target_did, "userDid": "did:plc:xc_someone", "role": "member" }), + &other_key, + &other_dpop, + &other_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +#[serial] +async fn cross_client_delegated_write_rejected() { + let app = common::app::TestApp::new_with_encryption().await; + seed_procedure_lexicon(&app).await; + + let owner_did = "did:plc:xc_dw_owner"; + let admin_did = "did:plc:xc_dw_admin"; + let target_did = "did:plc:xc_dw_studio"; + + let (owner_key, _owner_secret, owner_dpop, owner_token) = + setup_linked_account(&app, owner_did, target_did).await; + + // Add an admin under the correct client + dpop_post( + &app, + "/xrpc/dev.happyview.delegation.addDelegate", + &json!({ "accountDid": target_did, "userDid": admin_did, "role": "admin" }), + &owner_key, + &owner_dpop, + &owner_token, + ) + .await; + + // Admin authenticates via a different API client and tries a delegated write + let (other_key, other_dpop, other_token) = setup_dpop_session(&app, admin_did).await; + let resp = dpop_post( + &app, + "/xrpc/games.gamesgamesgamesgames.createGame", + &json!({ "title": "Cross-client Game", "delegateDid": target_did }), + &other_key, + &other_dpop, + &other_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +#[serial] +async fn cross_client_list_accounts_isolated() { + let app = common::app::TestApp::new_with_encryption().await; + let owner_did = "did:plc:xc_la_owner"; + let studio1 = "did:plc:xc_la_studio1"; + let studio2 = "did:plc:xc_la_studio2"; + + // Link studio1 under client A + setup_linked_account(&app, owner_did, studio1).await; + + // Link studio2 under client B (different API client) + let (client_b_key, _client_b_secret, client_b_dpop, client_b_token) = + setup_linked_account(&app, owner_did, studio2).await; + + // listAccounts from client B should only show studio2 + let resp = dpop_get( + &app, + "/xrpc/dev.happyview.delegation.listAccounts", + &client_b_key, + &client_b_dpop, + &client_b_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let body = response_json(resp).await; + let accounts = body["accounts"].as_array().unwrap(); + assert_eq!(accounts.len(), 1); + assert_eq!(accounts[0]["did"], studio2); +}