Skip to content

[3/n] /v1/me/access-tokens list and delete #8227

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Jun 4, 2025
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
12 changes: 11 additions & 1 deletion nexus/db-model/src/device_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use nexus_db_schema::schema::{device_access_token, device_auth_request};

use chrono::{DateTime, Duration, Utc};
use nexus_types::external_api::views;
use omicron_uuid_kinds::{AccessTokenKind, TypedUuid};
use omicron_uuid_kinds::{AccessTokenKind, GenericUuid, TypedUuid};
use rand::{Rng, RngCore, SeedableRng, distributions::Slice, rngs::StdRng};
use uuid::Uuid;

Expand Down Expand Up @@ -173,6 +173,16 @@ impl From<DeviceAccessToken> for views::DeviceAccessTokenGrant {
}
}

impl From<DeviceAccessToken> for views::DeviceAccessToken {
fn from(access_token: DeviceAccessToken) -> Self {
Self {
id: access_token.id.into_untyped_uuid(),
time_created: access_token.time_created,
time_expires: access_token.time_expires,
}
}
}

#[cfg(test)]
mod test {
use super::*;
Expand Down
65 changes: 65 additions & 0 deletions nexus/db-queries/src/db/datastore/device_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,18 @@ use crate::authz;
use crate::context::OpContext;
use crate::db::model::DeviceAccessToken;
use crate::db::model::DeviceAuthRequest;
use crate::db::pagination::paginated;
use async_bb8_diesel::AsyncRunQueryDsl;
use chrono::Utc;
use diesel::prelude::*;
use nexus_db_errors::ErrorHandler;
use nexus_db_errors::public_error_from_diesel;
use nexus_db_schema::schema::device_access_token;
use omicron_common::api::external::CreateResult;
use omicron_common::api::external::DataPageParams;
use omicron_common::api::external::Error;
use omicron_common::api::external::InternalContext;
use omicron_common::api::external::ListResultVec;
use omicron_common::api::external::LookupResult;
use omicron_common::api::external::LookupType;
use omicron_common::api::external::ResourceType;
Expand Down Expand Up @@ -176,4 +181,64 @@ impl DataStore {
)
})
}

// Similar to session hard delete and silo group list, we do not do a
// typical authz check, instead effectively encoding the policy here that
// any user is allowed to list and delete their own tokens. When we add the
// ability for silo admins to list and delete tokens from any user, we will
// have to model these permissions properly in the polar policy.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is important. We hack around the harder authz problem of modeling token properly by restricting these functions to the current actor (for now).


pub async fn current_user_token_list(
&self,
opctx: &OpContext,
pagparams: &DataPageParams<'_, Uuid>,
) -> ListResultVec<DeviceAccessToken> {
let &actor = opctx
.authn
.actor_required()
.internal_context("listing current user's tokens")?;

use nexus_db_schema::schema::device_access_token::dsl;
paginated(dsl::device_access_token, dsl::id, &pagparams)
.filter(dsl::silo_user_id.eq(actor.actor_id()))
// we don't have time_deleted on tokens. unfortunately this is not
// indexed well. maybe it can be!
.filter(
dsl::time_expires
.is_null()
.or(dsl::time_expires.gt(Utc::now())),
)
.select(DeviceAccessToken::as_select())
.load_async(&*self.pool_connection_authorized(opctx).await?)
.await
.map_err(|e| public_error_from_diesel(e, ErrorHandler::Server))
}

pub async fn current_user_token_delete(
&self,
opctx: &OpContext,
token_id: Uuid,
) -> Result<(), Error> {
let &actor = opctx
.authn
.actor_required()
.internal_context("deleting current user's token")?;

use nexus_db_schema::schema::device_access_token::dsl;
let num_deleted = diesel::delete(dsl::device_access_token)
.filter(dsl::silo_user_id.eq(actor.actor_id()))
.filter(dsl::id.eq(token_id))
.execute_async(&*self.pool_connection_authorized(opctx).await?)
.await
.map_err(|e| public_error_from_diesel(e, ErrorHandler::Server))?;

if num_deleted == 0 {
return Err(Error::not_found_by_id(
ResourceType::DeviceAccessToken,
&token_id,
));
}

Ok(())
}
Copy link
Contributor Author

@david-crespo david-crespo May 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a hard delete — might want to put that in the name, since it's unusual. We do that in the session delete method. If we wanted a soft delete, we could set time_expires to now instead. That feels kind of bad to me, but so does hard delete.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the hard delete is fine, setting time_expires feels kind of worse especially with regards to errors when that token is used. Adding hard_delete to the name is fine with me, if you think that's important.

}
5 changes: 5 additions & 0 deletions nexus/external-api/output/nexus_tags.txt
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,11 @@ API operations found with tag "system/status"
OPERATION ID METHOD URL PATH
ping GET /v1/ping

API operations found with tag "tokens"
OPERATION ID METHOD URL PATH
current_user_access_token_delete DELETE /v1/me/access-tokens/{token_id}
current_user_access_token_list GET /v1/me/access-tokens
Comment on lines +292 to +293
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I'm in favor of putting this under /v1/me, personally!


API operations found with tag "vpcs"
OPERATION ID METHOD URL PATH
internet_gateway_create POST /v1/internet-gateways
Expand Down
32 changes: 32 additions & 0 deletions nexus/external-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,12 @@ const PUT_UPDATE_REPOSITORY_MAX_BYTES: usize = 4 * GIB;
url = "http://docs.oxide.computer/api/snapshots"
}
},
"tokens" = {
description = "API clients use device access tokens for authentication.",
external_docs = {
url = "http://docs.oxide.computer/api/tokens"
}
},
"vpcs" = {
description = "Virtual Private Clouds (VPCs) provide isolated network environments for managing and deploying services.",
external_docs = {
Expand Down Expand Up @@ -3149,6 +3155,32 @@ pub trait NexusExternalApi {
path_params: Path<params::SshKeyPath>,
) -> Result<HttpResponseDeleted, HttpError>;

/// List access tokens
///
/// List device access tokens for the currently authenticated user.
#[endpoint {
method = GET,
path = "/v1/me/access-tokens",
tags = ["tokens"],
}]
async fn current_user_access_token_list(
rqctx: RequestContext<Self::Context>,
query_params: Query<PaginatedById>,
) -> Result<HttpResponseOk<ResultsPage<views::DeviceAccessToken>>, HttpError>;

/// Delete access token
///
/// Delete a device access token for the currently authenticated user.
#[endpoint {
method = DELETE,
path = "/v1/me/access-tokens/{token_id}",
tags = ["tokens"],
}]
async fn current_user_access_token_delete(
rqctx: RequestContext<Self::Context>,
path_params: Path<params::TokenPath>,
) -> Result<HttpResponseDeleted, HttpError>;

// Support bundles (experimental)

/// List all support bundles
Expand Down
20 changes: 19 additions & 1 deletion nexus/src/app/device_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ use nexus_db_queries::db::model::{DeviceAccessToken, DeviceAuthRequest};
use anyhow::anyhow;
use nexus_types::external_api::params::DeviceAccessTokenRequest;
use nexus_types::external_api::views;
use omicron_common::api::external::{CreateResult, Error};
use omicron_common::api::external::{
CreateResult, DataPageParams, Error, ListResultVec,
};

use chrono::{Duration, Utc};
use serde::Serialize;
Expand Down Expand Up @@ -291,4 +293,20 @@ impl super::Nexus {
.header(header::CONTENT_TYPE, "application/json")
.body(body.into())?)
}

pub(crate) async fn current_user_token_list(
&self,
opctx: &OpContext,
pagparams: &DataPageParams<'_, Uuid>,
) -> ListResultVec<DeviceAccessToken> {
self.db_datastore.current_user_token_list(opctx, pagparams).await
}

pub(crate) async fn current_user_token_delete(
&self,
opctx: &OpContext,
token_id: Uuid,
) -> Result<(), Error> {
self.db_datastore.current_user_token_delete(opctx, token_id).await
}
}
51 changes: 51 additions & 0 deletions nexus/src/external_api/http_entrypoints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7068,6 +7068,57 @@ impl NexusExternalApi for NexusExternalApiImpl {
.await
}

async fn current_user_access_token_list(
rqctx: RequestContext<Self::Context>,
query_params: Query<PaginatedById>,
) -> Result<HttpResponseOk<ResultsPage<views::DeviceAccessToken>>, HttpError>
{
let apictx = rqctx.context();
let handler = async {
let opctx =
crate::context::op_context_for_external_api(&rqctx).await?;
let nexus = &apictx.context.nexus;
let query = query_params.into_inner();
let pag_params = data_page_params_for(&rqctx, &query)?;
let tokens = nexus
.current_user_token_list(&opctx, &pag_params)
.await?
.into_iter()
.map(views::DeviceAccessToken::from)
.collect();
Ok(HttpResponseOk(ScanById::results_page(
&query,
tokens,
&marker_for_id,
)?))
};
apictx
.context
.external_latencies
.instrument_dropshot_handler(&rqctx, handler)
.await
}

async fn current_user_access_token_delete(
rqctx: RequestContext<Self::Context>,
path_params: Path<params::TokenPath>,
) -> Result<HttpResponseDeleted, HttpError> {
let apictx = rqctx.context();
let handler = async {
let opctx =
crate::context::op_context_for_external_api(&rqctx).await?;
let nexus = &apictx.context.nexus;
let path = path_params.into_inner();
nexus.current_user_token_delete(&opctx, path.token_id).await?;
Ok(HttpResponseDeleted())
};
apictx
.context
.external_latencies
.instrument_dropshot_handler(&rqctx, handler)
.await
}

async fn support_bundle_list(
rqctx: RequestContext<ApiContext>,
query_params: Query<PaginatedById>,
Expand Down
Loading
Loading