service actor undeletable: domain const, 403, hide delete UI
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -148,7 +148,7 @@ function UserProfilePage() {
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
{isAdmin && !data.is_federated && (
|
||||
{isAdmin && !data.is_federated && !data.is_service_account && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
||||
Reference in New Issue
Block a user