Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,9 @@ requests_per_minute = 120
admin::me_api_keys::create,
admin::me_api_keys::revoke,
admin::me_api_keys::rotate,
// Self-service endpoints - Sessions
admin::me_sessions::list,
admin::me_sessions::delete_one,
// Admin routes - Organizations
admin::organizations::create,
admin::organizations::get,
Expand Down
180 changes: 180 additions & 0 deletions src/routes/admin/me_sessions.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
//! Self-service session management endpoints.
//!
//! Allows users to view and revoke their own sessions at `/admin/v1/me/sessions`.

use axum::{
Extension, Json,
extract::{Path, State},
};
use serde_json::json;
use tower_cookies::Cookies;
use uuid::Uuid;

use super::{AuditActor, error::AdminError, sessions::get_session_store};
use crate::{
AppState,
middleware::{AdminAuth, AuthzContext, ClientInfo},
models::CreateAuditLog,
routes::admin::sessions::{SessionInfo, SessionListResponse, SessionsRevokedResponse},
services::Services,
};

fn get_services(state: &AppState) -> Result<&Services, AdminError> {
state.services.as_ref().ok_or(AdminError::ServicesRequired)
}

/// List current user's active sessions.
///
/// Returns an empty list if enhanced session management is not enabled.
/// The `enhanced_enabled` field indicates whether session tracking is active.
#[cfg_attr(feature = "utoipa", utoipa::path(
get,
path = "/admin/v1/me/sessions",
tag = "me",
operation_id = "me_sessions_list",
responses(
(status = 200, description = "List of current user's sessions", body = SessionListResponse),
(status = 401, description = "Not authenticated", body = crate::openapi::ErrorResponse),
)
))]
#[tracing::instrument(
name = "admin.me.sessions.list",
skip(state, admin_auth, authz, cookies)
)]
pub async fn list(
State(state): State<AppState>,
Extension(admin_auth): Extension<AdminAuth>,
Extension(authz): Extension<AuthzContext>,
cookies: Cookies,
) -> Result<Json<SessionListResponse>, AdminError> {
authz.require("me", "read", None, None, None, None)?;

let external_id = &admin_auth.identity.external_id;
if external_id.is_empty() {
return Ok(Json(SessionListResponse {
data: vec![],
enhanced_enabled: false,
current_session_id: None,
}));
}

let session_store = get_session_store(&state)?;
let enhanced_enabled = session_store.is_enhanced_enabled();

// Extract current session ID from cookie so the UI can highlight it.
// Only include when enhanced sessions are enabled to avoid leaking the session UUID.
let current_session_id = if enhanced_enabled {
let session_config = state.config.auth.session_config_or_default();
cookies
.get(&session_config.cookie_name)
.and_then(|c| c.value().parse::<Uuid>().ok())
} else {
None
};

let sessions = session_store
.list_user_sessions(external_id)
.await
.map_err(|e| AdminError::Internal(format!("Failed to list sessions: {}", e)))?;

let data = sessions
.into_iter()
.map(|s| SessionInfo {
id: s.id,
device: s.device,
created_at: s.created_at,
last_activity: s.last_activity,
expires_at: s.expires_at,
})
.collect();

Ok(Json(SessionListResponse {
data,
enhanced_enabled,
current_session_id,
}))
}

/// Revoke a specific session belonging to the current user.
///
/// Returns success even if the session doesn't exist (idempotent).
/// Returns 400 if the session belongs to a different user.
#[cfg_attr(feature = "utoipa", utoipa::path(
delete,
path = "/admin/v1/me/sessions/{session_id}",
tag = "me",
operation_id = "me_sessions_delete_one",
params(("session_id" = Uuid, Path, description = "Session ID to revoke")),
responses(
(status = 200, description = "Session revoked", body = SessionsRevokedResponse),
(status = 400, description = "Session does not belong to current user", body = crate::openapi::ErrorResponse),
(status = 401, description = "Not authenticated", body = crate::openapi::ErrorResponse),
)
))]
#[tracing::instrument(name = "admin.me.sessions.delete_one", skip(state, admin_auth, authz), fields(%session_id))]
pub async fn delete_one(
State(state): State<AppState>,
Extension(admin_auth): Extension<AdminAuth>,
Extension(authz): Extension<AuthzContext>,
Extension(client_info): Extension<ClientInfo>,
Path(session_id): Path<Uuid>,
) -> Result<Json<SessionsRevokedResponse>, AdminError> {
authz.require("me", "delete", None, None, None, None)?;

let external_id = &admin_auth.identity.external_id;
let services = get_services(&state)?;
let actor = AuditActor::from(&admin_auth);

let session_store = get_session_store(&state)?;
Comment thread
greptile-apps[bot] marked this conversation as resolved.

// Verify session belongs to the current user
let session_existed = match session_store.get_session(session_id).await {
Ok(Some(session)) => {
if session.external_id != *external_id {
return Err(AdminError::BadRequest(
"Session does not belong to current user".to_string(),
));
}
true
}
Ok(None) => false,
Err(e) => {
return Err(AdminError::Internal(format!(
"Failed to look up session: {e}"
)));
}
};

let sessions_revoked = if !session_existed {
0
} else {
session_store
.delete_session(session_id)
.await
.map_err(|e| AdminError::Internal(format!("Failed to delete session: {e}")))?;
1
};

if session_existed {
let _ = services
.audit_logs
.create(CreateAuditLog {
actor_type: actor.actor_type,
actor_id: actor.actor_id,
action: "session.self_delete_one".to_string(),
resource_type: "session".to_string(),
resource_id: session_id,
org_id: None,
project_id: None,
details: json!({
"session_id": session_id,
"sessions_revoked": sessions_revoked,
}),
ip_address: client_info.ip_address,
user_agent: client_info.user_agent,
})
.await;
}

Ok(Json(SessionsRevokedResponse { sessions_revoked }))
}
7 changes: 6 additions & 1 deletion src/routes/admin/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ mod error;
pub mod me;
pub mod me_api_keys;
pub mod me_providers;
#[cfg(feature = "sso")]
pub mod me_sessions;
pub mod model_pricing;
pub mod org_rbac_policies;
#[cfg(feature = "sso")]
Expand Down Expand Up @@ -689,7 +691,10 @@ pub(crate) fn admin_v1_routes() -> Router<AppState> {
// SSO routes (only available when sso feature is enabled)
#[cfg(feature = "sso")]
let router = router
// User Sessions
// Self-service sessions (current user)
.route("/me/sessions", get(me_sessions::list))
.route("/me/sessions/{session_id}", delete(me_sessions::delete_one))
// User Sessions (admin)
.route(
"/users/{user_id}/sessions",
get(sessions::list).delete(sessions::delete_all),
Expand Down
6 changes: 6 additions & 0 deletions src/routes/admin/sessions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ pub struct SessionListResponse {
/// Whether enhanced session management is enabled.
/// If false, the UI should show a message that session tracking is not enabled.
pub enhanced_enabled: bool,
/// The session ID of the current request (if authenticated via session cookie).
/// Allows the UI to highlight the current session and warn before self-revocation.
#[serde(skip_serializing_if = "Option::is_none")]
pub current_session_id: Option<Uuid>,
}

/// Response after revoking sessions.
Expand Down Expand Up @@ -153,6 +157,7 @@ pub async fn list(
Ok(Json(SessionListResponse {
data,
enhanced_enabled,
current_session_id: None,
}))
}

Expand Down Expand Up @@ -386,6 +391,7 @@ mod tests {
let response = SessionListResponse {
data: vec![],
enhanced_enabled: false,
current_session_id: None,
};

let json = serde_json::to_string(&response).unwrap();
Expand Down
92 changes: 92 additions & 0 deletions ui/src/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -2962,6 +2962,92 @@
}
}
},
"/admin/v1/me/sessions": {
"get": {
"tags": [
"me"
],
"summary": "List current user's active sessions.",
"description": "Returns an empty list if enhanced session management is not enabled.\nThe `enhanced_enabled` field indicates whether session tracking is active.",
"operationId": "me_sessions_list",
"responses": {
"200": {
"description": "List of current user's sessions",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionListResponse"
}
}
}
},
"401": {
"description": "Not authenticated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/admin/v1/me/sessions/{session_id}": {
"delete": {
"tags": [
"me"
],
"summary": "Revoke a specific session belonging to the current user.",
"description": "Returns success even if the session doesn't exist (idempotent).\nReturns 400 if the session belongs to a different user.",
"operationId": "me_sessions_delete_one",
"parameters": [
{
"name": "session_id",
"in": "path",
"description": "Session ID to revoke",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"200": {
"description": "Session revoked",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionsRevokedResponse"
}
}
}
},
"400": {
"description": "Session does not belong to current user",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"401": {
"description": "Not authenticated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/admin/v1/me/usage": {
"get": {
"tags": [
Expand Down Expand Up @@ -27792,6 +27878,12 @@
"enhanced_enabled"
],
"properties": {
"current_session_id": {
"type": "string",
"format": "uuid",
"description": "The session ID of the current request (if authenticated via session cookie).\nAllows the UI to highlight the current session and warn before self-revocation.",
"nullable": true
},
"data": {
"type": "array",
"items": {
Expand Down
25 changes: 20 additions & 5 deletions ui/src/components/Admin/SessionsPanel/SessionCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export interface SessionCardProps {
session: SessionInfo;
onRevoke?: (sessionId: string) => void;
isRevoking?: boolean;
isCurrent?: boolean;
}

function formatRelativeTime(date: Date): string {
Expand Down Expand Up @@ -37,23 +38,37 @@ function formatDateTime(dateStr: string): string {
return date.toLocaleString();
}

export function SessionCard({ session, onRevoke, isRevoking = false }: SessionCardProps) {
export function SessionCard({
session,
onRevoke,
isRevoking = false,
isCurrent = false,
}: SessionCardProps) {
const createdAt = new Date(session.created_at);
const expiresAt = new Date(session.expires_at);
const lastActivity = session.last_activity ? new Date(session.last_activity) : null;

return (
<div className="bg-card border border-border rounded-lg p-4 space-y-3">
<div
className={`bg-card border rounded-lg p-4 space-y-3 ${isCurrent ? "border-primary/50" : "border-border"}`}
>
{/* Device Info */}
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3 min-w-0 flex-1">
<div className="flex-shrink-0 w-10 h-10 bg-primary/10 rounded-lg flex items-center justify-center">
<Monitor className="w-5 h-5 text-primary" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground truncate">
{session.device?.device_description || "Unknown Device"}
</p>
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-foreground truncate">
{session.device?.device_description || "Unknown Device"}
</p>
{isCurrent && (
<span className="flex-shrink-0 text-xs font-medium text-primary bg-primary/10 px-1.5 py-0.5 rounded">
This device
</span>
)}
</div>
{session.device?.ip_address && (
<div className="flex items-center gap-1.5 mt-1">
<Globe className="w-3.5 h-3.5 text-muted-foreground" />
Expand Down
Loading
Loading