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 user_adapter;
|
||||||
pub mod watchlist_handler;
|
pub mod watchlist_handler;
|
||||||
|
|
||||||
pub const INSTANCE_ACTOR_ID: uuid::Uuid =
|
pub const INSTANCE_ACTOR_ID: uuid::Uuid = domain::value_objects::SERVICE_ACTOR_UUID;
|
||||||
uuid::Uuid::from_bytes([0, 0, 0, 0, 0, 0, 0x40, 0, 0x80, 0, 0, 0, 0, 0, 0, 0]);
|
|
||||||
|
|
||||||
pub use k_ap::{
|
pub use k_ap::{
|
||||||
ActivityPubService, ActivityRepository, ActorRepository, ApContentReader, ApFederationConfig,
|
ActivityPubService, ActivityRepository, ActorRepository, ApContentReader, ApFederationConfig,
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ pub async fn get_user_profile(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
is_federated: false,
|
is_federated: false,
|
||||||
|
is_service_account: domain::value_objects::UserId::from_uuid(user_id).is_service_actor(),
|
||||||
handle: Some(profile.identity.handle.clone()),
|
handle: Some(profile.identity.handle.clone()),
|
||||||
actor_url: Some(profile.identity.actor_url.clone()),
|
actor_url: Some(profile.identity.actor_url.clone()),
|
||||||
})
|
})
|
||||||
@@ -227,6 +228,7 @@ async fn build_federated_profile_response(
|
|||||||
view_data,
|
view_data,
|
||||||
goals: None,
|
goals: None,
|
||||||
is_federated: true,
|
is_federated: true,
|
||||||
|
is_service_account: false,
|
||||||
handle: Some(fed.handle),
|
handle: Some(fed.handle),
|
||||||
actor_url: Some(fed.actor_url),
|
actor_url: Some(fed.actor_url),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -243,6 +243,8 @@ pub async fn get_user_profile_html(
|
|||||||
heatmap,
|
heatmap,
|
||||||
page_items: pag.page_items,
|
page_items: pag.page_items,
|
||||||
is_own_profile,
|
is_own_profile,
|
||||||
|
is_service_account: domain::value_objects::UserId::from_uuid(profile_user_uuid)
|
||||||
|
.is_service_actor(),
|
||||||
error: params.error,
|
error: params.error,
|
||||||
following_count: profile.following_count,
|
following_count: profile.following_count,
|
||||||
followers_count: profile.followers_count,
|
followers_count: profile.followers_count,
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ pub struct ProfileTemplate<'a> {
|
|||||||
pub heatmap: Vec<HeatmapCell>,
|
pub heatmap: Vec<HeatmapCell>,
|
||||||
pub page_items: Vec<PageItem>,
|
pub page_items: Vec<PageItem>,
|
||||||
pub is_own_profile: bool,
|
pub is_own_profile: bool,
|
||||||
|
pub is_service_account: bool,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
pub following_count: usize,
|
pub following_count: usize,
|
||||||
pub followers_count: usize,
|
pub followers_count: usize,
|
||||||
|
|||||||
@@ -97,7 +97,7 @@
|
|||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if ctx.is_admin && !is_own_profile %}
|
{% if ctx.is_admin && !is_own_profile && !is_service_account %}
|
||||||
<section class="profile-manage">
|
<section class="profile-manage">
|
||||||
<h3>Admin</h3>
|
<h3>Admin</h3>
|
||||||
{% if let Some(err) = error %}
|
{% if let Some(err) = error %}
|
||||||
|
|||||||
@@ -126,6 +126,9 @@ pub struct UserProfileResponse {
|
|||||||
pub goals: Option<Vec<GoalDto>>,
|
pub goals: Option<Vec<GoalDto>>,
|
||||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||||
pub is_federated: bool,
|
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")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub handle: Option<String>,
|
pub handle: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ pub async fn execute(
|
|||||||
target: UserId,
|
target: UserId,
|
||||||
password: &str,
|
password: &str,
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
|
if target.is_service_actor() {
|
||||||
|
return Err(DomainError::Forbidden(
|
||||||
|
"the instance service account cannot be deleted".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let actor_user = deps
|
let actor_user = deps
|
||||||
.user
|
.user
|
||||||
.find_by_id(&actor)
|
.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:?}");
|
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;
|
.await;
|
||||||
assert!(!body.contains("/delete"), "embed page");
|
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!(ReviewId);
|
||||||
uuid_id!(UserId);
|
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!(ImportSessionId);
|
||||||
uuid_id!(ImportProfileId);
|
uuid_id!(ImportProfileId);
|
||||||
uuid_id!(WatchlistEntryId);
|
uuid_id!(WatchlistEntryId);
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ export const userProfileResponseSchema = z.object({
|
|||||||
trends: userTrendsDtoSchema.optional(),
|
trends: userTrendsDtoSchema.optional(),
|
||||||
goals: z.array(goalDtoSchema).optional(),
|
goals: z.array(goalDtoSchema).optional(),
|
||||||
is_federated: z.boolean().optional().default(false),
|
is_federated: z.boolean().optional().default(false),
|
||||||
|
is_service_account: z.boolean().optional().default(false),
|
||||||
handle: z.string().optional(),
|
handle: z.string().optional(),
|
||||||
display_name: z.string().optional(),
|
display_name: z.string().optional(),
|
||||||
bio: z.string().optional(),
|
bio: z.string().optional(),
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ function UserProfilePage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
{isAdmin && !data.is_federated && (
|
{isAdmin && !data.is_federated && !data.is_service_account && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
Reference in New Issue
Block a user