Skip to content

Commit a8aeac6

Browse files
authored
[3/n] /v1/me/access-tokens list and delete (#8227)
Built on top of #8137 and #8214. This is only for a user to list and delete their own tokens. It doesn't quite match RFD 570, which says `/v1/device-tokens` instead of `/v1/me/access-tokens`, but it feels good under `/v1/me`, and after trying to make the UI too, I think "access tokens" is much more intuitive. If I stick with this, I will update RFD 570 to match. ~~I'm not sure about the path `/v1/device-tokens` — in the API we call them `Device Access Tokens`. I think `/v1/access-tokens` might be more intuitive because the `device` is sort of an implementation detail, it refers to the OAuth device auth flow, which we are using. In practice, the user just gets a token with the CLI and pastes a code into the web UI and they don't have to think too much about it, so exposing that detail in the name might not be worth it.~~ Went with `/v1/me/access-tokens`. - [x] Basic token list and delete - [x] Basic integration tests - [x] Finalize endpoint paths - [x] Figure out authz story - Went with restricting datastore functions to current actor for now
1 parent fc3244d commit a8aeac6

File tree

12 files changed

+460
-4
lines changed

12 files changed

+460
-4
lines changed

nexus/db-model/src/device_auth.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use nexus_db_schema::schema::{device_access_token, device_auth_request};
1111

1212
use chrono::{DateTime, Duration, Utc};
1313
use nexus_types::external_api::views;
14-
use omicron_uuid_kinds::{AccessTokenKind, TypedUuid};
14+
use omicron_uuid_kinds::{AccessTokenKind, GenericUuid, TypedUuid};
1515
use rand::{Rng, RngCore, SeedableRng, distributions::Slice, rngs::StdRng};
1616
use uuid::Uuid;
1717

@@ -173,6 +173,16 @@ impl From<DeviceAccessToken> for views::DeviceAccessTokenGrant {
173173
}
174174
}
175175

176+
impl From<DeviceAccessToken> for views::DeviceAccessToken {
177+
fn from(access_token: DeviceAccessToken) -> Self {
178+
Self {
179+
id: access_token.id.into_untyped_uuid(),
180+
time_created: access_token.time_created,
181+
time_expires: access_token.time_expires,
182+
}
183+
}
184+
}
185+
176186
#[cfg(test)]
177187
mod test {
178188
use super::*;

nexus/db-queries/src/db/datastore/device_auth.rs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,18 @@ use crate::authz;
99
use crate::context::OpContext;
1010
use crate::db::model::DeviceAccessToken;
1111
use crate::db::model::DeviceAuthRequest;
12+
use crate::db::pagination::paginated;
1213
use async_bb8_diesel::AsyncRunQueryDsl;
14+
use chrono::Utc;
1315
use diesel::prelude::*;
1416
use nexus_db_errors::ErrorHandler;
1517
use nexus_db_errors::public_error_from_diesel;
1618
use nexus_db_schema::schema::device_access_token;
1719
use omicron_common::api::external::CreateResult;
20+
use omicron_common::api::external::DataPageParams;
1821
use omicron_common::api::external::Error;
22+
use omicron_common::api::external::InternalContext;
23+
use omicron_common::api::external::ListResultVec;
1924
use omicron_common::api::external::LookupResult;
2025
use omicron_common::api::external::LookupType;
2126
use omicron_common::api::external::ResourceType;
@@ -176,4 +181,64 @@ impl DataStore {
176181
)
177182
})
178183
}
184+
185+
// Similar to session hard delete and silo group list, we do not do a
186+
// typical authz check, instead effectively encoding the policy here that
187+
// any user is allowed to list and delete their own tokens. When we add the
188+
// ability for silo admins to list and delete tokens from any user, we will
189+
// have to model these permissions properly in the polar policy.
190+
191+
pub async fn current_user_token_list(
192+
&self,
193+
opctx: &OpContext,
194+
pagparams: &DataPageParams<'_, Uuid>,
195+
) -> ListResultVec<DeviceAccessToken> {
196+
let &actor = opctx
197+
.authn
198+
.actor_required()
199+
.internal_context("listing current user's tokens")?;
200+
201+
use nexus_db_schema::schema::device_access_token::dsl;
202+
paginated(dsl::device_access_token, dsl::id, &pagparams)
203+
.filter(dsl::silo_user_id.eq(actor.actor_id()))
204+
// we don't have time_deleted on tokens. unfortunately this is not
205+
// indexed well. maybe it can be!
206+
.filter(
207+
dsl::time_expires
208+
.is_null()
209+
.or(dsl::time_expires.gt(Utc::now())),
210+
)
211+
.select(DeviceAccessToken::as_select())
212+
.load_async(&*self.pool_connection_authorized(opctx).await?)
213+
.await
214+
.map_err(|e| public_error_from_diesel(e, ErrorHandler::Server))
215+
}
216+
217+
pub async fn current_user_token_delete(
218+
&self,
219+
opctx: &OpContext,
220+
token_id: Uuid,
221+
) -> Result<(), Error> {
222+
let &actor = opctx
223+
.authn
224+
.actor_required()
225+
.internal_context("deleting current user's token")?;
226+
227+
use nexus_db_schema::schema::device_access_token::dsl;
228+
let num_deleted = diesel::delete(dsl::device_access_token)
229+
.filter(dsl::silo_user_id.eq(actor.actor_id()))
230+
.filter(dsl::id.eq(token_id))
231+
.execute_async(&*self.pool_connection_authorized(opctx).await?)
232+
.await
233+
.map_err(|e| public_error_from_diesel(e, ErrorHandler::Server))?;
234+
235+
if num_deleted == 0 {
236+
return Err(Error::not_found_by_id(
237+
ResourceType::DeviceAccessToken,
238+
&token_id,
239+
));
240+
}
241+
242+
Ok(())
243+
}
179244
}

nexus/external-api/output/nexus_tags.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,11 @@ API operations found with tag "system/status"
287287
OPERATION ID METHOD URL PATH
288288
ping GET /v1/ping
289289

290+
API operations found with tag "tokens"
291+
OPERATION ID METHOD URL PATH
292+
current_user_access_token_delete DELETE /v1/me/access-tokens/{token_id}
293+
current_user_access_token_list GET /v1/me/access-tokens
294+
290295
API operations found with tag "vpcs"
291296
OPERATION ID METHOD URL PATH
292297
internet_gateway_create POST /v1/internet-gateways

nexus/external-api/src/lib.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,12 @@ const PUT_UPDATE_REPOSITORY_MAX_BYTES: usize = 4 * GIB;
162162
url = "http://docs.oxide.computer/api/snapshots"
163163
}
164164
},
165+
"tokens" = {
166+
description = "API clients use device access tokens for authentication.",
167+
external_docs = {
168+
url = "http://docs.oxide.computer/api/tokens"
169+
}
170+
},
165171
"vpcs" = {
166172
description = "Virtual Private Clouds (VPCs) provide isolated network environments for managing and deploying services.",
167173
external_docs = {
@@ -3149,6 +3155,32 @@ pub trait NexusExternalApi {
31493155
path_params: Path<params::SshKeyPath>,
31503156
) -> Result<HttpResponseDeleted, HttpError>;
31513157

3158+
/// List access tokens
3159+
///
3160+
/// List device access tokens for the currently authenticated user.
3161+
#[endpoint {
3162+
method = GET,
3163+
path = "/v1/me/access-tokens",
3164+
tags = ["tokens"],
3165+
}]
3166+
async fn current_user_access_token_list(
3167+
rqctx: RequestContext<Self::Context>,
3168+
query_params: Query<PaginatedById>,
3169+
) -> Result<HttpResponseOk<ResultsPage<views::DeviceAccessToken>>, HttpError>;
3170+
3171+
/// Delete access token
3172+
///
3173+
/// Delete a device access token for the currently authenticated user.
3174+
#[endpoint {
3175+
method = DELETE,
3176+
path = "/v1/me/access-tokens/{token_id}",
3177+
tags = ["tokens"],
3178+
}]
3179+
async fn current_user_access_token_delete(
3180+
rqctx: RequestContext<Self::Context>,
3181+
path_params: Path<params::TokenPath>,
3182+
) -> Result<HttpResponseDeleted, HttpError>;
3183+
31523184
// Support bundles (experimental)
31533185

31543186
/// List all support bundles

nexus/src/app/device_auth.rs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,9 @@ use nexus_db_queries::db::model::{DeviceAccessToken, DeviceAuthRequest};
5555
use anyhow::anyhow;
5656
use nexus_types::external_api::params::DeviceAccessTokenRequest;
5757
use nexus_types::external_api::views;
58-
use omicron_common::api::external::{CreateResult, Error};
58+
use omicron_common::api::external::{
59+
CreateResult, DataPageParams, Error, ListResultVec,
60+
};
5961

6062
use chrono::{Duration, Utc};
6163
use serde::Serialize;
@@ -291,4 +293,20 @@ impl super::Nexus {
291293
.header(header::CONTENT_TYPE, "application/json")
292294
.body(body.into())?)
293295
}
296+
297+
pub(crate) async fn current_user_token_list(
298+
&self,
299+
opctx: &OpContext,
300+
pagparams: &DataPageParams<'_, Uuid>,
301+
) -> ListResultVec<DeviceAccessToken> {
302+
self.db_datastore.current_user_token_list(opctx, pagparams).await
303+
}
304+
305+
pub(crate) async fn current_user_token_delete(
306+
&self,
307+
opctx: &OpContext,
308+
token_id: Uuid,
309+
) -> Result<(), Error> {
310+
self.db_datastore.current_user_token_delete(opctx, token_id).await
311+
}
294312
}

nexus/src/external_api/http_entrypoints.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7068,6 +7068,57 @@ impl NexusExternalApi for NexusExternalApiImpl {
70687068
.await
70697069
}
70707070

7071+
async fn current_user_access_token_list(
7072+
rqctx: RequestContext<Self::Context>,
7073+
query_params: Query<PaginatedById>,
7074+
) -> Result<HttpResponseOk<ResultsPage<views::DeviceAccessToken>>, HttpError>
7075+
{
7076+
let apictx = rqctx.context();
7077+
let handler = async {
7078+
let opctx =
7079+
crate::context::op_context_for_external_api(&rqctx).await?;
7080+
let nexus = &apictx.context.nexus;
7081+
let query = query_params.into_inner();
7082+
let pag_params = data_page_params_for(&rqctx, &query)?;
7083+
let tokens = nexus
7084+
.current_user_token_list(&opctx, &pag_params)
7085+
.await?
7086+
.into_iter()
7087+
.map(views::DeviceAccessToken::from)
7088+
.collect();
7089+
Ok(HttpResponseOk(ScanById::results_page(
7090+
&query,
7091+
tokens,
7092+
&marker_for_id,
7093+
)?))
7094+
};
7095+
apictx
7096+
.context
7097+
.external_latencies
7098+
.instrument_dropshot_handler(&rqctx, handler)
7099+
.await
7100+
}
7101+
7102+
async fn current_user_access_token_delete(
7103+
rqctx: RequestContext<Self::Context>,
7104+
path_params: Path<params::TokenPath>,
7105+
) -> Result<HttpResponseDeleted, HttpError> {
7106+
let apictx = rqctx.context();
7107+
let handler = async {
7108+
let opctx =
7109+
crate::context::op_context_for_external_api(&rqctx).await?;
7110+
let nexus = &apictx.context.nexus;
7111+
let path = path_params.into_inner();
7112+
nexus.current_user_token_delete(&opctx, path.token_id).await?;
7113+
Ok(HttpResponseDeleted())
7114+
};
7115+
apictx
7116+
.context
7117+
.external_latencies
7118+
.instrument_dropshot_handler(&rqctx, handler)
7119+
.await
7120+
}
7121+
70717122
async fn support_bundle_list(
70727123
rqctx: RequestContext<ApiContext>,
70737124
query_params: Query<PaginatedById>,

0 commit comments

Comments
 (0)