diff --git a/nexus/db-model/src/device_auth.rs b/nexus/db-model/src/device_auth.rs index 6fc3264f590..76c6b6ac37a 100644 --- a/nexus/db-model/src/device_auth.rs +++ b/nexus/db-model/src/device_auth.rs @@ -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; @@ -173,6 +173,16 @@ impl From for views::DeviceAccessTokenGrant { } } +impl From 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::*; diff --git a/nexus/db-queries/src/db/datastore/device_auth.rs b/nexus/db-queries/src/db/datastore/device_auth.rs index 77ede5dc8d4..e81ea997d2b 100644 --- a/nexus/db-queries/src/db/datastore/device_auth.rs +++ b/nexus/db-queries/src/db/datastore/device_auth.rs @@ -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; @@ -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. + + pub async fn current_user_token_list( + &self, + opctx: &OpContext, + pagparams: &DataPageParams<'_, Uuid>, + ) -> ListResultVec { + 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(()) + } } diff --git a/nexus/external-api/output/nexus_tags.txt b/nexus/external-api/output/nexus_tags.txt index 4f381cd47a0..23393fe9a88 100644 --- a/nexus/external-api/output/nexus_tags.txt +++ b/nexus/external-api/output/nexus_tags.txt @@ -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 + API operations found with tag "vpcs" OPERATION ID METHOD URL PATH internet_gateway_create POST /v1/internet-gateways diff --git a/nexus/external-api/src/lib.rs b/nexus/external-api/src/lib.rs index ebfdaeaef35..9f1f40185e7 100644 --- a/nexus/external-api/src/lib.rs +++ b/nexus/external-api/src/lib.rs @@ -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 = { @@ -3149,6 +3155,32 @@ pub trait NexusExternalApi { path_params: Path, ) -> Result; + /// 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, + query_params: Query, + ) -> Result>, 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, + path_params: Path, + ) -> Result; + // Support bundles (experimental) /// List all support bundles diff --git a/nexus/src/app/device_auth.rs b/nexus/src/app/device_auth.rs index e796333cfd3..c7fe08f3f2f 100644 --- a/nexus/src/app/device_auth.rs +++ b/nexus/src/app/device_auth.rs @@ -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; @@ -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 { + 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 + } } diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index 5fb6f0ec805..69366a4674a 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -7068,6 +7068,57 @@ impl NexusExternalApi for NexusExternalApiImpl { .await } + async fn current_user_access_token_list( + rqctx: RequestContext, + query_params: Query, + ) -> Result>, 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, + path_params: Path, + ) -> Result { + 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, query_params: Query, diff --git a/nexus/tests/integration_tests/device_auth.rs b/nexus/tests/integration_tests/device_auth.rs index 62c1fe281f8..110624f6239 100644 --- a/nexus/tests/integration_tests/device_auth.rs +++ b/nexus/tests/integration_tests/device_auth.rs @@ -4,13 +4,15 @@ use std::num::NonZeroU32; +use chrono::Utc; +use dropshot::ResultsPage; use dropshot::test_util::ClientTestContext; use nexus_auth::authn::USER_TEST_UNPRIVILEGED; use nexus_db_queries::db::fixed_data::silo::DEFAULT_SILO; use nexus_db_queries::db::identity::{Asset, Resource}; use nexus_test_utils::http_testing::TestResponse; use nexus_test_utils::resource_helpers::{ - object_get, object_put, object_put_error, + object_delete_error, object_get, object_put, object_put_error, }; use nexus_test_utils::{ http_testing::{AuthnMode, NexusRequest, RequestBuilder}, @@ -138,6 +140,10 @@ async fn test_device_auth_flow(cptestctx: &ControlPlaneTestContext) { .expect("failed to deserialize OAuth error"); assert_eq!(&error.error, "authorization_pending"); + // Check tokens before creating the device token + assert_eq!(get_tokens_priv(testctx).await.len(), 0); + assert_eq!(get_tokens_unpriv(testctx).await.len(), 0); + // Authenticated confirmation should succeed. NexusRequest::new( RequestBuilder::new(testctx, Method::POST, "/device/confirm") @@ -162,10 +168,17 @@ async fn test_device_auth_flow(cptestctx: &ControlPlaneTestContext) { .expect("failed to get token") .parsed_body() .expect("failed to deserialize token response"); + assert_eq!(token.token_type, DeviceAccessTokenType::Bearer); assert_eq!(token.access_token.len(), 52); assert!(token.access_token.starts_with("oxide-token-")); + // Check token list endpoints after creating the device token + assert_eq!(get_tokens_priv(testctx).await.len(), 0); + let tokens_unpriv_after = get_tokens_unpriv(testctx).await; + assert_eq!(tokens_unpriv_after.len(), 1); + assert_eq!(tokens_unpriv_after[0].time_expires, None); + // now make a request with the token. it 403s because unpriv user has no // roles project_list(&testctx, &token.access_token, StatusCode::FORBIDDEN) @@ -191,6 +204,37 @@ async fn test_device_auth_flow(cptestctx: &ControlPlaneTestContext) { project_list(&testctx, &token.access_token, StatusCode::OK) .await .expect("failed to get projects with token"); + + let token_id = tokens_unpriv_after[0].id; + + // Priv user cannot delete unpriv's token through this endpoint by ID + let token_url = format!("/v1/me/access-tokens/{}", token_id); + object_delete_error(testctx, &token_url, StatusCode::NOT_FOUND).await; + + // Test deleting the token as the owner + NexusRequest::object_delete(testctx, &token_url) + .authn_as(AuthnMode::UnprivilegedUser) + .execute() + .await + .expect("failed to delete token"); + + // Verify token is gone from the list + assert_eq!(get_tokens_unpriv(testctx).await.len(), 0); + + // Token should no longer work for API calls + project_list(&testctx, &token.access_token, StatusCode::UNAUTHORIZED) + .await + .expect("deleted token should be unauthorized"); + + // Trying to delete the same token again should 404 + NexusRequest::new( + RequestBuilder::new(testctx, Method::DELETE, &token_url) + .expect_status(Some(StatusCode::NOT_FOUND)), + ) + .authn_as(AuthnMode::UnprivilegedUser) + .execute() + .await + .expect("double delete should 404"); } /// Helper to make the test cute. Goes through the whole flow, returns the token @@ -258,10 +302,18 @@ async fn test_device_token_expiration(cptestctx: &ControlPlaneTestContext) { object_get(testctx, "/v1/auth-settings").await; assert_eq!(settings.device_token_max_ttl_seconds, None); + // no tokens in the list + assert_eq!(get_tokens_priv(testctx).await.len(), 0); + // get a token for the privileged user. default silo max token expiration // is null, so tokens don't expire let initial_token = get_device_token(testctx).await; + // now there is a token in the list + let tokens = get_tokens_priv(testctx).await; + assert_eq!(tokens.len(), 1); + assert_eq!(tokens[0].time_expires, None); + // test token works on project list project_list(&testctx, &initial_token, StatusCode::OK) .await @@ -325,6 +377,21 @@ async fn test_device_token_expiration(cptestctx: &ControlPlaneTestContext) { // create token again (this one will have the 3-second expiration) let expiring_token = get_device_token(testctx).await; + // use a block so we don't touch expiring_token + { + // now there are two tokens in the list + let tokens = get_tokens_priv(testctx).await; + assert_eq!(tokens.len(), 2); + + let permanent_token = + tokens.iter().find(|t| t.time_expires.is_none()).unwrap(); + let expiring_token = + tokens.iter().find(|t| t.time_expires.is_some()).unwrap(); + + assert_eq!(permanent_token.time_expires, None); + assert!(expiring_token.time_expires.unwrap() > Utc::now()); + } + // immediately use token, it should work project_list(&testctx, &expiring_token, StatusCode::OK) .await @@ -343,6 +410,11 @@ async fn test_device_token_expiration(cptestctx: &ControlPlaneTestContext) { .await .expect("initial token should still work"); + // back down to one non-expiring token + let tokens = get_tokens_priv(testctx).await; + assert_eq!(tokens.len(), 1); + assert_eq!(tokens[0].time_expires, None); + // now test setting the silo max TTL back to null let settings: views::SiloAuthSettings = object_put( testctx, @@ -359,6 +431,26 @@ async fn test_device_token_expiration(cptestctx: &ControlPlaneTestContext) { assert_eq!(settings.device_token_max_ttl_seconds, None); } +async fn get_tokens_priv( + testctx: &ClientTestContext, +) -> Vec { + NexusRequest::object_get(testctx, "/v1/me/access-tokens") + .authn_as(AuthnMode::PrivilegedUser) + .execute_and_parse_unwrap::>() + .await + .items +} + +async fn get_tokens_unpriv( + testctx: &ClientTestContext, +) -> Vec { + NexusRequest::object_get(testctx, "/v1/me/access-tokens") + .authn_as(AuthnMode::UnprivilegedUser) + .execute_and_parse_unwrap::>() + .await + .items +} + async fn project_list( testctx: &ClientTestContext, token: &str, diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index 5dfcbf02d28..6d6aea75d0c 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -2597,6 +2597,25 @@ pub static VERIFY_ENDPOINTS: LazyLock> = AllowedMethod::Delete, ], }, + /* Tokens */ + VerifyEndpoint { + url: "/v1/me/access-tokens", + visibility: Visibility::Public, + unprivileged_access: UnprivilegedAccess::ReadOnly, + allowed_methods: vec![AllowedMethod::Get], + }, + // Creating the resource here with SetupReqs is more complicated + // than with other resources because it's a multi-step process where + // later steps depend on earlier ones, so for now we will be lazy + // and opt out. + + // VerifyEndpoint { + // url: "/v1/me/access-tokens/token-id", + // visibility: Visibility::Public, + // unprivileged_access: UnprivilegedAccess::None, + // allowed_methods: vec![AllowedMethod::Delete], + // }, + /* Certificates */ VerifyEndpoint { url: &DEMO_CERTIFICATES_URL, diff --git a/nexus/tests/output/uncovered-authz-endpoints.txt b/nexus/tests/output/uncovered-authz-endpoints.txt index 5ebbf9183e8..5bfc5137529 100644 --- a/nexus/tests/output/uncovered-authz-endpoints.txt +++ b/nexus/tests/output/uncovered-authz-endpoints.txt @@ -1,5 +1,6 @@ API endpoints with no coverage in authz tests: probe_delete (delete "/experimental/v1/probes/{probe}") +current_user_access_token_delete (delete "/v1/me/access-tokens/{token_id}") probe_list (get "/experimental/v1/probes") probe_view (get "/experimental/v1/probes/{probe}") support_bundle_download (get "/experimental/v1/system/support-bundles/{bundle_id}/download") diff --git a/nexus/types/src/external_api/params.rs b/nexus/types/src/external_api/params.rs index 90b220d1f9a..782d4d7f7f6 100644 --- a/nexus/types/src/external_api/params.rs +++ b/nexus/types/src/external_api/params.rs @@ -96,6 +96,7 @@ path_param!(CertificatePath, certificate, "certificate"); id_path_param!(SupportBundlePath, bundle_id, "support bundle"); id_path_param!(GroupPath, group_id, "group"); +id_path_param!(TokenPath, token_id, "token"); // TODO: The hardware resources should be represented by its UUID or a hardware // ID that can be used to deterministically generate the UUID. diff --git a/nexus/types/src/external_api/views.rs b/nexus/types/src/external_api/views.rs index 302700e6a15..cdca60b1f89 100644 --- a/nexus/types/src/external_api/views.rs +++ b/nexus/types/src/external_api/views.rs @@ -15,7 +15,7 @@ use daft::Diffable; use omicron_common::api::external::{ AffinityPolicy, AllowedSourceIps as ExternalAllowedSourceIps, ByteCount, Digest, Error, FailureDomain, IdentityMetadata, InstanceState, Name, - ObjectIdentity, RoleName, SimpleIdentityOrName, + ObjectIdentity, RoleName, SimpleIdentity, SimpleIdentityOrName, }; use omicron_uuid_kinds::{AlertReceiverUuid, AlertUuid}; use oxnet::{Ipv4Net, Ipv6Net}; @@ -989,6 +989,23 @@ pub struct SshKey { pub public_key: String, } +/// View of a device access token +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, PartialEq)] +pub struct DeviceAccessToken { + /// A unique, immutable, system-controlled identifier for the token. + /// Note that this ID is not the bearer token itself, which starts with + /// "oxide-token-" + pub id: Uuid, + pub time_created: DateTime, + pub time_expires: Option>, +} + +impl SimpleIdentity for DeviceAccessToken { + fn id(&self) -> Uuid { + self.id + } +} + // OAUTH 2.0 DEVICE AUTHORIZATION REQUESTS & TOKENS /// Response to an initial device authorization request. diff --git a/openapi/nexus.json b/openapi/nexus.json index fa20508660b..03d5ed9a5bf 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -5512,6 +5512,99 @@ } } }, + "/v1/me/access-tokens": { + "get": { + "tags": [ + "tokens" + ], + "summary": "List access tokens", + "description": "List device access tokens for the currently authenticated user.", + "operationId": "current_user_access_token_list", + "parameters": [ + { + "in": "query", + "name": "limit", + "description": "Maximum number of items returned by a single call", + "schema": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 1 + } + }, + { + "in": "query", + "name": "page_token", + "description": "Token returned by previous call to retrieve the subsequent page", + "schema": { + "nullable": true, + "type": "string" + } + }, + { + "in": "query", + "name": "sort_by", + "schema": { + "$ref": "#/components/schemas/IdSortMode" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceAccessTokenResultsPage" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + }, + "x-dropshot-pagination": { + "required": [] + } + } + }, + "/v1/me/access-tokens/{token_id}": { + "delete": { + "tags": [ + "tokens" + ], + "summary": "Delete access token", + "description": "Delete a device access token for the currently authenticated user.", + "operationId": "current_user_access_token_delete", + "parameters": [ + { + "in": "path", + "name": "token_id", + "description": "ID of the token", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "successful deletion" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/v1/me/groups": { "get": { "tags": [ @@ -16512,6 +16605,30 @@ "public_cert" ] }, + "DeviceAccessToken": { + "description": "View of a device access token", + "type": "object", + "properties": { + "id": { + "description": "A unique, immutable, system-controlled identifier for the token. Note that this ID is not the bearer token itself, which starts with \"oxide-token-\"", + "type": "string", + "format": "uuid" + }, + "time_created": { + "type": "string", + "format": "date-time" + }, + "time_expires": { + "nullable": true, + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "time_created" + ] + }, "DeviceAccessTokenRequest": { "type": "object", "properties": { @@ -16532,6 +16649,27 @@ "grant_type" ] }, + "DeviceAccessTokenResultsPage": { + "description": "A single page of results", + "type": "object", + "properties": { + "items": { + "description": "list of items on this page of results", + "type": "array", + "items": { + "$ref": "#/components/schemas/DeviceAccessToken" + } + }, + "next_page": { + "nullable": true, + "description": "token used to fetch the next page of results (if any)", + "type": "string" + } + }, + "required": [ + "items" + ] + }, "DeviceAuthRequest": { "type": "object", "properties": { @@ -26728,6 +26866,13 @@ { "name": "system/update" }, + { + "name": "tokens", + "description": "API clients use device access tokens for authentication.", + "externalDocs": { + "url": "http://docs.oxide.computer/api/tokens" + } + }, { "name": "vpcs", "description": "Virtual Private Clouds (VPCs) provide isolated network environments for managing and deploying services.",