service actor undeletable: domain const, 403, hide delete UI

This commit is contained in:
2026-10-26 03:15:27 +01:00
parent 3861d99e1f
commit 9d0166f5cb
12 changed files with 85 additions and 4 deletions

View File

@@ -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,

View File

@@ -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),
})

View File

@@ -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,

View File

@@ -56,6 +56,7 @@ pub struct ProfileTemplate<'a> {
pub heatmap: Vec<HeatmapCell>,
pub page_items: Vec<PageItem>,
pub is_own_profile: bool,
pub is_service_account: bool,
pub error: Option<String>,
pub following_count: usize,
pub followers_count: usize,

View File

@@ -97,7 +97,7 @@
</section>
{% endif %}
{% if ctx.is_admin && !is_own_profile %}
{% if ctx.is_admin && !is_own_profile && !is_service_account %}
<section class="profile-manage">
<h3>Admin</h3>
{% if let Some(err) = error %}

View File

@@ -126,6 +126,9 @@ pub struct UserProfileResponse {
pub goals: Option<Vec<GoalDto>>,
#[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<String>,
#[serde(skip_serializing_if = "Option::is_none")]

View File

@@ -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)

View File

@@ -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());
}

View File

@@ -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}");
}

View File

@@ -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);