Skip to content

Commit 52bd251

Browse files
authored
Add API token revocation (#801)
Add first-class revocation for API tokens, distinct from expiration. A revoked token is rejected at the authentication boundary, so a leaked JWT stops working immediately rather than waiting for its TTL. - New migration `token_revoked` adds a nullable `revoked` BIGINT column (mirroring the soft-delete pattern on organization/project) with a partial `WHERE revoked IS NULL` index for the hot path. - `AuthUser::from_token` now looks up API-key JWTs in the token table and rejects any whose `revoked` column is set (client-audience JWTs for browser sessions are not persisted and skip the check). - New `DELETE /v0/users/{user}/tokens/{token}` endpoint guarded by `same_user!`; token list hides revoked entries by default with a `?revoked=true` opt-in for audit. - CLI: `bencher token revoke` and `bencher token list --revoked`. - Console: Revoke button on the token detail page (terminal - no unrevoke) and a Revoked toggle on the list, mirroring the archived dimension UX. Revoked tokens show a warning notification with the revocation date. - Integration tests cover revoke-hides-from-list, direct-GET-still- visible, auth-breaks-after-revoke, forbidden-for-other-user, and double-revoke-rejected. Seed test exercises the end-to-end CLI lifecycle.
1 parent 388e903 commit 52bd251

30 files changed

Lines changed: 1285 additions & 33 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/api_users/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,12 @@ publish = false
1010
default = []
1111
plus = ["bencher_endpoint/plus", "bencher_json/plus", "bencher_schema/plus"]
1212
sentry = ["bencher_schema/sentry"]
13-
otel = ["bencher_endpoint/otel", "bencher_schema/otel"]
13+
otel = ["dep:bencher_otel", "bencher_endpoint/otel", "bencher_schema/otel"]
1414

1515
[dependencies]
1616
bencher_endpoint.workspace = true
1717
bencher_json = { workspace = true, features = ["server", "schema", "db"] }
18+
bencher_otel = { workspace = true, optional = true }
1819
bencher_schema.workspace = true
1920
diesel.workspace = true
2021
dropshot.workspace = true

lib/api_users/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ impl bencher_endpoint::Registrar for Api {
3737
api_description.register(tokens::user_token_post)?;
3838
api_description.register(tokens::user_token_get)?;
3939
api_description.register(tokens::user_token_patch)?;
40+
api_description.register(tokens::user_token_delete)?;
4041

4142
Ok(())
4243
}

lib/api_users/src/tokens.rs

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use bencher_endpoint::{
2-
CorsResponse, Endpoint, Get, Patch, Post, ResponseCreated, ResponseOk, TotalCount,
2+
CorsResponse, Delete, Endpoint, Get, Patch, Post, ResponseCreated, ResponseDeleted, ResponseOk,
3+
TotalCount,
34
};
45
use bencher_json::{
56
JsonDirection, JsonNewToken, JsonPagination, JsonToken, JsonTokens, ResourceName, Search,
@@ -8,14 +9,14 @@ use bencher_json::{
89
use bencher_schema::{
910
auth_conn,
1011
context::ApiContext,
11-
error::{resource_conflict_err, resource_not_found_err},
12+
error::{conflict_error, resource_conflict_err, resource_not_found_err},
1213
model::user::{
1314
QueryUser, UserId,
1415
auth::{AuthUser, BearerToken},
1516
same_user,
1617
token::{InsertToken, QueryToken, UpdateToken},
1718
},
18-
schema, write_conn,
19+
schema, write_conn, write_transaction,
1920
};
2021
use diesel::{
2122
BoolExpressionMethods as _, ExpressionMethods as _, QueryDsl as _, RunQueryDsl as _,
@@ -48,6 +49,9 @@ pub struct UserTokensQuery {
4849
pub name: Option<ResourceName>,
4950
/// Search by token name, slug, or UUID.
5051
pub search: Option<Search>,
52+
/// If set to `true`, only returns revoked tokens.
53+
/// If not set or set to `false`, only returns non-revoked tokens.
54+
pub revoked: Option<bool>,
5155
}
5256

5357
#[endpoint {
@@ -139,6 +143,12 @@ fn get_ls_query<'q>(
139143
.filter(schema::token::user_id.eq(user_id))
140144
.into_boxed();
141145

146+
if let Some(true) = query_params.revoked {
147+
query = query.filter(schema::token::revoked.is_not_null());
148+
} else {
149+
query = query.filter(schema::token::revoked.is_null());
150+
}
151+
142152
if let Some(name) = query_params.name.as_ref() {
143153
query = query.filter(schema::token::name.eq(name));
144154
}
@@ -211,6 +221,9 @@ async fn post_inner(
211221
.execute(write_conn!(context))
212222
.map_err(resource_conflict_err!(Token, insert_token))?;
213223

224+
#[cfg(feature = "otel")]
225+
bencher_otel::ApiMeter::increment(bencher_otel::ApiCounter::UserTokenCreate);
226+
214227
auth_conn!(context, |conn| {
215228
schema::token::table
216229
.filter(schema::token::uuid.eq(&insert_token.uuid))
@@ -237,7 +250,7 @@ pub async fn user_token_options(
237250
_rqctx: RequestContext<ApiContext>,
238251
_path_params: Path<UserTokenParams>,
239252
) -> Result<CorsResponse, HttpError> {
240-
Ok(Endpoint::cors(&[Get.into(), Patch.into()]))
253+
Ok(Endpoint::cors(&[Get.into(), Patch.into(), Delete.into()]))
241254
}
242255

243256
/// View a token
@@ -328,3 +341,55 @@ async fn patch_inner(
328341
QueryToken::get(conn, query_token.id)?.into_json(conn)
329342
})
330343
}
344+
345+
/// Revoke a token
346+
///
347+
/// Revoke an API token for a user.
348+
/// Revocation is terminal: a revoked token can no longer authenticate any request,
349+
/// and the revocation cannot be undone (for security — a leaked JWT must not become valid again).
350+
/// Only the authenticated user themselves and server admins have access to this endpoint.
351+
#[endpoint {
352+
method = DELETE,
353+
path = "/v0/users/{user}/tokens/{token}",
354+
tags = ["users", "tokens"]
355+
}]
356+
pub async fn user_token_delete(
357+
rqctx: RequestContext<ApiContext>,
358+
bearer_token: BearerToken,
359+
path_params: Path<UserTokenParams>,
360+
) -> Result<ResponseDeleted, HttpError> {
361+
let auth_user = AuthUser::from_token(rqctx.context(), bearer_token).await?;
362+
delete_inner(rqctx.context(), path_params.into_inner(), &auth_user).await?;
363+
Ok(Delete::auth_response_deleted())
364+
}
365+
366+
async fn delete_inner(
367+
context: &ApiContext,
368+
path_params: UserTokenParams,
369+
auth_user: &AuthUser,
370+
) -> Result<(), HttpError> {
371+
let query_user = QueryUser::from_resource_id(auth_conn!(context), &path_params.user)?;
372+
same_user!(auth_user, context.rbac, query_user.uuid);
373+
374+
let query_token = QueryToken::get_user_token(
375+
auth_conn!(context),
376+
query_user.id,
377+
&path_params.token.to_string(),
378+
)?;
379+
380+
let now = context.clock.now();
381+
let rows = write_transaction!(context, |conn| QueryToken::revoke(
382+
conn,
383+
query_token.id,
384+
now
385+
))
386+
.map_err(resource_conflict_err!(Token, (&query_user, &query_token)))?;
387+
if rows == 0 {
388+
return Err(conflict_error("Token has already been revoked"));
389+
}
390+
391+
#[cfg(feature = "otel")]
392+
bencher_otel::ApiMeter::increment(bencher_otel::ApiCounter::UserTokenRevoke);
393+
394+
Ok(())
395+
}

lib/api_users/src/users.rs

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ use bencher_schema::{
1212
admin::AdminUser,
1313
auth::{AuthUser, BearerToken},
1414
same_user,
15+
token::QueryToken,
1516
},
16-
schema, write_conn,
17+
schema, write_conn, write_transaction,
1718
};
1819
use diesel::{
1920
BoolExpressionMethods as _, ExpressionMethods as _, QueryDsl as _, RunQueryDsl as _,
@@ -234,10 +235,27 @@ async fn patch_inner(
234235
}
235236

236237
let update_user = UpdateUser::from(json_user.clone());
237-
diesel::update(schema::user::table.filter(schema::user::id.eq(query_user.id)))
238-
.set(&update_user)
239-
.execute(write_conn!(context))
238+
239+
let email_changed = json_user
240+
.email
241+
.as_ref()
242+
.is_some_and(|new_email| *new_email != query_user.email);
243+
244+
if email_changed {
245+
let now = context.clock.now();
246+
write_transaction!(context, |conn| {
247+
diesel::update(schema::user::table.filter(schema::user::id.eq(query_user.id)))
248+
.set(&update_user)
249+
.execute(conn)?;
250+
QueryToken::revoke_all(conn, query_user.id, now)
251+
})
240252
.map_err(resource_conflict_err!(User, (&query_user, &json_user)))?;
253+
} else {
254+
diesel::update(schema::user::table.filter(schema::user::id.eq(query_user.id)))
255+
.set(&update_user)
256+
.execute(write_conn!(context))
257+
.map_err(resource_conflict_err!(User, (&query_user, &json_user)))?;
258+
}
241259

242260
Ok(QueryUser::get(auth_conn!(context), query_user.id)?.into_json())
243261
}

0 commit comments

Comments
 (0)