From 9d0166f5cb36e642d58302c5b0330836ed07e991 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 26 Oct 2026 03:15:27 +0100 Subject: [PATCH] service actor undeletable: domain const, 403, hide delete UI --- crates/adapters/activitypub/src/lib.rs | 3 +- .../http-axum/src/api/user_profile.rs | 2 + crates/adapters/http-axum/src/html/profile.rs | 2 + .../http-axum/src/html/templates/users.rs | 1 + .../adapters/http-axum/templates/profile.html | 2 +- crates/api-types/src/users.rs | 3 ++ .../src/users/request_account_deletion.rs | 6 +++ .../users/request_account_deletion_test.rs | 15 ++++++++ .../bootstrap/tests/account_deletion_test.rs | 38 +++++++++++++++++++ crates/domain/src/value_objects/ids.rs | 14 +++++++ spa/src/features/users.ts | 1 + spa/src/routes/_app/users.$id.tsx | 2 +- 12 files changed, 85 insertions(+), 4 deletions(-) diff --git a/crates/adapters/activitypub/src/lib.rs b/crates/adapters/activitypub/src/lib.rs index 50ad31a9..4b463eb7 100644 --- a/crates/adapters/activitypub/src/lib.rs +++ b/crates/adapters/activitypub/src/lib.rs @@ -13,8 +13,7 @@ pub(crate) mod urls; pub mod user_adapter; pub mod watchlist_handler; -pub const INSTANCE_ACTOR_ID: uuid::Uuid = - uuid::Uuid::from_bytes([0, 0, 0, 0, 0, 0, 0x40, 0, 0x80, 0, 0, 0, 0, 0, 0, 0]); +pub const INSTANCE_ACTOR_ID: uuid::Uuid = domain::value_objects::SERVICE_ACTOR_UUID; pub use k_ap::{ ActivityPubService, ActivityRepository, ActorRepository, ApContentReader, ApFederationConfig, diff --git a/crates/adapters/http-axum/src/api/user_profile.rs b/crates/adapters/http-axum/src/api/user_profile.rs index c12874ef..bac314cb 100644 --- a/crates/adapters/http-axum/src/api/user_profile.rs +++ b/crates/adapters/http-axum/src/api/user_profile.rs @@ -151,6 +151,7 @@ pub async fn get_user_profile( } }, is_federated: false, + is_service_account: domain::value_objects::UserId::from_uuid(user_id).is_service_actor(), handle: Some(profile.identity.handle.clone()), actor_url: Some(profile.identity.actor_url.clone()), }) @@ -227,6 +228,7 @@ async fn build_federated_profile_response( view_data, goals: None, is_federated: true, + is_service_account: false, handle: Some(fed.handle), actor_url: Some(fed.actor_url), }) diff --git a/crates/adapters/http-axum/src/html/profile.rs b/crates/adapters/http-axum/src/html/profile.rs index a27e99d3..b405fd60 100644 --- a/crates/adapters/http-axum/src/html/profile.rs +++ b/crates/adapters/http-axum/src/html/profile.rs @@ -243,6 +243,8 @@ pub async fn get_user_profile_html( heatmap, page_items: pag.page_items, is_own_profile, + is_service_account: domain::value_objects::UserId::from_uuid(profile_user_uuid) + .is_service_actor(), error: params.error, following_count: profile.following_count, followers_count: profile.followers_count, diff --git a/crates/adapters/http-axum/src/html/templates/users.rs b/crates/adapters/http-axum/src/html/templates/users.rs index cd7fbf15..f3ac8019 100644 --- a/crates/adapters/http-axum/src/html/templates/users.rs +++ b/crates/adapters/http-axum/src/html/templates/users.rs @@ -56,6 +56,7 @@ pub struct ProfileTemplate<'a> { pub heatmap: Vec, pub page_items: Vec, pub is_own_profile: bool, + pub is_service_account: bool, pub error: Option, pub following_count: usize, pub followers_count: usize, diff --git a/crates/adapters/http-axum/templates/profile.html b/crates/adapters/http-axum/templates/profile.html index 1cd77b55..e48e25ef 100644 --- a/crates/adapters/http-axum/templates/profile.html +++ b/crates/adapters/http-axum/templates/profile.html @@ -97,7 +97,7 @@ {% endif %} - {% if ctx.is_admin && !is_own_profile %} + {% if ctx.is_admin && !is_own_profile && !is_service_account %}

Admin

{% if let Some(err) = error %} diff --git a/crates/api-types/src/users.rs b/crates/api-types/src/users.rs index 5e8fbe8a..4ccef828 100644 --- a/crates/api-types/src/users.rs +++ b/crates/api-types/src/users.rs @@ -126,6 +126,9 @@ pub struct UserProfileResponse { pub goals: Option>, #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub is_federated: bool, + /// The instance's service account: never deletable, so UIs hide the action. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub is_service_account: bool, #[serde(skip_serializing_if = "Option::is_none")] pub handle: Option, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/crates/application/src/users/request_account_deletion.rs b/crates/application/src/users/request_account_deletion.rs index de1546c0..c018b83b 100644 --- a/crates/application/src/users/request_account_deletion.rs +++ b/crates/application/src/users/request_account_deletion.rs @@ -12,6 +12,12 @@ pub async fn execute( target: UserId, password: &str, ) -> Result<(), DomainError> { + if target.is_service_actor() { + return Err(DomainError::Forbidden( + "the instance service account cannot be deleted".into(), + )); + } + let actor_user = deps .user .find_by_id(&actor) diff --git a/crates/application/tests/users/request_account_deletion_test.rs b/crates/application/tests/users/request_account_deletion_test.rs index 5fff6089..94eb8b0c 100644 --- a/crates/application/tests/users/request_account_deletion_test.rs +++ b/crates/application/tests/users/request_account_deletion_test.rs @@ -287,3 +287,18 @@ async fn a_non_admin_gets_forbidden_before_any_password_check() { assert!(matches!(err, DomainError::Forbidden(_)), "got {err:?}"); } + +#[tokio::test] +async fn the_service_actor_cannot_be_deleted_even_by_an_admin() { + let f = fixture(); + let admin = f.user("root", UserRole::Admin).await; + let other = f.user("second", UserRole::Admin).await; + let _ = other; + + let err = request_account_deletion::execute(&f.deps, admin, UserId::service_actor(), PASSWORD) + .await + .unwrap_err(); + + assert!(matches!(err, DomainError::Forbidden(_)), "got {err:?}"); + assert!(f.store.recorded_events().is_empty()); +} diff --git a/crates/bootstrap/tests/account_deletion_test.rs b/crates/bootstrap/tests/account_deletion_test.rs index 1b11a922..b592df10 100644 --- a/crates/bootstrap/tests/account_deletion_test.rs +++ b/crates/bootstrap/tests/account_deletion_test.rs @@ -311,3 +311,41 @@ async fn delete_forms_render_only_where_they_apply() { .await; assert!(!body.contains("/delete"), "embed page"); } + +const SERVICE_ACTOR: &str = "00000000-0000-4000-8000-000000000000"; + +#[tokio::test] +async fn the_service_actor_is_not_deletable_and_offers_no_delete_ui() { + let (router, pool, alice, bob) = two_users().await; + promote(&pool, &alice.user_id).await; + promote(&pool, &bob.user_id).await; + + let page = format!("/users/{SERVICE_ACTOR}"); + let (status, _, body) = send(&router, get(&page, Some(&alice.cookie))).await; + assert_eq!(status, 200, "{body}"); + assert!( + !body.contains(&format!("action=\"/users/{SERVICE_ACTOR}/delete\"")), + "admin sees a delete form for the service actor" + ); + + let uri = format!("/api/v1/users/{SERVICE_ACTOR}"); + let (status, _, body) = send(&router, delete_json(&uri, &alice.token, PASSWORD)).await; + assert_eq!(status, 403, "{body}"); + + let req = Request::builder() + .uri(&uri) + .header(header::AUTHORIZATION, format!("Bearer {}", alice.token)) + .body(Body::empty()) + .unwrap(); + let (status, _, body) = send(&router, req).await; + assert_eq!(status, 200, "{body}"); + assert!(body.contains("\"is_service_account\":true"), "{body}"); + + let req = Request::builder() + .uri(format!("/api/v1/users/{}", bob.user_id)) + .header(header::AUTHORIZATION, format!("Bearer {}", alice.token)) + .body(Body::empty()) + .unwrap(); + let (_, _, body) = send(&router, req).await; + assert!(!body.contains("is_service_account"), "{body}"); +} diff --git a/crates/domain/src/value_objects/ids.rs b/crates/domain/src/value_objects/ids.rs index 930a95d3..293b67c5 100644 --- a/crates/domain/src/value_objects/ids.rs +++ b/crates/domain/src/value_objects/ids.rs @@ -32,6 +32,20 @@ impl MovieId { uuid_id!(ReviewId); uuid_id!(UserId); + +/// Id of the instance's service actor (seeded by migration 0022). It signs +/// fetches for the whole instance and is never a deletable account. +pub const SERVICE_ACTOR_UUID: Uuid = + Uuid::from_bytes([0, 0, 0, 0, 0, 0, 0x40, 0, 0x80, 0, 0, 0, 0, 0, 0, 0]); + +impl UserId { + pub const fn service_actor() -> Self { + Self(SERVICE_ACTOR_UUID) + } + pub fn is_service_actor(&self) -> bool { + self.0 == SERVICE_ACTOR_UUID + } +} uuid_id!(ImportSessionId); uuid_id!(ImportProfileId); uuid_id!(WatchlistEntryId); diff --git a/spa/src/features/users.ts b/spa/src/features/users.ts index 852297e3..e5570194 100644 --- a/spa/src/features/users.ts +++ b/spa/src/features/users.ts @@ -112,6 +112,7 @@ export const userProfileResponseSchema = z.object({ trends: userTrendsDtoSchema.optional(), goals: z.array(goalDtoSchema).optional(), is_federated: z.boolean().optional().default(false), + is_service_account: z.boolean().optional().default(false), handle: z.string().optional(), display_name: z.string().optional(), bio: z.string().optional(), diff --git a/spa/src/routes/_app/users.$id.tsx b/spa/src/routes/_app/users.$id.tsx index 2c70fd21..0557b394 100644 --- a/spa/src/routes/_app/users.$id.tsx +++ b/spa/src/routes/_app/users.$id.tsx @@ -148,7 +148,7 @@ function UserProfilePage() { )} - {isAdmin && !data.is_federated && ( + {isAdmin && !data.is_federated && !data.is_service_account && (