diff --git a/README.md b/README.md index 4771fd0e..55f26e89 100644 --- a/README.md +++ b/README.md @@ -69,39 +69,77 @@ The following endpoints are injected by the `inject_endpoints` macro: | Method | Endpoint | Description | |--------|----------|-------------| -| GET | /oauth/client | Vliew all OAuth clients | +| GET | /oauth/client | View all OAuth clients | | POST | /oauth/client | Create a new OAuth client | | GET | /oauth/client/{client_id} | View an individual OAuth client | | POST | /oauth/client/{client_id}/secret | Create a new secret for an OAuth client | | DELETE | /oauth/client/{client_id}/secret/{secret_id} | Delete an existing secret from an OAuth client | -| POST | /oauth/client/{client_id}/redirect_uri | Add a redirect URI to an OAuth cleint | +| POST | /oauth/client/{client_id}/redirect_uri | Add a redirect URI to an OAuth client | | DELETE | /oauth/client/{client_id}/redirect_uri/{redirect_uri_id} | Delete an existing redirect URI from an OAuth client | +| GET | /magic/client | View all magic link clients | +| POST | /magic/client | Create a new magic link client | +| GET | /magic/client/{client_id} | View an individual magic link client | +| POST | /magic/client/{client_id}/secret | Create a new secret for a magic link client | +| DELETE | /magic/client/{client_id}/secret/{secret_id} | Delete an existing secret from a magic link client | +| POST | /magic/client/{client_id}/redirect_uri | Add a redirect URI to a magic link client | +| DELETE | /magic/client/{client_id}/redirect_uri/{redirect_uri_id} | Delete an existing redirect URI from a magic link client | | GET | /login/oauth/{provider}/code/authorize | Start an OAuth authorization_code flow | | GET | /login/oauth/{provider}/code/callback | Internal url for handling return calls from external OAuth providers | | POST | /login/oauth/{provider}/code/token | Complete an authorization_code flow by exchanging an authorization code for an access token | -| GET | /login/oauth/{provider}/device | Start an OAuth device_code flow | -| POST | /login/oauth/{provider}/device/exchange | Complete a device_code flow by exchanging a request for an access token | -| GET | /.well-known/openid-configuration | Retrive OpenID configuartion information. Specifically the jwks url | +| GET | /login/oauth/{provider}/public-pkce | Retrieve provider metadata for a public PKCE authorization_code flow | +| GET | /login/oauth/{provider}/device | Retrieve provider metadata for a device_code flow | +| POST | /login/oauth/{provider}/device | Start an OAuth device_code flow | +| POST | /login/oauth/{provider}/device/exchange | Complete a device_code flow by exchanging a device code for an access token | +| POST | /login/magic/{channel}/send | Send a magic link to a recipient | +| POST | /login/magic/{channel}/exchange | Complete a magic link login by exchanging an access code for an access token | +| GET | /.well-known/openid-configuration | Retrieve OpenID configuration information. Specifically the jwks url | | GET | /.well-known/jwks.json | Retrieve JWKS for verifying access tokens | | GET | /self | View information about the calling user | -| GET | /api-user/{identifier} | View information about a specific user | +| GET | /api-user | View all users | | POST | /api-user | Create a new user | -| POST | /api-user/{identifier} | Update information on an existing user | -| GET | /api-user/{identifier}/token | View all API tokens for an existing user | -| POST | /api-user/{identifier}/token | Create a new API token for an existing user | -| GET | /api-user/{identifier}/token/{token_identifier} | View an existing API token | -| DELETE | /api-user/{identifier}/token/{token_identifier} | Delete an existing API token | -| POST | /api-user/{identifier}/group | Add an existing user to a group | -| DELETE | /api-user/{identifier}/group/{group_id} | Remove an existing user from a group | -| POST | /api-user/{identifier}/link | TBD | -| POST | /api-user-provider/{identifier}/link-token | TBD | +| GET | /api-user/{user_id} | View information about a specific user | +| POST | /api-user/{user_id} | Replace the permissions assigned to an existing user | +| GET | /api-user/{user_id}/session | View the existing sessions for a user | +| POST | /api-user/{user_id}/logout | Revoke all existing sessions for a user | +| POST | /api-user/{user_id}/permission | Add a single permission to an existing user | +| DELETE | /api-user/{user_id}/permission | Remove a single permission from an existing user | +| GET | /api-user/{user_id}/token | View all API tokens for an existing user | +| POST | /api-user/{user_id}/token | Create a new API token for an existing user | +| GET | /api-user/{user_id}/token/{api_key_id} | View an existing API token | +| DELETE | /api-user/{user_id}/token/{api_key_id} | Revoke an existing API token | +| PUT | /api-user/{user_id}/contact/email | Set the contact email for an existing user | +| POST | /api-user/{user_id}/group | Add an existing user to a group | +| DELETE | /api-user/{user_id}/group/{group_id} | Remove an existing user from a group | +| POST | /api-user/{user_id}/link | Link an existing login provider to an existing user | +| POST | /api-user-provider/{provider_id}/link-token | Create a token for linking a login provider to a different user | | GET | /group | View all groups | | POST | /group | Create a new group | | PUT | /group/{group_id} | Update an existing group | | DELETE | /group/{group_id} | Delete an existing group | +| GET | /group-membership/{group_id} | View the members of a group | | GET | /mapper | View all mappers | | POST | /mapper | Create a new mapper | -| DELETE | /mapper/{identifier} | Delete and existing mapper | +| DELETE | /mapper/{mapper_id} | Delete an existing mapper | + +When the `local-dev` feature is enabled `inject_endpoints` also injects: + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | /login/local | Login as a local development user | + +The `inject_v_saga_endpoints` macro, available with the `sagas` feature (enabled by default), +injects: + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | /saga | View all sagas | +| GET | /saga/{saga} | View a saga along with its events | + +The `inject_v_metrics_endpoints` macro, available with the `metrics` feature, injects: + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | /v/metrics | Report service metrics in the Prometheus exposition format | ## Contributing diff --git a/v-api/src/context/mod.rs b/v-api/src/context/mod.rs index 6481e94e..b1898eb3 100644 --- a/v-api/src/context/mod.rs +++ b/v-api/src/context/mod.rs @@ -2165,6 +2165,17 @@ pub(crate) mod test_mocks { .upsert(token) .await } + + async fn revoke( + &self, + filter: v_model::storage::AccessTokenFilter, + ) -> Result { + self.access_token_store + .as_ref() + .unwrap() + .revoke(filter) + .await + } } #[async_trait] diff --git a/v-api/src/context/user.rs b/v-api/src/context/user.rs index 13ce81a9..05692b4f 100644 --- a/v-api/src/context/user.rs +++ b/v-api/src/context/user.rs @@ -15,9 +15,9 @@ use v_model::{ NewApiUserProvider, Permissions, UserId, UserProviderId, permissions::{AsScope, Caller, Permission, PermissionError, PermissionStorage}, storage::{ - AccessGroupFilter, AccessGroupStore, AccessTokenStore, ApiKeyFilter, ApiKeyStore, - ApiUserContactEmailStore, ApiUserFilter, ApiUserProviderFilter, ApiUserProviderStore, - ApiUserStore, ListPagination, StoreError, + AccessGroupFilter, AccessGroupStore, AccessTokenFilter, AccessTokenStore, ApiKeyFilter, + ApiKeyStore, ApiUserContactEmailStore, ApiUserFilter, ApiUserProviderFilter, + ApiUserProviderStore, ApiUserStore, ListPagination, StoreError, }, }; @@ -699,6 +699,44 @@ where expires_in: claims.exp - Utc::now().timestamp(), }) } + + pub async fn list_access_tokens( + &self, + caller: &Caller, + api_user: &TypedUuid, + ) -> ResourceResult, UserContextError> { + if caller.can(&VPermission::GetApiUser(*api_user).into()) { + let filter = AccessTokenFilter { + api_user_id: Some(vec![*api_user]), + revoked: true, + ..Default::default() + }; + + let tokens = + AccessTokenStore::list(&*self.storage, filter, &ListPagination::unlimited()) + .await?; + Ok(tokens) + } else { + resource_restricted() + } + } + + pub async fn revoke_access_tokens( + &self, + caller: &Caller, + api_user: &TypedUuid, + ) -> ResourceResult { + if caller.can(&VPermission::ManageApiUser(*api_user).into()) { + let filter = AccessTokenFilter { + api_user_id: Some(vec![*api_user]), + ..Default::default() + }; + + Ok(AccessTokenStore::revoke(&*self.storage, filter).await?) + } else { + resource_restricted() + } + } } #[cfg(test)] diff --git a/v-api/src/endpoints/api_user.rs b/v-api/src/endpoints/api_user.rs index 0f16ca99..28baea59 100644 --- a/v-api/src/endpoints/api_user.rs +++ b/v-api/src/endpoints/api_user.rs @@ -17,7 +17,8 @@ use tap::TapFallible; use tracing::instrument; use uuid::Uuid; use v_model::{ - AccessGroupId, ApiKeyId, ApiUser, ApiUserContactEmail, ApiUserProvider, NewApiKey, UserId, + AccessGroupId, AccessToken, ApiKeyId, ApiUser, ApiUserContactEmail, ApiUserProvider, NewApiKey, + UserId, permissions::{Caller, Permission, PermissionStorage, Permissions}, storage::{AccessGroupFilter, ApiUserFilter, ApiUserProviderFilter, ListPagination}, }; @@ -307,6 +308,70 @@ where Ok(HttpResponseOk(GetUserResponse::new(info.user, providers))) } +/// List all sessions for a user. +/// +/// Returns a list of sessions. +#[instrument(skip(rqctx), err(Debug))] +pub async fn list_api_user_sessions_op( + rqctx: &RequestContext>, + path: ApiUserPath, +) -> Result>, HttpError> +where + T: VAppPermission + PermissionStorage, +{ + let (ctx, caller) = rqctx.as_ctx().await?; + list_api_user_sessions_inner(ctx, caller, path).await +} + +#[instrument(skip(ctx))] +pub async fn list_api_user_sessions_inner( + ctx: &VContext, + caller: Caller, + path: ApiUserPath, +) -> Result>, HttpError> +where + T: VAppPermission + PermissionStorage, +{ + let tokens = ctx.user.list_access_tokens(&caller, &path.user_id).await?; + Ok(HttpResponseOk(tokens)) +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct RevokedResponse { + count: usize, +} + +/// Revoke all sessions for a user. +/// +/// Returns the number of sessions that were revoked. +#[instrument(skip(rqctx), err(Debug))] +pub async fn logout_api_user_op( + rqctx: &RequestContext>, + path: ApiUserPath, +) -> Result, HttpError> +where + T: VAppPermission + PermissionStorage, +{ + let (ctx, caller) = rqctx.as_ctx().await?; + logout_api_user_inner(ctx, caller, path).await +} + +#[instrument(skip(ctx))] +pub async fn logout_api_user_inner( + ctx: &VContext, + caller: Caller, + path: ApiUserPath, +) -> Result, HttpError> +where + T: VAppPermission + PermissionStorage, +{ + let count = ctx + .user + .revoke_access_tokens(&caller, &path.user_id) + .await?; + Ok(HttpResponseOk(RevokedResponse { count })) +} + /// Lists all API tokens for a specific user. /// /// Returns a collection of API tokens associated with the specified user, @@ -876,11 +941,13 @@ mod tests { use mockall::predicate::eq; use newtype_uuid::TypedUuid; use v_model::{ - ApiKey, ApiUser, ApiUserContactEmail, ApiUserInfo, ApiUserProvider, NewApiUser, + AccessToken, ApiKey, ApiUser, ApiUserContactEmail, ApiUserInfo, ApiUserProvider, + NewApiUser, permissions::{Caller, Permissions}, storage::{ - ApiKeyFilter, ListPagination, MockAccessGroupStore, MockApiKeyStore, - MockApiUserContactEmailStore, MockApiUserProviderStore, MockApiUserStore, StoreError, + AccessTokenFilter, ApiKeyFilter, ListPagination, MockAccessGroupStore, + MockAccessTokenStore, MockApiKeyStore, MockApiUserContactEmailStore, + MockApiUserProviderStore, MockApiUserStore, StoreError, }, }; @@ -889,8 +956,8 @@ mod tests { endpoints::api_user::{ ApiKeyCreateParams, ApiUserEmailUpdateParams, ApiUserPath, ApiUserTokenPath, create_api_user_inner, create_api_user_token_inner, delete_api_user_token_inner, - get_api_user_token_inner, list_api_user_tokens_inner, set_api_user_contact_email_inner, - update_api_user_inner, + get_api_user_token_inner, list_api_user_sessions_inner, list_api_user_tokens_inner, + logout_api_user_inner, set_api_user_contact_email_inner, update_api_user_inner, }, permissions::{VPermission, VPermissionResponse}, util::tests::get_status, @@ -1931,4 +1998,108 @@ mod tests { assert_eq!(get_status(&resp), StatusCode::OK); assert_eq!("user@company".to_string(), resp.unwrap().0.email); } + + #[tokio::test] + async fn test_list_api_user_sessions_permissions() { + let api_user_id = TypedUuid::new_v4(); + let session = AccessToken { + id: TypedUuid::new_v4(), + user_id: api_user_id, + revoked_at: None, + created_at: Utc::now(), + updated_at: Utc::now(), + }; + + let mut token_store = MockAccessTokenStore::new(); + token_store + .expect_list() + .withf(move |filter: &AccessTokenFilter, _| { + filter.api_user_id == Some(vec![api_user_id]) && filter.revoked + }) + .times(1) + .returning(move |_, _| Ok(vec![session.clone()])); + + let mut storage = MockStorage::new(); + storage.access_token_store = Some(Arc::new(token_store)); + let ctx = mock_context(Arc::new(storage)).await; + + let no_permissions = Caller { + id: TypedUuid::new_v4(), + permissions: Permissions::new(), + extensions: HashMap::default(), + }; + let resp = list_api_user_sessions_inner( + &ctx, + no_permissions, + ApiUserPath { + user_id: api_user_id, + }, + ) + .await; + assert_eq!(get_status(&resp), StatusCode::FORBIDDEN); + + let can_read = Caller { + id: TypedUuid::new_v4(), + permissions: vec![VPermission::GetApiUser(api_user_id)].into(), + extensions: HashMap::default(), + }; + let resp = list_api_user_sessions_inner( + &ctx, + can_read, + ApiUserPath { + user_id: api_user_id, + }, + ) + .await; + assert_eq!(get_status(&resp), StatusCode::OK); + assert_eq!(1, resp.unwrap().0.len()); + } + + #[tokio::test] + async fn test_logout_api_user_permissions() { + let api_user_id = TypedUuid::new_v4(); + + // Expecting exactly one revoke also proves that the forbidden call never reached storage + let mut token_store = MockAccessTokenStore::new(); + token_store + .expect_revoke() + .withf(move |filter: &AccessTokenFilter| filter.api_user_id == Some(vec![api_user_id])) + .times(1) + .returning(|_| Ok(3)); + + let mut storage = MockStorage::new(); + storage.access_token_store = Some(Arc::new(token_store)); + let ctx = mock_context(Arc::new(storage)).await; + + let can_only_read = Caller { + id: TypedUuid::new_v4(), + permissions: vec![VPermission::GetApiUser(api_user_id)].into(), + extensions: HashMap::default(), + }; + let resp = logout_api_user_inner( + &ctx, + can_only_read, + ApiUserPath { + user_id: api_user_id, + }, + ) + .await; + assert_eq!(get_status(&resp), StatusCode::FORBIDDEN); + + let can_manage = Caller { + id: TypedUuid::new_v4(), + permissions: vec![VPermission::ManageApiUser(api_user_id)].into(), + extensions: HashMap::default(), + }; + let resp = logout_api_user_inner( + &ctx, + can_manage, + ApiUserPath { + user_id: api_user_id, + }, + ) + .await; + assert_eq!(get_status(&resp), StatusCode::OK); + assert_eq!(3, resp.unwrap().0.count); + } } diff --git a/v-api/src/endpoints/handlers.rs b/v-api/src/endpoints/handlers.rs index b0bf73d2..39f17f8d 100644 --- a/v-api/src/endpoints/handlers.rs +++ b/v-api/src/endpoints/handlers.rs @@ -13,7 +13,7 @@ mod macros { RequestContext, TypedBody, Body, }; use http::Response; - use v_model::{Mapper, ApiUserContactEmail, OAuthClient, OAuthClientRedirectUri, OAuthClientSecret, AccessGroup, ApiUser, MagicLink, MagicLinkRedirectUri, MagicLinkSecret}; + use v_model::{Mapper, ApiUserContactEmail, OAuthClient, OAuthClientRedirectUri, OAuthClientSecret, AccessGroup, ApiUser, MagicLink, MagicLinkRedirectUri, MagicLinkSecret, AccessToken}; use v_api::endpoints::{ api_user::{ @@ -21,9 +21,9 @@ mod macros { delete_api_user_token_op, get_api_user_op, list_api_user_op, get_api_user_token_op, get_self_op, link_provider_op, list_api_user_tokens_op, remove_api_user_from_group_op, remove_api_user_permission_op, set_api_user_contact_email_op, list_api_users_for_group_op, - update_api_user_op, AddGroupBody, ApiKeyCreateParams, ApiKeyResponse, ApiUserPath, + update_api_user_op, list_api_user_sessions_op, logout_api_user_op, AddGroupBody, ApiKeyCreateParams, ApiKeyResponse, ApiUserPath, ApiUserPermissionParams, ApiUserProviderLinkPayload, ApiUserRemoveGroupPath, ApiUserTokenPath, - ApiUserUpdateParams, GetUserResponse, InitialApiKeyResponse, ApiUserEmailUpdateParams, ApiUserGroupPath + ApiUserUpdateParams, GetUserResponse, InitialApiKeyResponse, ApiUserEmailUpdateParams, ApiUserGroupPath, RevokedResponse }, api_user_provider::{ create_link_token_op, ApiUserLinkRequestPayload, ApiUserLinkRequestResponse, @@ -490,6 +490,30 @@ mod macros { update_api_user_op(&rqctx, path.into_inner(), body.into_inner()).await } + /// List existing sessions for a user. + #[endpoint { + method = GET, + path = "/api-user/{user_id}/session", + }] + pub async fn list_api_user_sessions( + rqctx: RequestContext<$context_type>, + path: Path, + ) -> Result>, HttpError> { + list_api_user_sessions_op(&rqctx, path.into_inner()).await + } + + /// Revoke any existing sessions for the user. + #[endpoint { + method = POST, + path = "/api-user/{user_id}/logout", + }] + pub async fn logout_api_user( + rqctx: RequestContext<$context_type>, + path: Path, + ) -> Result, HttpError> { + logout_api_user_op(&rqctx, path.into_inner()).await + } + /// Add a single permission to a user #[endpoint { method = POST, @@ -760,6 +784,10 @@ mod macros { .expect("Failed to register endpoint"); $api.register(set_api_user_contact_email) .expect("Failed to register endpoint"); + $api.register(list_api_user_sessions) + .expect("Failed to register endpoint"); + $api.register(logout_api_user) + .expect("Failed to register endpoint"); $api.register(list_api_user_tokens) .expect("Failed to register endpoint"); $api.register(get_api_user_token) diff --git a/v-api/tests/endpoint_macros.rs b/v-api/tests/endpoint_macros.rs index 930f67d0..df625c66 100644 --- a/v-api/tests/endpoint_macros.rs +++ b/v-api/tests/endpoint_macros.rs @@ -62,8 +62,10 @@ mod system { } } -/// The paths of the registered endpoints, taken from the generated OpenAPI document. -fn registered_paths(api: &ApiDescription) -> BTreeSet { +/// Extract the methods from a described dropshot API. This creates a set of +/// (method, pattern) pairs that each map to an #endpoint definition that has +/// been registered with the description. +fn registered_endpoints(api: &ApiDescription) -> BTreeSet<(String, String)> { let document = api .openapi("v-api endpoint macros", Version::new(0, 0, 1)) .json() @@ -72,57 +74,127 @@ fn registered_paths(api: &ApiDescription) -> BTreeSet { document["paths"] .as_object() .expect("OpenAPI document has no paths object") - .keys() - .cloned() + .iter() + .flat_map(|(path, item)| { + item.as_object() + .expect("OpenAPI path item is not an object") + .keys() + .filter(|key| { + matches!( + key.as_str(), + "get" | "put" | "post" | "delete" | "options" | "head" | "patch" | "trace" + ) + }) + .map(|method| (method.to_uppercase(), path.clone())) + }) .collect() } -/// The endpoints of `v_system_endpoints` are reachable once `inject_endpoints` has run -#[test] -fn system_endpoints_are_registered() { - let paths = registered_paths(&system::api()); - - for expected in [ - "/.well-known/jwks.json", - "/.well-known/openid-configuration", - "/api-user", - "/api-user/{user_id}", - "/api-user/{user_id}/token/{api_key_id}", - "/group", - "/group/{group_id}", - "/login/magic/{channel}/send", - "/login/oauth/{provider}/code/token", - "/login/oauth/{provider}/device", - "/magic/client", - "/mapper", - "/oauth/client/{client_id}/secret", - "/self", - ] { - assert!(paths.contains(expected), "{expected} was not registered"); - } -} +/// Every endpoint our inject macros define +fn expected_endpoints() -> BTreeSet<(String, String)> { + let mut endpoints = vec![ + // OAuth clients + ("GET", "/oauth/client"), + ("POST", "/oauth/client"), + ("GET", "/oauth/client/{client_id}"), + ("POST", "/oauth/client/{client_id}/secret"), + ("DELETE", "/oauth/client/{client_id}/secret/{secret_id}"), + ("POST", "/oauth/client/{client_id}/redirect_uri"), + ( + "DELETE", + "/oauth/client/{client_id}/redirect_uri/{redirect_uri_id}", + ), + // Magic link clients + ("GET", "/magic/client"), + ("POST", "/magic/client"), + ("GET", "/magic/client/{client_id}"), + ("POST", "/magic/client/{client_id}/secret"), + ("DELETE", "/magic/client/{client_id}/secret/{secret_id}"), + ("POST", "/magic/client/{client_id}/redirect_uri"), + ( + "DELETE", + "/magic/client/{client_id}/redirect_uri/{redirect_uri_id}", + ), + // OAuth login + ("GET", "/login/oauth/{provider}/code/authorize"), + ("GET", "/login/oauth/{provider}/code/callback"), + ("POST", "/login/oauth/{provider}/code/token"), + ("GET", "/login/oauth/{provider}/public-pkce"), + ("GET", "/login/oauth/{provider}/device"), + ("POST", "/login/oauth/{provider}/device"), + ("POST", "/login/oauth/{provider}/device/exchange"), + // Magic link login + ("POST", "/login/magic/{channel}/send"), + ("POST", "/login/magic/{channel}/exchange"), + // Well known + ("GET", "/.well-known/openid-configuration"), + ("GET", "/.well-known/jwks.json"), + // Users + ("POST", "/api-user-provider/{provider_id}/link-token"), + ("GET", "/self"), + ("GET", "/api-user"), + ("POST", "/api-user"), + ("GET", "/api-user/{user_id}"), + ("POST", "/api-user/{user_id}"), + ("GET", "/api-user/{user_id}/session"), + ("POST", "/api-user/{user_id}/logout"), + ("POST", "/api-user/{user_id}/permission"), + ("DELETE", "/api-user/{user_id}/permission"), + ("GET", "/api-user/{user_id}/token"), + ("POST", "/api-user/{user_id}/token"), + ("GET", "/api-user/{user_id}/token/{api_key_id}"), + ("DELETE", "/api-user/{user_id}/token/{api_key_id}"), + ("PUT", "/api-user/{user_id}/contact/email"), + ("POST", "/api-user/{user_id}/group"), + ("DELETE", "/api-user/{user_id}/group/{group_id}"), + ("POST", "/api-user/{user_id}/link"), + // Groups + ("GET", "/group"), + ("POST", "/group"), + ("PUT", "/group/{group_id}"), + ("DELETE", "/group/{group_id}"), + ("GET", "/group-membership/{group_id}"), + // Mappers + ("GET", "/mapper"), + ("POST", "/mapper"), + ("DELETE", "/mapper/{mapper_id}"), + ]; + + #[cfg(feature = "local-dev")] + endpoints.push(("POST", "/login/local")); -/// The endpoints of `v_saga_endpoints` are reachable once `inject_v_saga_endpoints` has run -#[cfg(feature = "sagas")] -#[test] -fn saga_endpoints_are_registered() { - let paths = registered_paths(&system::api()); + #[cfg(feature = "sagas")] + endpoints.extend([("GET", "/saga"), ("GET", "/saga/{saga}")]); - assert!(paths.contains("/saga"), "/saga was not registered"); - assert!( - paths.contains("/saga/{saga}"), - "/saga/{{saga}} was not registered" - ); + #[cfg(feature = "metrics")] + endpoints.push(("GET", "/v/metrics")); + + endpoints + .into_iter() + .map(|(method, path)| (method.to_string(), path.to_string())) + .collect() } -/// The endpoint of `v_metrics_endpoints` is reachable once `inject_v_metrics_endpoints` has run -#[cfg(feature = "metrics")] +/// Checks that endpoints registered by the inject macros match exactly to our +/// endpoint map. If this test is failing and the new endpoint has not been +/// added to `expected_endpoints` then you will need to add it there. #[test] -fn metrics_endpoint_is_registered() { - let paths = registered_paths(&system::api()); +fn registered_endpoints_match_the_expected_set() { + let registered = registered_endpoints(&system::api()); + let expected = expected_endpoints(); + + // Ensure that every expected endpoint in our map is present in the api. + // This catches cases where an endpoint is created but not registered, or + // accidentally removed. + let missing = expected.difference(®istered).collect::>(); + + // Ensures that no new endpoint is added without it being added to our test + // set above. This is our "checkin" for endpoints we use to make sure we + // do not break / remove endpoints unexpectedly. + let unexpected = registered.difference(&expected).collect::>(); assert!( - paths.contains("/v/metrics"), - "/v/metrics was not registered" + missing.is_empty() && unexpected.is_empty(), + "registered endpoints do not match\n missing {missing:?}\n unexpected {unexpected:?}" ); } diff --git a/v-model/src/storage/mod.rs b/v-model/src/storage/mod.rs index 0108b647..c42c8a79 100644 --- a/v-model/src/storage/mod.rs +++ b/v-model/src/storage/mod.rs @@ -260,6 +260,7 @@ pub trait AccessTokenStore { pagination: &ListPagination, ) -> Result, StoreError>; async fn upsert(&self, token: NewAccessToken) -> Result; + async fn revoke(&self, filter: AccessTokenFilter) -> Result; } #[derive(Debug, Default)] diff --git a/v-model/src/storage/postgres.rs b/v-model/src/storage/postgres.rs index 995453fd..e47d0ab5 100644 --- a/v-model/src/storage/postgres.rs +++ b/v-model/src/storage/postgres.rs @@ -676,6 +676,38 @@ impl AccessTokenStore for PostgresStore { Ok(token_m.into()) } + + async fn revoke(&self, filter: AccessTokenFilter) -> Result { + let mut query = update(api_user_access_token::table).into_boxed(); + + let AccessTokenFilter { + id, + api_user_id, + // The revoked filter in this context is not useful as we do not + // revoke already revoked tokens + revoked: _, + } = filter; + + if let Some(id) = id { + query = query.filter( + api_user_access_token::id + .eq_any(id.into_iter().map(|token| token.into_untyped_uuid())), + ); + } + + if let Some(api_user_id) = api_user_id { + query = query.filter( + api_user_access_token::api_user_id + .eq_any(api_user_id.into_iter().map(|user| user.into_untyped_uuid())), + ); + } + + Ok(query + .filter(api_user_access_token::revoked_at.is_null()) + .set(api_user_access_token::revoked_at.eq(Utc::now())) + .execute_async(&*self.pool.get().await?) + .await?) + } } #[async_trait]