Skip to content
Open
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
70 changes: 54 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 11 additions & 0 deletions v-api/src/context/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2165,6 +2165,17 @@ pub(crate) mod test_mocks {
.upsert(token)
.await
}

async fn revoke(
&self,
filter: v_model::storage::AccessTokenFilter,
) -> Result<usize, v_model::storage::StoreError> {
self.access_token_store
.as_ref()
.unwrap()
.revoke(filter)
.await
}
}

#[async_trait]
Expand Down
44 changes: 41 additions & 3 deletions v-api/src/context/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
};

Expand Down Expand Up @@ -699,6 +699,44 @@ where
expires_in: claims.exp - Utc::now().timestamp(),
})
}

pub async fn list_access_tokens(
&self,
caller: &Caller<T>,
api_user: &TypedUuid<UserId>,
) -> ResourceResult<Vec<AccessToken>, UserContextError> {
if caller.can(&VPermission::GetApiUser(*api_user).into()) {
let filter = AccessTokenFilter {
api_user_id: Some(vec![*api_user]),
revoked: true,
Comment thread
notpeter marked this conversation as resolved.
..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<T>,
api_user: &TypedUuid<UserId>,
) -> ResourceResult<usize, UserContextError> {
if caller.can(&VPermission::ManageApiUser(*api_user).into()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do we want to support users logging themselves out or only admins?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Eventually yes, but didn't want to hold this up on determining the required permission for it.

let filter = AccessTokenFilter {
api_user_id: Some(vec![*api_user]),
..Default::default()
};

Ok(AccessTokenStore::revoke(&*self.storage, filter).await?)
} else {
resource_restricted()
}
}
}

#[cfg(test)]
Expand Down
183 changes: 177 additions & 6 deletions v-api/src/endpoints/api_user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
};
Expand Down Expand Up @@ -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<T>(
rqctx: &RequestContext<impl ApiContext<AppPermissions = T>>,
path: ApiUserPath,
) -> Result<HttpResponseOk<Vec<AccessToken>>, 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<T>(
ctx: &VContext<T>,
caller: Caller<T>,
path: ApiUserPath,
) -> Result<HttpResponseOk<Vec<AccessToken>>, 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<T>(
rqctx: &RequestContext<impl ApiContext<AppPermissions = T>>,
path: ApiUserPath,
) -> Result<HttpResponseOk<RevokedResponse>, 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<T>(
ctx: &VContext<T>,
caller: Caller<T>,
path: ApiUserPath,
) -> Result<HttpResponseOk<RevokedResponse>, 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,
Expand Down Expand Up @@ -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,
},
};

Expand All @@ -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,
Expand Down Expand Up @@ -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);
}
}
Loading
Loading