security(external_user): protect unnecessary route access to external users

This commit is contained in:
Edouard Vanbelle
2026-06-02 14:46:27 +02:00
parent 21c06da700
commit 6d70a7000e
8 changed files with 236 additions and 19 deletions
@@ -24,12 +24,42 @@ pub fn app_password_routes() -> Router<Arc<AppState>> {
/// POST /api/auth/app-passwords — Create a new app password.
///
/// Returns the plain-text password ONCE. The user must copy it immediately.
///
/// External users are rejected with 403: app passwords are persistent
/// credentials, and the magic-link-eligibility rule (`has_login_credential`)
/// is built on the assumption that externals have NO other credential
/// configured. Letting an external mint an app password would break that
/// invariant — and the Basic-Auth surface (`/remote.php/*`, `/ocs/*`)
/// has no semantic meaning for them anyway. See the
/// [magic-link auth architecture page] for the full visibility model.
///
/// [magic-link auth architecture page]: ../../../../docs/architecture/magic-link-auth.md
async fn create_app_password(
State(state): State<Arc<AppState>>,
user: AuthUser,
Json(request): Json<CreateAppPasswordRequestDto>,
) -> Result<Json<crate::application::dtos::app_password_dto::AppPasswordCreatedResponseDto>, AppError>
{
// Gate externals BEFORE we touch the app_password_service. The
// service treats every authenticated caller equally; the policy
// that externals can't hold persistent credentials lives here.
if let Some(auth_svc) = state.auth_service.as_ref()
&& let Err(err) = crate::interfaces::middleware::user::require_internal_user(
&auth_svc.auth_application_service,
user.id,
)
.await
{
tracing::info!(
target: "audit",
event = "auth.app_password_create_rejected",
reason = "external_user",
caller_id = %user.id,
"👮🏻‍♂️ External user blocked from creating an app password"
);
return Err(err);
}
let service = state
.app_password_service
.as_ref()
@@ -258,9 +258,28 @@ pub async fn search_groups(
headers: HeaderMap,
Query(q): Query<SearchGroupsQuery>,
) -> Result<impl IntoResponse, AppError> {
// Any authenticated user can discover groups for the share dialog —
// membership lists remain admin-only via list_members.
let (_caller_id, role) = require_authenticated(&state, &headers).await?;
// Any authenticated INTERNAL user can discover groups for the
// share dialog — membership lists remain admin-only via
// list_members. External users have no business enumerating
// groups; defence-in-depth on top of the ReBAC layer (which
// already prevents them from being added to any group anyway).
let (caller_id, role) = require_authenticated(&state, &headers).await?;
if let Some(auth_svc) = state.auth_service.as_ref()
&& let Err(err) = crate::interfaces::middleware::user::require_internal_user(
&auth_svc.auth_application_service,
caller_id,
)
.await
{
tracing::info!(
target: "audit",
event = "groups.search_rejected",
reason = "external_user",
caller_id = %caller_id,
"👮🏻‍♂️ External user blocked from /api/groups/search"
);
return Err(err);
}
let can_manage = role == "admin";
let svc = service(&state)?;
// The share-dialog autocomplete doesn't render a member-count chip, so