diff --git a/Cargo.lock b/Cargo.lock index 3c11307..45192ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2422,7 +2422,7 @@ dependencies = [ [[package]] name = "progenitor" version = "0.3.0" -source = "git+https://github.com/oxidecomputer/progenitor?branch=print-example#032b1bcc2f672af6abdbd0678d04d0405c7701ae" +source = "git+https://github.com/oxidecomputer/progenitor?branch=print-example#424f7b34de7debfd4028b10cdcdf34b3192150e1" dependencies = [ "progenitor-client", "progenitor-impl", @@ -2433,7 +2433,7 @@ dependencies = [ [[package]] name = "progenitor-client" version = "0.3.0" -source = "git+https://github.com/oxidecomputer/progenitor?branch=print-example#032b1bcc2f672af6abdbd0678d04d0405c7701ae" +source = "git+https://github.com/oxidecomputer/progenitor?branch=print-example#424f7b34de7debfd4028b10cdcdf34b3192150e1" dependencies = [ "bytes", "futures-core", @@ -2447,7 +2447,7 @@ dependencies = [ [[package]] name = "progenitor-impl" version = "0.3.0" -source = "git+https://github.com/oxidecomputer/progenitor?branch=print-example#032b1bcc2f672af6abdbd0678d04d0405c7701ae" +source = "git+https://github.com/oxidecomputer/progenitor?branch=print-example#424f7b34de7debfd4028b10cdcdf34b3192150e1" dependencies = [ "getopts", "heck", @@ -2469,7 +2469,7 @@ dependencies = [ [[package]] name = "progenitor-macro" version = "0.3.0" -source = "git+https://github.com/oxidecomputer/progenitor?branch=print-example#032b1bcc2f672af6abdbd0678d04d0405c7701ae" +source = "git+https://github.com/oxidecomputer/progenitor?branch=print-example#424f7b34de7debfd4028b10cdcdf34b3192150e1" dependencies = [ "openapiv3", "proc-macro2", @@ -2801,6 +2801,7 @@ dependencies = [ "config", "dirs 5.0.1", "itertools 0.11.0", + "jsonwebtoken", "oauth2", "progenitor-client", "reqwest", @@ -3041,9 +3042,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.101.4" +version = "0.101.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d93931baf2d282fff8d3a532bbfd7653f734643161b87e3e01e59a04439bf0d" +checksum = "3c7d5dece342910d9ba34d259310cae3e0154b873b35408b787b59bce53d34fe" dependencies = [ "ring", "untrusted", @@ -3977,7 +3978,7 @@ checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" [[package]] name = "typify" version = "0.0.14" -source = "git+https://github.com/oxidecomputer/typify#42390b7e0df6957b77785f33801b0bfbb0e7eafa" +source = "git+https://github.com/oxidecomputer/typify#76a411ee23423c20120bd07f47d79b38b64d06cf" dependencies = [ "typify-impl", "typify-macro", @@ -3986,7 +3987,7 @@ dependencies = [ [[package]] name = "typify-impl" version = "0.0.14" -source = "git+https://github.com/oxidecomputer/typify#42390b7e0df6957b77785f33801b0bfbb0e7eafa" +source = "git+https://github.com/oxidecomputer/typify#76a411ee23423c20120bd07f47d79b38b64d06cf" dependencies = [ "heck", "log", @@ -4003,7 +4004,7 @@ dependencies = [ [[package]] name = "typify-macro" version = "0.0.14" -source = "git+https://github.com/oxidecomputer/typify#42390b7e0df6957b77785f33801b0bfbb0e7eafa" +source = "git+https://github.com/oxidecomputer/typify#76a411ee23423c20120bd07f47d79b38b64d06cf" dependencies = [ "proc-macro2", "quote", diff --git a/README.md b/README.md index 9912a76..985ceee 100644 --- a/README.md +++ b/README.md @@ -2,32 +2,86 @@ Work in progress replacement for RFD processing and programmatic access. -## TODO +## Granting access to external users -Add job state and locking +## RFD Model + +### Revisions + +## RFD Processing + +### Triggers + +### Periodic Schedule ## Authentication -Rough sketch of how users can authenticate to the RFD API +### Accounts and Providers + +#### Supported Providers + +GitHub - +Google - + +### Account Linking + +### OAuth2 Authorization Code +### OAuth2 Device Code + +``` +Browser Client RFD API Google + │ │ │ │ + │ │ Request oauth config │ │ + │ ├──────────────────────────►│ │ + │ │◄──────────────────────────┤ │ + │ │ Return with custom │ │ + │ │ token endpoint │ │ + │ │ │ │ + │ │ Device authz request │ │ + │ ├───────────────────────────┼────────────────────────────►│ + │ Authenticate with │◄──────────────────────────┼─────────────────────────────┤ + │ Google and enter │ Return device_code, │ │ + │ user_code │ user_code, etc │ │ + │◄───────────────────┤ │ │ + │ │ │ │ + │ │ Poll token endpoint │ │ + │ ├──────────────────────────►│ │ + │ │ device_code │ Proxied token call │ + │ │ . ├────────────────────────────►│ + │ │ . │◄────────────────────────────┤ + │ │ . │ Return access token │ + │ │◄──────────────────────────┤ │ + │ │ Failure response: │ │ + │ │ Authn not complete │ │ + ├───────────────────►│ │ │ + │ Complete authn │ Poll token endpoint │ │ + │ ├──────────────────────────►│ │ + │ │ device_code │ Proxied token call │ + │ │ ├────────────────────────────►│ + │ │ │◄────────────────────────────┤ + │ │ │ Return access token │ + │ │◄──────────────────────────┤ │ + │ │ Use access token to │ │ + │ │ fetch user info and │ │ + │ │ perform authn based │ │ + │ │ on verified emails │ │ + │ │ into the RFD API. │ │ + │ │ Return RFD API token │ │ + │ │ │ │ + │ │ │ │ ``` - ┌─────────┐ ┌─────────┐ - │ Google │ │ GitHub │ - └────┬────┘ └──┬───┬──┘ - │ ┌───────┘ └───────────┐ -┌─────────┴─────────┐ ┌─────────┴─────────┐ ┌───────────┴───────────┐ -│ OIDC Access Token │ │ App Access Token │ │ Personal Access Token │ -└─────────┬─────────┘ └─────────┬─────────┘ └───────────┬───────────┘ - │ │ │ -┌─────────┴─────────┐ ┌───┴───────────────────────┴───┐ -│ /login/jwt/google │ │ /login/access-token/github │ -└─────────┬─────────┘ └───────────────┬───────────────┘ - └─────────────┐ ┌───────────────┘ - ┌─────────┴───┴─────────┐ - │ Create/Fetch API User │ - └───────────┬───────────┘ - │ - ┌────────────┴────────────┐ - │ Create New Access Token │ - └─────────────────────────┘ -``` \ No newline at end of file + +## Authorization + +### Permissions + +### Groups + +### Mappers + +#### Supported Mappers + +Email Address - +Email Domain - +GitHub Username - diff --git a/rfd-api-spec.json b/rfd-api-spec.json index 82305af..e4fedb5 100644 --- a/rfd-api-spec.json +++ b/rfd-api-spec.json @@ -10,6 +10,52 @@ "version": "" }, "paths": { + "/.well-known/jwks.json": { + "get": { + "operationId": "jwks_json", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Jwks" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/.well-known/openid-configuration": { + "get": { + "operationId": "openid_configuration", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OpenIdConfiguration" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/api-user": { "post": { "summary": "Create a new user with a given set of permissions", @@ -65,7 +111,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiUser_for_ApiPermission" + "$ref": "#/components/schemas/GetApiUserResponse" } } } @@ -209,6 +255,44 @@ } } }, + "/api-user/{identifier}/link": { + "post": { + "summary": "Link an existing login provider to this user", + "operationId": "link_provider", + "parameters": [ + { + "in": "path", + "name": "identifier", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiUserProviderLinkPayload" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/api-user/{identifier}/token": { "get": { "summary": "List the active and expired API tokens for a given user", @@ -374,6 +458,51 @@ } } }, + "/api-user-provider/{identifier}/link-token": { + "post": { + "summary": "Create a new link token for linking this provider to a different api user", + "operationId": "create_link_token", + "parameters": [ + { + "in": "path", + "name": "identifier", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiUserLinkRequestPayload" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiUserLinkRequestResponse" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/github": { "post": { "tags": [ @@ -1151,7 +1280,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiUser_for_ApiPermission" + "$ref": "#/components/schemas/GetApiUserResponse" } } } @@ -1331,8 +1460,13 @@ "UpdateApiUserSelf", "UpdateApiUserAssigned", "UpdateApiUserAll", + "CreateUserApiProviderLinkToken", "ListGroups", "CreateGroup", + "ManageGroupMembershipAssigned", + "ManageGroupMembershipAll", + "ManageGroupsAssigned", + "ManageGroupsAll", "GetRfdsAssigned", "GetRfdsAll", "GetDiscussionsAssigned", @@ -1451,6 +1585,36 @@ ], "additionalProperties": false }, + { + "type": "object", + "properties": { + "ManageGroupMembership": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "ManageGroupMembership" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "ManageGroupMemberships": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "uniqueItems": true + } + }, + "required": [ + "ManageGroupMemberships" + ], + "additionalProperties": false + }, { "type": "object", "properties": { @@ -1464,6 +1628,36 @@ ], "additionalProperties": false }, + { + "type": "object", + "properties": { + "ManageGroup": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "ManageGroup" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "ManageGroups": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "uniqueItems": true + } + }, + "required": [ + "ManageGroups" + ], + "additionalProperties": false + }, { "type": "object", "properties": { @@ -1616,6 +1810,87 @@ } ] }, + "ApiUserLinkRequestPayload": { + "type": "object", + "properties": { + "user_identifier": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "user_identifier" + ] + }, + "ApiUserLinkRequestResponse": { + "type": "object", + "properties": { + "token": { + "type": "string" + } + }, + "required": [ + "token" + ] + }, + "ApiUserProvider": { + "type": "object", + "properties": { + "api_user_id": { + "type": "string", + "format": "uuid" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "deleted_at": { + "nullable": true, + "type": "string", + "format": "date-time" + }, + "emails": { + "type": "array", + "items": { + "type": "string" + } + }, + "id": { + "type": "string", + "format": "uuid" + }, + "provider": { + "type": "string" + }, + "provider_id": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "api_user_id", + "created_at", + "emails", + "id", + "provider", + "provider_id", + "updated_at" + ] + }, + "ApiUserProviderLinkPayload": { + "type": "object", + "properties": { + "token": { + "type": "string" + } + }, + "required": [ + "token" + ] + }, "ApiUserUpdateParams": { "type": "object", "properties": { @@ -1775,6 +2050,24 @@ "source" ] }, + "GetApiUserResponse": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/ApiUser_for_ApiPermission" + }, + "providers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ApiUserProvider" + } + } + }, + "required": [ + "info", + "providers" + ] + }, "GitHubCommit": { "type": "object", "properties": { @@ -1974,6 +2267,47 @@ "key" ] }, + "Jwk": { + "type": "object", + "properties": { + "e": { + "type": "string" + }, + "kid": { + "type": "string" + }, + "kty": { + "type": "string" + }, + "n": { + "type": "string" + }, + "use": { + "type": "string" + } + }, + "required": [ + "e", + "kid", + "kty", + "n", + "use" + ] + }, + "Jwks": { + "type": "object", + "properties": { + "keys": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Jwk" + } + } + }, + "required": [ + "keys" + ] + }, "ListRfd": { "type": "object", "properties": { @@ -2219,6 +2553,17 @@ "google" ] }, + "OpenIdConfiguration": { + "type": "object", + "properties": { + "jwks_uri": { + "type": "string" + } + }, + "required": [ + "jwks_uri" + ] + }, "Permissions_for_ApiPermission": { "type": "array", "items": { diff --git a/rfd-api/src/authn/jwt.rs b/rfd-api/src/authn/jwt.rs index 411c817..0e8e697 100644 --- a/rfd-api/src/authn/jwt.rs +++ b/rfd-api/src/authn/jwt.rs @@ -38,6 +38,7 @@ pub struct Jwt { #[derive(Debug, Deserialize, Serialize)] pub struct Claims { pub aud: Uuid, + pub prv: Uuid, pub scp: Vec, pub exp: i64, pub nbf: i64, diff --git a/rfd-api/src/context.rs b/rfd-api/src/context.rs index 379f6a4..2b5796b 100644 --- a/rfd-api/src/context.rs +++ b/rfd-api/src/context.rs @@ -13,20 +13,22 @@ use rfd_model::{ storage::{ AccessGroupFilter, AccessGroupStore, AccessTokenStore, ApiKeyFilter, ApiKeyStore, ApiUserFilter, ApiUserProviderFilter, ApiUserProviderStore, ApiUserStore, JobStore, - ListPagination, LoginAttemptFilter, LoginAttemptStore, MapperFilter, MapperStore, - OAuthClientFilter, OAuthClientRedirectUriStore, OAuthClientSecretStore, OAuthClientStore, - RfdFilter, RfdPdfFilter, RfdPdfStore, RfdRevisionFilter, RfdRevisionStore, RfdStore, - StoreError, + LinkRequestStore, ListPagination, LoginAttemptFilter, LoginAttemptStore, MapperFilter, + MapperStore, OAuthClientFilter, OAuthClientRedirectUriStore, OAuthClientSecretStore, + OAuthClientStore, RfdFilter, RfdPdfFilter, RfdPdfStore, RfdRevisionFilter, + RfdRevisionStore, RfdStore, StoreError, }, - AccessGroup, AccessToken, ApiUser, ApiUserProvider, InvalidValueError, Job, LoginAttempt, - Mapper, NewAccessGroup, NewAccessToken, NewApiKey, NewApiUser, NewApiUserProvider, NewJob, - NewLoginAttempt, NewMapper, NewOAuthClient, NewOAuthClientRedirectUri, NewOAuthClientSecret, - OAuthClient, OAuthClientRedirectUri, OAuthClientSecret, + AccessGroup, AccessToken, ApiUser, ApiUserProvider, InvalidValueError, Job, LinkRequest, + LoginAttempt, Mapper, NewAccessGroup, NewAccessToken, NewApiKey, NewApiUser, + NewApiUserProvider, NewJob, NewLinkRequest, NewLoginAttempt, NewMapper, NewOAuthClient, + NewOAuthClientRedirectUri, NewOAuthClientSecret, OAuthClient, OAuthClientRedirectUri, + OAuthClientSecret, }; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::{ collections::{BTreeSet, HashMap}, + ops::Add, sync::Arc, }; use tap::TapFallible; @@ -37,6 +39,7 @@ use uuid::Uuid; use crate::{ authn::{ jwt::{Claims, JwtSigner, JwtSignerError}, + key::{RawApiKey, SignedApiKey}, AuthError, AuthToken, Signer, }, config::{AsymmetricKey, JwtConfig, SearchConfig}, @@ -68,6 +71,7 @@ pub trait Storage: + OAuthClientRedirectUriStore + AccessGroupStore + MapperStore + + LinkRequestStore + Send + Sync + 'static @@ -88,6 +92,7 @@ impl Storage for T where + OAuthClientRedirectUriStore + AccessGroupStore + MapperStore + + LinkRequestStore + Send + Sync + 'static @@ -546,7 +551,10 @@ impl ApiContext { // Login Operations #[instrument(skip(self, info), fields(info.external_id))] - pub async fn register_api_user(&self, info: UserInfo) -> Result { + pub async fn register_api_user( + &self, + info: UserInfo, + ) -> Result<(User, ApiUserProvider), ApiError> { // Check if we have seen this identity before let mut filter = ApiUserProviderFilter::default(); filter.provider = Some(vec![info.external_id.provider().to_string()]); @@ -571,25 +579,28 @@ impl ApiContext { let user = self .ensure_api_user(Uuid::new_v4(), mapped_permissions, mapped_groups) .await?; - self.update_api_user_provider(NewApiUserProvider { - id: Uuid::new_v4(), - api_user_id: user.id, - emails: info.verified_emails, - provider: info.external_id.provider().to_string(), - provider_id: info.external_id.id().to_string(), - }) - .await?; + let user_provider = self + .update_api_user_provider(NewApiUserProvider { + id: Uuid::new_v4(), + api_user_id: user.id, + emails: info.verified_emails, + provider: info.external_id.provider().to_string(), + provider_id: info.external_id.id().to_string(), + }) + .await?; - Ok(user) + Ok((user, user_provider)) } 1 => { tracing::info!("Found an existing user. Attaching provider."); // This branch ensures that there is a 0th indexed item let provider = api_user_providers.into_iter().nth(0).unwrap(); - Ok(self - .ensure_api_user(provider.api_user_id, mapped_permissions, mapped_groups) - .await?) + Ok(( + self.ensure_api_user(provider.api_user_id, mapped_permissions, mapped_groups) + .await?, + provider, + )) } _ => { // If we found more than one provider, then we have encountered an inconsistency in @@ -691,6 +702,7 @@ impl ApiContext { pub async fn register_access_token( &self, api_user: &ApiUser, + api_user_provider: &ApiUserProvider, scope: Vec, expires_at: Option>, ) -> Result { @@ -704,6 +716,7 @@ impl ApiContext { // Ensure that the token is within the configured limits let claims = Claims { aud: api_user.id, + prv: api_user_provider.id, scp: scope, exp: expires_at.timestamp(), nbf: Utc::now().timestamp(), @@ -1111,6 +1124,63 @@ impl ApiContext { .await .map(|_| ())?) } + + pub async fn get_link_request(&self, id: &Uuid) -> Result, StoreError> { + Ok(LinkRequestStore::get(&*self.storage, id, false, false).await?) + } + + pub async fn create_link_request_token( + &self, + source_provider: &Uuid, + source_user: &Uuid, + target: &Uuid, + ) -> Result { + let link_id = Uuid::new_v4(); + let secret = RawApiKey::generate::<8>(&link_id); + let signed = secret.sign(&*self.secrets.signer).await.unwrap(); + + Ok(LinkRequestStore::upsert( + &*self.storage, + &NewLinkRequest { + id: link_id, + source_provider_id: *source_provider, + source_api_user_id: *source_user, + target_api_user_id: *target, + secret_signature: signed.signature().to_string(), + expires_at: Utc::now().add(Duration::minutes(15)), + completed_at: None, + }, + ) + .await + .map(|_| signed)?) + } + + pub async fn complete_link_request( + &self, + link_request: LinkRequest, + ) -> Result, StoreError> { + if let Some(mut provider) = self + .get_api_user_provider(&link_request.source_provider_id) + .await? + { + provider.api_user_id = link_request.target_api_user_id; + + tracing::info!(?provider, "Created provider update"); + + let source_api_user_id = link_request.source_api_user_id; + let mut update_request: NewLinkRequest = link_request.into(); + update_request.completed_at = Some(Utc::now()); + LinkRequestStore::upsert(&*self.storage, &update_request).await?; + + Ok(Some( + ApiUserProviderStore::transfer(&*self.storage, provider.into(), source_api_user_id) + .await?, + )) + } else { + tracing::warn!(?link_request, "Expected to find a provider that was assigned to a link request, but it looks to have gone missing"); + Ok(None) + } + } } #[cfg(test)] @@ -1143,6 +1213,7 @@ mod tests { let user_token = ctx.jwt.signers[0] .sign(&Claims { aud: user_id, + prv: Uuid::new_v4(), scp: scope, exp: Utc::now().add(Duration::seconds(60)).timestamp(), nbf: 0, @@ -1227,12 +1298,13 @@ pub(crate) mod test_mocks { permissions::Caller, storage::{ AccessGroupStore, AccessTokenStore, ApiKeyStore, ApiUserProviderStore, ApiUserStore, - JobStore, ListPagination, LoginAttemptStore, MapperStore, MockAccessGroupStore, - MockAccessTokenStore, MockApiKeyStore, MockApiUserProviderStore, MockApiUserStore, - MockJobStore, MockLoginAttemptStore, MockMapperStore, MockOAuthClientRedirectUriStore, - MockOAuthClientSecretStore, MockOAuthClientStore, MockRfdPdfStore, - MockRfdRevisionStore, MockRfdStore, OAuthClientRedirectUriStore, - OAuthClientSecretStore, OAuthClientStore, RfdPdfStore, RfdRevisionStore, RfdStore, + JobStore, LinkRequestStore, ListPagination, LoginAttemptStore, MapperStore, + MockAccessGroupStore, MockAccessTokenStore, MockApiKeyStore, MockApiUserProviderStore, + MockApiUserStore, MockJobStore, MockLinkRequestStore, MockLoginAttemptStore, + MockMapperStore, MockOAuthClientRedirectUriStore, MockOAuthClientSecretStore, + MockOAuthClientStore, MockRfdPdfStore, MockRfdRevisionStore, MockRfdStore, + OAuthClientRedirectUriStore, OAuthClientSecretStore, OAuthClientStore, RfdPdfStore, + RfdRevisionStore, RfdStore, }, ApiKey, ApiUserProvider, NewAccessGroup, NewAccessToken, NewApiKey, NewApiUser, NewApiUserProvider, NewJob, NewLoginAttempt, NewMapper, NewRfd, NewRfdPdf, NewRfdRevision, @@ -1296,6 +1368,7 @@ pub(crate) mod test_mocks { pub oauth_client_redirect_uri_store: Option>, pub access_group_store: Option>>, pub mapper_store: Option>, + pub link_request_store: Option>, } impl MockStorage { @@ -1316,6 +1389,7 @@ pub(crate) mod test_mocks { oauth_client_redirect_uri_store: None, access_group_store: None, mapper_store: None, + link_request_store: None, } } } @@ -1610,6 +1684,18 @@ pub(crate) mod test_mocks { .await } + async fn transfer( + &self, + provider: NewApiUserProvider, + current_api_user_id: uuid::Uuid, + ) -> Result { + self.api_user_provider_store + .as_ref() + .unwrap() + .transfer(provider, current_api_user_id) + .await + } + async fn delete( &self, id: &uuid::Uuid, @@ -1878,4 +1964,43 @@ pub(crate) mod test_mocks { self.mapper_store.as_ref().unwrap().delete(id).await } } + + #[async_trait] + impl LinkRequestStore for MockStorage { + async fn get( + &self, + id: &uuid::Uuid, + expired: bool, + completed: bool, + ) -> Result, rfd_model::storage::StoreError> { + self.link_request_store + .as_ref() + .unwrap() + .get(id, expired, completed) + .await + } + + async fn list( + &self, + filter: rfd_model::storage::LinkRequestFilter, + pagination: &ListPagination, + ) -> Result, rfd_model::storage::StoreError> { + self.link_request_store + .as_ref() + .unwrap() + .list(filter, pagination) + .await + } + + async fn upsert( + &self, + request: &rfd_model::NewLinkRequest, + ) -> Result { + self.link_request_store + .as_ref() + .unwrap() + .upsert(request) + .await + } + } } diff --git a/rfd-api/src/endpoints/api_user.rs b/rfd-api/src/endpoints/api_user.rs index 5dc9f71..bdb51de 100644 --- a/rfd-api/src/endpoints/api_user.rs +++ b/rfd-api/src/endpoints/api_user.rs @@ -2,12 +2,17 @@ use std::collections::BTreeSet; use chrono::{DateTime, Utc}; use dropshot::{ - endpoint, HttpError, HttpResponseCreated, HttpResponseOk, Path, RequestContext, TypedBody, + endpoint, HttpError, HttpResponseCreated, HttpResponseOk, HttpResponseUpdatedNoContent, Path, + RequestContext, TypedBody, }; use partial_struct::partial; -use rfd_model::{storage::ListPagination, ApiUser, NewApiKey, NewApiUser}; +use rfd_model::{ + storage::{ApiUserProviderFilter, ListPagination}, + ApiUser, ApiUserProvider, NewApiKey, NewApiUser, +}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use tap::TapFallible; use trace_request::trace_request; use tracing::instrument; use uuid::Uuid; @@ -17,10 +22,18 @@ use crate::{ context::ApiContext, error::ApiError, permissions::ApiPermission, - util::response::{forbidden, not_found, to_internal_error}, + util::response::{ + bad_request, forbidden, internal_error, not_found, to_internal_error, unauthorized, + }, ApiCaller, ApiPermissions, User, }; +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub struct GetApiUserResponse { + info: ApiUser, + providers: Vec, +} + /// Retrieve the user information of the calling user #[trace_request] #[endpoint { @@ -30,7 +43,7 @@ use crate::{ #[instrument(skip(rqctx), fields(request_id = rqctx.request_id), err(Debug))] pub async fn get_self( rqctx: RequestContext, -) -> Result>, HttpError> { +) -> Result, HttpError> { let ctx = rqctx.context(); let auth = ctx.authn_token(&rqctx).await; let caller = ctx.get_caller(&auth?).await?; @@ -47,7 +60,7 @@ pub async fn get_self( pub async fn get_api_user( rqctx: RequestContext, path: Path, -) -> Result, HttpError> { +) -> Result, HttpError> { let ctx = rqctx.context(); let auth = ctx.authn_token(&rqctx).await?; get_api_user_op( @@ -63,7 +76,7 @@ async fn get_api_user_op( ctx: &ApiContext, caller: &ApiCaller, user_id: &Uuid, -) -> Result, HttpError> { +) -> Result, HttpError> { if caller.any(&[ &ApiPermission::GetApiUser(caller.id).into(), &ApiPermission::GetApiUserAll.into(), @@ -73,10 +86,20 @@ async fn get_api_user_op( .await .map_err(ApiError::Storage)?; + let mut filter = ApiUserProviderFilter::default(); + filter.api_user_id = Some(vec![caller.id]); + let providers = ctx + .list_api_user_provider(filter, &ListPagination::default().limit(10)) + .await + .map_err(ApiError::Storage)?; + if let Some(user) = user { tracing::trace!(user = ?serde_json::to_string(&user), "Found user"); - Ok(HttpResponseOk(user)) + Ok(HttpResponseOk(GetApiUserResponse { + info: user, + providers, + })) } else { tracing::error!("Failed to find api user record for authenticated user"); Err(not_found("Failed to find")) @@ -470,6 +493,79 @@ pub async fn remove_api_user_from_group( } } +// TODO: Needs to be implemented + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ApiUserProviderLinkPayload { + token: String, +} + +/// Link an existing login provider to this user +#[trace_request] +#[endpoint { + method = POST, + path = "/api-user/{identifier}/link", +}] +#[instrument(skip(rqctx), fields(request_id = rqctx.request_id), err(Debug))] +pub async fn link_provider( + rqctx: RequestContext, + path: Path, + body: TypedBody, +) -> Result { + let ctx = rqctx.context(); + let auth = ctx.authn_token(&rqctx).await?; + let caller = ctx.get_caller(&auth).await?; + let path = path.into_inner(); + let body = body.into_inner(); + + // This endpoint can only be called by the user themselves, it can not be performed on behalf + // of a user + if path.identifier == caller.id { + let secret = RawApiKey::try_from(body.token.as_str()).map_err(|err| { + tracing::debug!(?err, "Invalid link request token"); + bad_request("Malformed link request token") + })?; + let link_request_id = Uuid::from_slice(secret.id()).map_err(|err| { + tracing::debug!(?err, "Failed to parse link request id from token"); + bad_request("Invalid link request token") + })?; + let link_request = ctx + .get_link_request(&link_request_id) + .await + .map_err(ApiError::Storage)? + .ok_or_else(|| not_found("Failed to find identifier"))?; + + // Verify that the found link request is assigned to the user calling the endpoint and that + // the token provided matches the stored signature + if link_request.target_api_user_id == caller.id + && secret + .verify( + &*ctx.secrets.signer, + link_request.secret_signature.as_bytes(), + ) + .is_ok() + { + let result = ctx + .complete_link_request(link_request) + .await + .tap_err(|err| tracing::error!(?err, "Failed to complete link request")) + .map_err(ApiError::Storage)?; + + match result { + Some(provider) => { + tracing::info!(?provider, "Completed link request"); + Ok(HttpResponseUpdatedNoContent()) + } + None => Err(internal_error("Failed to update provider")), + } + } else { + Err(unauthorized()) + } + } else { + Err(unauthorized()) + } +} + #[cfg(test)] mod tests { use std::{collections::BTreeSet, sync::Arc}; diff --git a/rfd-api/src/endpoints/api_user_provider.rs b/rfd-api/src/endpoints/api_user_provider.rs new file mode 100644 index 0000000..9c6b55d --- /dev/null +++ b/rfd-api/src/endpoints/api_user_provider.rs @@ -0,0 +1,70 @@ +use dropshot::{endpoint, HttpError, HttpResponseOk, Path, RequestContext, TypedBody}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use trace_request::trace_request; +use tracing::instrument; +use uuid::Uuid; + +use crate::{ + context::ApiContext, error::ApiError, permissions::ApiPermission, util::response::forbidden, +}; + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ApiUserProviderPath { + identifier: Uuid, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ApiUserLinkRequestPayload { + user_identifier: Uuid, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct ApiUserLinkRequestResponse { + token: String, +} + +/// Create a new link token for linking this provider to a different api user +#[trace_request] +#[endpoint { + method = POST, + path = "/api-user-provider/{identifier}/link-token", +}] +#[instrument(skip(rqctx), fields(request_id = rqctx.request_id), err(Debug))] +pub async fn create_link_token( + rqctx: RequestContext, + path: Path, + body: TypedBody, +) -> Result, HttpError> { + let ctx = rqctx.context(); + let auth = ctx.authn_token(&rqctx).await?; + let caller = ctx.get_caller(&auth).await?; + let path = path.into_inner(); + let body = body.into_inner(); + + let provider = ctx + .get_api_user_provider(&path.identifier) + .await + .map_err(ApiError::Storage)?; + + if let Some(provider) = provider { + if provider.api_user_id == caller.id + && caller.can(&ApiPermission::CreateUserApiProviderLinkToken) + { + let token = ctx + .create_link_request_token(&path.identifier, &caller.id, &body.user_identifier) + .await + .map_err(ApiError::Storage)?; + + Ok(HttpResponseOk(ApiUserLinkRequestResponse { + token: token.key(), + })) + } else { + tracing::info!(caller = ?caller.id, provider = ?provider.id, provider_user = ?provider.api_user_id, "User does not have permission to modify this provider"); + Err(forbidden()) + } + } else { + tracing::debug!(id = ?path.identifier, "Failed to find requested provider"); + Err(forbidden()) + } +} diff --git a/rfd-api/src/endpoints/login/oauth/code.rs b/rfd-api/src/endpoints/login/oauth/code.rs index 34fa4fb..37d6bea 100644 --- a/rfd-api/src/endpoints/login/oauth/code.rs +++ b/rfd-api/src/endpoints/login/oauth/code.rs @@ -484,7 +484,7 @@ pub async fn authz_code_exchange( tracing::debug!("Retrieved user information from remote provider"); // Register this user as an API user if needed - let api_user = ctx.register_api_user(info).await?; + let (api_user, api_user_provider) = ctx.register_api_user(info).await?; tracing::info!(api_user_id = ?api_user.id, "Retrieved api user to generate access token for"); @@ -499,6 +499,7 @@ pub async fn authz_code_exchange( let token = ctx .register_access_token( &api_user, + &api_user_provider, scope, Some(Utc::now().add(Duration::seconds(7 * 24 * 60 * 60))), ) diff --git a/rfd-api/src/endpoints/login/oauth/device_token.rs b/rfd-api/src/endpoints/login/oauth/device_token.rs index d6c43b5..a4ad5e7 100644 --- a/rfd-api/src/endpoints/login/oauth/device_token.rs +++ b/rfd-api/src/endpoints/login/oauth/device_token.rs @@ -185,15 +185,17 @@ pub async fn exchange_device_token( tracing::debug!("Verified and validated OAuth user"); - let api_user = ctx.register_api_user(info).await?; + let (api_user, api_user_provider) = ctx.register_api_user(info).await?; - tracing::info!(api_user_id = ?api_user.id, "Retrieved api user to generate device token for"); + tracing::info!(api_user_id = ?api_user.id, api_user_provider_id = ?api_user_provider.id, "Retrieved api user to generate device token for"); let token = ctx .register_access_token( &api_user, + &api_user_provider, vec![ "user:info:r".to_string(), + "user:provider:w".to_string(), "user:token:r".to_string(), "user:token:w".to_string(), "group:r".to_string(), diff --git a/rfd-api/src/endpoints/mod.rs b/rfd-api/src/endpoints/mod.rs index 0077123..80051d0 100644 --- a/rfd-api/src/endpoints/mod.rs +++ b/rfd-api/src/endpoints/mod.rs @@ -1,5 +1,7 @@ pub mod api_user; +pub mod api_user_provider; pub mod group; pub mod login; pub mod rfd; pub mod webhook; +pub mod well_known; diff --git a/rfd-api/src/endpoints/well_known/mod.rs b/rfd-api/src/endpoints/well_known/mod.rs new file mode 100644 index 0000000..de734b2 --- /dev/null +++ b/rfd-api/src/endpoints/well_known/mod.rs @@ -0,0 +1,85 @@ +use dropshot::{endpoint, HttpError, HttpResponseOk, RequestContext}; +use jsonwebtoken::jwk::{AlgorithmParameters, JwkSet, PublicKeyUse}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use trace_request::trace_request; +use tracing::instrument; + +use crate::context::ApiContext; + +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub struct OpenIdConfiguration { + jwks_uri: String, +} + +#[trace_request] +#[endpoint { + method = GET, + path = "/.well-known/openid-configuration", +}] +#[instrument(skip(rqctx), err(Debug))] +pub async fn openid_configuration( + rqctx: RequestContext, +) -> Result, HttpError> { + Ok(HttpResponseOk(OpenIdConfiguration { + jwks_uri: format!("{}/.well-known/jwks.json", &rqctx.context().public_url), + })) +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub struct Jwks { + keys: Vec, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub struct Jwk { + kty: String, + kid: String, + #[serde(rename = "use")] + use_: String, + n: String, + e: String, +} + +#[trace_request] +#[endpoint { + method = GET, + path = "/.well-known/jwks.json", +}] +#[instrument(skip(rqctx), err(Debug))] +pub async fn jwks_json( + rqctx: RequestContext, +) -> Result, HttpError> { + let jwks = rqctx.context().jwks().await; + Ok(HttpResponseOk(jwks.into())) +} + +impl From<&JwkSet> for Jwks { + fn from(value: &JwkSet) -> Self { + Self { + keys: value + .keys + .iter() + .map(|jwk| { + let (algo, n, e) = match &jwk.algorithm { + AlgorithmParameters::RSA(params) => { + ("RSA".to_string(), params.n.clone(), params.e.clone()) + } + _ => panic!("Unexpected key type"), + }; + + Jwk { + kty: algo, + kid: jwk.common.key_id.as_ref().unwrap().clone(), + use_: match jwk.common.public_key_use { + Some(PublicKeyUse::Signature) => "sig".to_string(), + _ => panic!("Unexpected key use"), + }, + n, + e, + } + }) + .collect(), + } + } +} diff --git a/rfd-api/src/initial_data.rs b/rfd-api/src/initial_data.rs index 40479ad..e06eed2 100644 --- a/rfd-api/src/initial_data.rs +++ b/rfd-api/src/initial_data.rs @@ -53,12 +53,20 @@ impl InitialData { } pub async fn initialize(self, ctx: &ApiContext) -> Result<(), InitError> { + let existing_groups = ctx.get_groups().await?; + for group in self.groups { let span = tracing::info_span!("Initializing group", group = ?group); async { + let id = existing_groups + .iter() + .find(|g| g.name == group.name) + .map(|g| g.id) + .unwrap_or(Uuid::new_v4()); + ctx.create_group(NewAccessGroup { - id: Uuid::new_v4(), + id, name: group.name, permissions: group.permissions, }) diff --git a/rfd-api/src/permissions.rs b/rfd-api/src/permissions.rs index 48042ee..00dce7b 100644 --- a/rfd-api/src/permissions.rs +++ b/rfd-api/src/permissions.rs @@ -7,193 +7,10 @@ use uuid::Uuid; use crate::ApiPermissions; -pub trait PermissionStorage { - fn contract(&self, owner: &Uuid) -> Self; - fn expand(&self, owner: &Uuid, owner_permissions: Option<&ApiPermissions>) -> Self; -} - -impl PermissionStorage for Permissions { - fn contract(&self, owner: &Uuid) -> Self { - let mut contracted = Vec::new(); - - let mut rfds = BTreeSet::::new(); - let mut discussions = BTreeSet::::new(); - let mut read_oauth_clients = BTreeSet::::new(); - let mut update_oauth_clients = BTreeSet::::new(); - let mut delete_oauth_clients = BTreeSet::::new(); - - for p in self.iter() { - match p { - ApiPermission::GetRfd(number) => { - rfds.insert(*number); - } - ApiPermission::GetDiscussion(number) => { - discussions.insert(*number); - } - ApiPermission::GetOAuthClient(id) => { - read_oauth_clients.insert(*id); - } - ApiPermission::UpdateOAuthClient(id) => { - update_oauth_clients.insert(*id); - } - ApiPermission::DeleteOAuthClient(id) => { - delete_oauth_clients.insert(*id); - } - - ApiPermission::GetApiUser(id) => contracted.push(if id == owner { - ApiPermission::GetApiUserSelf - } else { - ApiPermission::GetApiUser(*id) - }), - ApiPermission::CreateApiUserToken(id) => contracted.push(if id == owner { - ApiPermission::CreateApiUserTokenSelf - } else { - ApiPermission::CreateApiUserToken(*id) - }), - ApiPermission::GetApiUserToken(id) => contracted.push(if id == owner { - ApiPermission::GetApiUserTokenSelf - } else { - ApiPermission::GetApiUserToken(*id) - }), - ApiPermission::DeleteApiUserToken(id) => contracted.push(if id == owner { - ApiPermission::DeleteApiUserTokenSelf - } else { - ApiPermission::DeleteApiUserToken(*id) - }), - ApiPermission::UpdateApiUser(id) => contracted.push(if id == owner { - ApiPermission::UpdateApiUserSelf - } else { - ApiPermission::UpdateApiUser(*id) - }), - - other => contracted.push(other.clone()), - } - } - - contracted.push(ApiPermission::GetRfds(rfds)); - contracted.push(ApiPermission::GetDiscussions(discussions)); - contracted.push(ApiPermission::GetOAuthClients(read_oauth_clients)); - contracted.push(ApiPermission::UpdateOAuthClients(update_oauth_clients)); - contracted.push(ApiPermission::DeleteOAuthClients(delete_oauth_clients)); - - contracted.into() - } - - fn expand(&self, owner: &Uuid, owner_permissions: Option<&ApiPermissions>) -> Self { - let mut expanded = Vec::new(); - - for p in self.iter() { - match p { - ApiPermission::GetRfds(numbers) => { - for number in numbers { - expanded.push(ApiPermission::GetRfd(*number)) - } - } - ApiPermission::GetDiscussions(numbers) => { - for number in numbers { - expanded.push(ApiPermission::GetDiscussion(*number)) - } - } - ApiPermission::GetOAuthClients(ids) => { - for id in ids { - expanded.push(ApiPermission::GetOAuthClient(*id)) - } - } - ApiPermission::UpdateOAuthClients(ids) => { - for id in ids { - expanded.push(ApiPermission::UpdateOAuthClient(*id)) - } - } - ApiPermission::DeleteOAuthClients(ids) => { - for id in ids { - expanded.push(ApiPermission::DeleteOAuthClient(*id)) - } - } - - ApiPermission::GetRfdsAssigned => { - expanded.push(p.clone()); - - if let Some(owner_permissions) = owner_permissions { - for p in owner_permissions.iter() { - match p { - ApiPermission::GetRfd(number) => { - expanded.push(ApiPermission::GetRfd(*number)) - } - _ => (), - } - } - } - } - ApiPermission::GetOAuthClientsAssigned => { - expanded.push(p.clone()); - - if let Some(owner_permissions) = owner_permissions { - for p in owner_permissions.iter() { - match p { - ApiPermission::GetOAuthClient(id) => { - expanded.push(ApiPermission::GetOAuthClient(*id)) - } - _ => (), - } - } - } - } - ApiPermission::UpdateOAuthClientsAssigned => { - expanded.push(p.clone()); - - if let Some(owner_permissions) = owner_permissions { - for p in owner_permissions.iter() { - match p { - ApiPermission::UpdateOAuthClient(id) => { - expanded.push(ApiPermission::UpdateOAuthClient(*id)) - } - _ => (), - } - } - } - } - ApiPermission::DeleteOAuthClientsAssigned => { - expanded.push(p.clone()); - - if let Some(owner_permissions) = owner_permissions { - for p in owner_permissions.iter() { - match p { - ApiPermission::DeleteOAuthClient(id) => { - expanded.push(ApiPermission::DeleteOAuthClient(*id)) - } - _ => (), - } - } - } - } - - ApiPermission::GetApiUserSelf => { - expanded.push(p.clone()); - expanded.push(ApiPermission::GetApiUser(*owner)) - } - ApiPermission::CreateApiUserTokenSelf => { - expanded.push(p.clone()); - expanded.push(ApiPermission::CreateApiUserToken(*owner)) - } - ApiPermission::GetApiUserTokenSelf => { - expanded.push(p.clone()); - expanded.push(ApiPermission::GetApiUserToken(*owner)) - } - ApiPermission::DeleteApiUserTokenSelf => { - expanded.push(p.clone()); - expanded.push(ApiPermission::DeleteApiUserToken(*owner)) - } - ApiPermission::UpdateApiUserSelf => { - expanded.push(p.clone()); - expanded.push(ApiPermission::UpdateApiUser(*owner)) - } - - other => expanded.push(other.clone()), - } - } - - expanded.into() - } +#[derive(Debug, Error)] +pub enum ApiPermissionError { + #[error("Scope is invalid: {0}")] + InvalidScope(String), } #[derive( @@ -223,13 +40,24 @@ pub enum ApiPermission { UpdateApiUserAssigned, UpdateApiUserAll, + // User provider permissions + CreateUserApiProviderLinkToken, + // Group permissions, ListGroups, CreateGroup, UpdateGroup(Uuid), AddToGroup(Uuid), RemoveFromGroup(Uuid), + ManageGroupMembership(Uuid), + ManageGroupMemberships(BTreeSet), + ManageGroupMembershipAssigned, + ManageGroupMembershipAll, DeleteGroup(Uuid), + ManageGroup(Uuid), + ManageGroups(BTreeSet), + ManageGroupsAssigned, + ManageGroupsAll, // RFD access permissions GetRfd(i32), @@ -258,12 +86,6 @@ pub enum ApiPermission { DeleteOAuthClientsAll, } -#[derive(Debug, Error)] -pub enum ApiPermissionError { - #[error("Scope is invalid: {0}")] - InvalidScope(String), -} - impl ApiPermission { pub fn as_scope(&self) -> &str { match self { @@ -289,12 +111,22 @@ impl ApiPermission { ApiPermission::UpdateApiUserAssigned => "user:info:w", ApiPermission::UpdateApiUserAll => "user:info:w", + ApiPermission::CreateUserApiProviderLinkToken => "user:provider:w", + ApiPermission::ListGroups => "group:r", ApiPermission::CreateGroup => "group:w", ApiPermission::UpdateGroup(_) => "group:w", ApiPermission::AddToGroup(_) => "group:membership:w", ApiPermission::RemoveFromGroup(_) => "group:membership:w", + ApiPermission::ManageGroupMembership(_) => "group:membership:w", + ApiPermission::ManageGroupMemberships(_) => "group:membership:w", + ApiPermission::ManageGroupMembershipAssigned => "group:membership:w", + ApiPermission::ManageGroupMembershipAll => "group:membership:w", ApiPermission::DeleteGroup(_) => "group:w", + ApiPermission::ManageGroup(_) => "group:w", + ApiPermission::ManageGroups(_) => "group:w", + ApiPermission::ManageGroupsAssigned => "group:w", + ApiPermission::ManageGroupsAll => "group:w", ApiPermission::GetRfd(_) => "rfd:content:r", ApiPermission::GetRfds(_) => "rfd:content:r", @@ -345,6 +177,9 @@ impl ApiPermission { permissions.insert(ApiPermission::UpdateApiUserAssigned); permissions.insert(ApiPermission::UpdateApiUserAll); } + "user:provider:w" => { + permissions.insert(ApiPermission::CreateUserApiProviderLinkToken); + } "user:token:r" => { permissions.insert(ApiPermission::GetApiUserTokenSelf); permissions.insert(ApiPermission::GetApiUserTokenAssigned); @@ -363,11 +198,12 @@ impl ApiPermission { } "group:w" => { permissions.insert(ApiPermission::CreateGroup); - - // TODO: Need new permissions to support non-create actions + permissions.insert(ApiPermission::ManageGroupsAssigned); + permissions.insert(ApiPermission::ManageGroupsAll); } "group:membership:w" => { - // TODO: Need new permissions to support this + permissions.insert(ApiPermission::ManageGroupMembershipAssigned); + permissions.insert(ApiPermission::ManageGroupMembershipAll); } "rfd:content:r" => { permissions.insert(ApiPermission::GetRfdsAssigned); @@ -398,3 +234,244 @@ impl ApiPermission { Ok(permissions) } } + +pub trait PermissionStorage { + fn contract(&self, owner: &Uuid) -> Self; + fn expand(&self, owner: &Uuid, owner_permissions: Option<&ApiPermissions>) -> Self; +} + +impl PermissionStorage for Permissions { + fn contract(&self, owner: &Uuid) -> Self { + let mut contracted = Vec::new(); + + let mut manage_group_memberships = BTreeSet::::new(); + let mut manage_groups = BTreeSet::::new(); + let mut rfds = BTreeSet::::new(); + let mut discussions = BTreeSet::::new(); + let mut read_oauth_clients = BTreeSet::::new(); + let mut update_oauth_clients = BTreeSet::::new(); + let mut delete_oauth_clients = BTreeSet::::new(); + + for p in self.iter() { + match p { + ApiPermission::GetApiUser(id) => contracted.push(if id == owner { + ApiPermission::GetApiUserSelf + } else { + ApiPermission::GetApiUser(*id) + }), + ApiPermission::CreateApiUserToken(id) => contracted.push(if id == owner { + ApiPermission::CreateApiUserTokenSelf + } else { + ApiPermission::CreateApiUserToken(*id) + }), + ApiPermission::GetApiUserToken(id) => contracted.push(if id == owner { + ApiPermission::GetApiUserTokenSelf + } else { + ApiPermission::GetApiUserToken(*id) + }), + ApiPermission::DeleteApiUserToken(id) => contracted.push(if id == owner { + ApiPermission::DeleteApiUserTokenSelf + } else { + ApiPermission::DeleteApiUserToken(*id) + }), + ApiPermission::UpdateApiUser(id) => contracted.push(if id == owner { + ApiPermission::UpdateApiUserSelf + } else { + ApiPermission::UpdateApiUser(*id) + }), + + ApiPermission::ManageGroupMembership(id) => { + manage_group_memberships.insert(*id); + } + ApiPermission::ManageGroup(id) => { + manage_groups.insert(*id); + } + + ApiPermission::GetRfd(number) => { + rfds.insert(*number); + } + ApiPermission::GetDiscussion(number) => { + discussions.insert(*number); + } + ApiPermission::GetOAuthClient(id) => { + read_oauth_clients.insert(*id); + } + ApiPermission::UpdateOAuthClient(id) => { + update_oauth_clients.insert(*id); + } + ApiPermission::DeleteOAuthClient(id) => { + delete_oauth_clients.insert(*id); + } + + other => contracted.push(other.clone()), + } + } + + contracted.push(ApiPermission::ManageGroupMemberships( + manage_group_memberships, + )); + contracted.push(ApiPermission::ManageGroups(manage_groups)); + contracted.push(ApiPermission::GetRfds(rfds)); + contracted.push(ApiPermission::GetDiscussions(discussions)); + contracted.push(ApiPermission::GetOAuthClients(read_oauth_clients)); + contracted.push(ApiPermission::UpdateOAuthClients(update_oauth_clients)); + contracted.push(ApiPermission::DeleteOAuthClients(delete_oauth_clients)); + + contracted.into() + } + + fn expand(&self, owner: &Uuid, owner_permissions: Option<&ApiPermissions>) -> Self { + let mut expanded = Vec::new(); + + for p in self.iter() { + match p { + ApiPermission::GetApiUserSelf => { + expanded.push(p.clone()); + expanded.push(ApiPermission::GetApiUser(*owner)) + } + ApiPermission::CreateApiUserTokenSelf => { + expanded.push(p.clone()); + expanded.push(ApiPermission::CreateApiUserToken(*owner)) + } + ApiPermission::GetApiUserTokenSelf => { + expanded.push(p.clone()); + expanded.push(ApiPermission::GetApiUserToken(*owner)) + } + ApiPermission::DeleteApiUserTokenSelf => { + expanded.push(p.clone()); + expanded.push(ApiPermission::DeleteApiUserToken(*owner)) + } + ApiPermission::UpdateApiUserSelf => { + expanded.push(p.clone()); + expanded.push(ApiPermission::UpdateApiUser(*owner)) + } + + ApiPermission::ManageGroupMemberships(ids) => { + for id in ids { + expanded.push(ApiPermission::ManageGroupMembership(*id)) + } + } + ApiPermission::ManageGroups(ids) => { + for id in ids { + expanded.push(ApiPermission::ManageGroup(*id)) + } + } + + ApiPermission::GetRfds(numbers) => { + for number in numbers { + expanded.push(ApiPermission::GetRfd(*number)) + } + } + ApiPermission::GetDiscussions(numbers) => { + for number in numbers { + expanded.push(ApiPermission::GetDiscussion(*number)) + } + } + ApiPermission::GetOAuthClients(ids) => { + for id in ids { + expanded.push(ApiPermission::GetOAuthClient(*id)) + } + } + ApiPermission::UpdateOAuthClients(ids) => { + for id in ids { + expanded.push(ApiPermission::UpdateOAuthClient(*id)) + } + } + ApiPermission::DeleteOAuthClients(ids) => { + for id in ids { + expanded.push(ApiPermission::DeleteOAuthClient(*id)) + } + } + + ApiPermission::ManageGroupMembershipAssigned => { + expanded.push(p.clone()); + + if let Some(owner_permissions) = owner_permissions { + for p in owner_permissions.iter() { + match p { + ApiPermission::ManageGroupMembership(id) => { + expanded.push(ApiPermission::ManageGroupMembership(*id)) + } + _ => (), + } + } + } + } + ApiPermission::ManageGroupsAssigned => { + expanded.push(p.clone()); + + if let Some(owner_permissions) = owner_permissions { + for p in owner_permissions.iter() { + match p { + ApiPermission::ManageGroup(id) => { + expanded.push(ApiPermission::ManageGroup(*id)) + } + _ => (), + } + } + } + } + ApiPermission::GetRfdsAssigned => { + expanded.push(p.clone()); + + if let Some(owner_permissions) = owner_permissions { + for p in owner_permissions.iter() { + match p { + ApiPermission::GetRfd(number) => { + expanded.push(ApiPermission::GetRfd(*number)) + } + _ => (), + } + } + } + } + ApiPermission::GetOAuthClientsAssigned => { + expanded.push(p.clone()); + + if let Some(owner_permissions) = owner_permissions { + for p in owner_permissions.iter() { + match p { + ApiPermission::GetOAuthClient(id) => { + expanded.push(ApiPermission::GetOAuthClient(*id)) + } + _ => (), + } + } + } + } + ApiPermission::UpdateOAuthClientsAssigned => { + expanded.push(p.clone()); + + if let Some(owner_permissions) = owner_permissions { + for p in owner_permissions.iter() { + match p { + ApiPermission::UpdateOAuthClient(id) => { + expanded.push(ApiPermission::UpdateOAuthClient(*id)) + } + _ => (), + } + } + } + } + ApiPermission::DeleteOAuthClientsAssigned => { + expanded.push(p.clone()); + + if let Some(owner_permissions) = owner_permissions { + for p in owner_permissions.iter() { + match p { + ApiPermission::DeleteOAuthClient(id) => { + expanded.push(ApiPermission::DeleteOAuthClient(*id)) + } + _ => (), + } + } + } + } + + other => expanded.push(other.clone()), + } + } + + expanded.into() + } +} diff --git a/rfd-api/src/server.rs b/rfd-api/src/server.rs index 65ba5a3..a30e79a 100644 --- a/rfd-api/src/server.rs +++ b/rfd-api/src/server.rs @@ -11,9 +11,10 @@ use crate::{ endpoints::{ api_user::{ add_api_user_to_group, create_api_user, create_api_user_token, delete_api_user_token, - get_api_user, get_api_user_token, get_self, list_api_user_tokens, + get_api_user, get_api_user_token, get_self, link_provider, list_api_user_tokens, remove_api_user_from_group, update_api_user, }, + api_user_provider::create_link_token, group::{create_group, delete_group, get_groups, update_group}, login::oauth::{ client::{ @@ -26,6 +27,7 @@ use crate::{ }, rfd::{get_rfd, get_rfds, search_rfds}, webhook::github_webhook, + well_known::{jwks_json, openid_configuration}, }, }; @@ -72,6 +74,10 @@ pub fn server( tag_definitions, }); + // .well-known + api.register(openid_configuration).unwrap(); + api.register(jwks_json).unwrap(); + // RFDs api.register(get_rfds).unwrap(); api.register(get_rfd).unwrap(); @@ -91,6 +97,8 @@ pub fn server( api.register(delete_api_user_token).unwrap(); api.register(add_api_user_to_group).unwrap(); api.register(remove_api_user_from_group).unwrap(); + api.register(link_provider).unwrap(); + api.register(create_link_token).unwrap(); // Group Management api.register(get_groups).unwrap(); diff --git a/rfd-cli/Cargo.toml b/rfd-cli/Cargo.toml index 9921117..26bedc0 100644 --- a/rfd-cli/Cargo.toml +++ b/rfd-cli/Cargo.toml @@ -12,6 +12,7 @@ clap = { workspace = true } config = { workspace = true } dirs = { workspace = true } itertools = { workspace = true } +jsonwebtoken = { workspace = true } oauth2 = { workspace = true } progenitor-client = { workspace = true } reqwest = { workspace = true } @@ -22,4 +23,4 @@ tabwriter = { workspace = true } textwrap = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } toml = { workspace = true } -uuid = { workspace = true, features = ["serde"] } \ No newline at end of file +uuid = { workspace = true, features = ["serde", "v4"] } \ No newline at end of file diff --git a/rfd-cli/src/auth/link.rs b/rfd-cli/src/auth/link.rs new file mode 100644 index 0000000..f20d374 --- /dev/null +++ b/rfd-cli/src/auth/link.rs @@ -0,0 +1,71 @@ +use core::panic; + +use anyhow::Result; +use clap::{Parser, Subcommand}; +use jsonwebtoken::{Algorithm, DecodingKey, Validation}; +use oauth2::TokenResponse; +use rfd_sdk::types::OAuthProviderName; +use serde::Deserialize; +use uuid::Uuid; + +use crate::{auth::oauth, Context}; + +use super::login::LoginProvider; + +// Authenticates and generates an access token for interacting with the api +#[derive(Parser, Debug)] +#[clap(name = "link")] +pub struct Link { + #[command(subcommand)] + provider: LoginProvider, +} + +#[derive(Debug, Deserialize)] +struct Claims { + prv: Uuid, +} + +impl Link { + pub async fn run(&self, ctx: &mut Context) -> Result<()> { + // Determine the user id of the currently authenticated user + let self_id = ctx.client()?.get_self().send().await?.info.id; + + let access_token = self.provider.run(ctx).await?; + + // Fetch the public JWKS from the remote API + let jwks = ctx.client()?.jwks_json().send().await?.into_inner(); + let jwk = &jwks.keys[0]; + + // Decode the access token to extract the provider token + let jwt = jsonwebtoken::decode::( + &access_token, + &DecodingKey::from_rsa_components(&jwk.n, &jwk.e)?, + &Validation::new(Algorithm::RS256), + )?; + + // An account linking request can only be generated by the owning account. Therefore we + // need to use the sdk to generate a new client + let client = Context::new_client(Ok(&access_token), ctx.config.host()?)?; + + // This needs to be the id of the provider the client just logged in with + let link_token = client + .create_link_token() + .identifier(jwt.claims.prv) + .body_map(|body| body.user_identifier(self_id)) + .send() + .await? + .into_inner() + .token; + + ctx.client()? + .link_provider() + .identifier(self_id) + .body_map(|body| body.token(link_token)) + .send() + .await?; + + println!("Successfully linked provider"); + + Ok(()) + } +} diff --git a/rfd-cli/src/auth/login.rs b/rfd-cli/src/auth/login.rs new file mode 100644 index 0000000..5321d96 --- /dev/null +++ b/rfd-cli/src/auth/login.rs @@ -0,0 +1,70 @@ +use anyhow::Result; +use clap::{Parser, Subcommand}; +use oauth2::TokenResponse; +use rfd_sdk::types::OAuthProviderName; + +use crate::{auth::oauth, Context}; + +// Authenticates and generates an access token for interacting with the api +#[derive(Parser, Debug)] +#[clap(name = "login")] +pub struct Login { + #[command(subcommand)] + provider: LoginProvider, +} + +impl Login { + pub async fn run(&self, ctx: &mut Context) -> Result<()> { + let access_token = self.provider.run(ctx).await?; + + ctx.config.set_token(access_token); + ctx.config.save()?; + + Ok(()) + } +} + +#[derive(Subcommand, Debug)] +pub enum LoginProvider { + /// Login via GitHub + #[command(name = "github")] + GitHub, + /// Login via Google + Google, +} + +impl LoginProvider { + fn as_name(&self) -> OAuthProviderName { + match self { + Self::GitHub => OAuthProviderName::Github, + Self::Google => OAuthProviderName::Google, + } + } + + pub async fn run(&self, ctx: &mut Context) -> Result { + let provider = ctx + .client()? + .get_device_provider() + .provider(self.as_name()) + .send() + .await?; + + let oauth_client = oauth::DeviceOAuth::new(provider.into_inner())?; + let details = oauth_client.get_device_authorization().await?; + + println!( + "To complete login visit: {} and enter {}", + details.verification_uri().as_str(), + details.user_code().secret() + ); + + let token_response = oauth_client.login(&details).await; + + let token = match token_response { + Ok(token) => Ok(token.access_token().to_owned()), + Err(err) => Err(anyhow::anyhow!("Authentication failed: {}", err)), + }?; + + Ok(token.secret().to_string()) + } +} diff --git a/rfd-cli/src/auth/mod.rs b/rfd-cli/src/auth/mod.rs index 61bcd30..3619ff1 100644 --- a/rfd-cli/src/auth/mod.rs +++ b/rfd-cli/src/auth/mod.rs @@ -7,72 +7,9 @@ use std::ops::Add; use crate::{err::format_api_err, Context}; +mod link; +mod login; mod oauth; -// Authenticates and generates an access token for interacting with the api -#[derive(Parser, Debug)] -#[clap(name = "login")] -pub struct Login { - #[command(subcommand)] - provider: LoginProvider, -} - -impl Login { - pub async fn run(&self, ctx: &mut Context) -> Result<()> { - let access_token = self.provider.run(ctx).await?; - // let access_token = match &self.provider { - // LoginProvider::GitHub(github) => github.run(ctx).await, - // LoginProvider::Google(google) => google.run(ctx).await, - // }?; - - ctx.config.set_token(access_token); - ctx.config.save()?; - - Ok(()) - } -} - -#[derive(Subcommand, Debug)] -pub enum LoginProvider { - /// Login via GitHub - #[command(name = "github")] - GitHub, - /// Login via Google - Google, -} - -impl LoginProvider { - fn as_name(&self) -> OAuthProviderName { - match self { - Self::GitHub => OAuthProviderName::Github, - Self::Google => OAuthProviderName::Google, - } - } - - async fn run(&self, ctx: &mut Context) -> Result { - let provider = ctx - .client()? - .get_device_provider() - .provider(self.as_name()) - .send() - .await?; - - let oauth_client = oauth::DeviceOAuth::new(provider.into_inner())?; - let details = oauth_client.get_device_authorization().await?; - - println!( - "To complete login visit: {} and enter {}", - details.verification_uri().as_str(), - details.user_code().secret() - ); - - let token_response = oauth_client.login(&details).await; - - let token = match token_response { - Ok(token) => Ok(token.access_token().to_owned()), - Err(err) => Err(anyhow::anyhow!("Authentication failed: {}", err)), - }?; - - Ok(token.secret().to_string()) - } -} +pub use link::Link; +pub use login::Login; diff --git a/rfd-cli/src/generated/cli.rs b/rfd-cli/src/generated/cli.rs index 62f8976..2e97313 100644 --- a/rfd-cli/src/generated/cli.rs +++ b/rfd-cli/src/generated/cli.rs @@ -19,15 +19,19 @@ impl Cli { pub fn get_command(cmd: CliCommand) -> clap::Command { match cmd { + CliCommand::JwksJson => Self::cli_jwks_json(), + CliCommand::OpenidConfiguration => Self::cli_openid_configuration(), CliCommand::CreateApiUser => Self::cli_create_api_user(), CliCommand::GetApiUser => Self::cli_get_api_user(), CliCommand::UpdateApiUser => Self::cli_update_api_user(), CliCommand::AddApiUserToGroup => Self::cli_add_api_user_to_group(), CliCommand::RemoveApiUserFromGroup => Self::cli_remove_api_user_from_group(), + CliCommand::LinkProvider => Self::cli_link_provider(), CliCommand::ListApiUserTokens => Self::cli_list_api_user_tokens(), CliCommand::CreateApiUserToken => Self::cli_create_api_user_token(), CliCommand::GetApiUserToken => Self::cli_get_api_user_token(), CliCommand::DeleteApiUserToken => Self::cli_delete_api_user_token(), + CliCommand::CreateLinkToken => Self::cli_create_link_token(), CliCommand::GithubWebhook => Self::cli_github_webhook(), CliCommand::GetGroups => Self::cli_get_groups(), CliCommand::CreateGroup => Self::cli_create_group(), @@ -56,6 +60,14 @@ impl Cli { } } + pub fn cli_jwks_json() -> clap::Command { + clap::Command::new("") + } + + pub fn cli_openid_configuration() -> clap::Command { + clap::Command::new("") + } + pub fn cli_create_api_user() -> clap::Command { clap::Command::new("") .arg( @@ -157,6 +169,37 @@ impl Cli { ) } + pub fn cli_link_provider() -> clap::Command { + clap::Command::new("") + .arg( + clap::Arg::new("identifier") + .long("identifier") + .value_parser(clap::value_parser!(uuid::Uuid)) + .required(true), + ) + .arg( + clap::Arg::new("token") + .long("token") + .value_parser(clap::value_parser!(String)) + .required_unless_present("json-body"), + ) + .arg( + clap::Arg::new("json-body") + .long("json-body") + .value_name("JSON-FILE") + .required(false) + .value_parser(clap::value_parser!(std::path::PathBuf)) + .help("Path to a file that contains the full json body."), + ) + .arg( + clap::Arg::new("json-body-template") + .long("json-body-template") + .action(clap::ArgAction::SetTrue) + .help("XXX"), + ) + .about("Link an existing login provider to this user") + } + pub fn cli_list_api_user_tokens() -> clap::Command { clap::Command::new("") .arg( @@ -230,6 +273,37 @@ impl Cli { ) } + pub fn cli_create_link_token() -> clap::Command { + clap::Command::new("") + .arg( + clap::Arg::new("identifier") + .long("identifier") + .value_parser(clap::value_parser!(uuid::Uuid)) + .required(true), + ) + .arg( + clap::Arg::new("user-identifier") + .long("user-identifier") + .value_parser(clap::value_parser!(uuid::Uuid)) + .required_unless_present("json-body"), + ) + .arg( + clap::Arg::new("json-body") + .long("json-body") + .value_name("JSON-FILE") + .required(false) + .value_parser(clap::value_parser!(std::path::PathBuf)) + .help("Path to a file that contains the full json body."), + ) + .arg( + clap::Arg::new("json-body-template") + .long("json-body-template") + .action(clap::ArgAction::SetTrue) + .help("XXX"), + ) + .about("Create a new link token for linking this provider to a different api user") + } + pub fn cli_github_webhook() -> clap::Command { clap::Command::new("") .arg( @@ -670,6 +744,12 @@ impl Cli { pub async fn execute(&self, cmd: CliCommand, matches: &clap::ArgMatches) { match cmd { + CliCommand::JwksJson => { + self.execute_jwks_json(matches).await; + } + CliCommand::OpenidConfiguration => { + self.execute_openid_configuration(matches).await; + } CliCommand::CreateApiUser => { self.execute_create_api_user(matches).await; } @@ -685,6 +765,9 @@ impl Cli { CliCommand::RemoveApiUserFromGroup => { self.execute_remove_api_user_from_group(matches).await; } + CliCommand::LinkProvider => { + self.execute_link_provider(matches).await; + } CliCommand::ListApiUserTokens => { self.execute_list_api_user_tokens(matches).await; } @@ -697,6 +780,9 @@ impl Cli { CliCommand::DeleteApiUserToken => { self.execute_delete_api_user_token(matches).await; } + CliCommand::CreateLinkToken => { + self.execute_create_link_token(matches).await; + } CliCommand::GithubWebhook => { self.execute_github_webhook(matches).await; } @@ -763,6 +849,28 @@ impl Cli { } } + pub async fn execute_jwks_json(&self, matches: &clap::ArgMatches) { + let mut request = self.client.jwks_json(); + self.over.execute_jwks_json(matches, &mut request).unwrap(); + let result = request.send().await; + match result { + Ok(r) => self.output.output_jwks_json(Ok(r.into_inner())), + Err(r) => self.output.output_jwks_json(Err(r)), + } + } + + pub async fn execute_openid_configuration(&self, matches: &clap::ArgMatches) { + let mut request = self.client.openid_configuration(); + self.over + .execute_openid_configuration(matches, &mut request) + .unwrap(); + let result = request.send().await; + match result { + Ok(r) => self.output.output_openid_configuration(Ok(r.into_inner())), + Err(r) => self.output.output_openid_configuration(Err(r)), + } + } + pub async fn execute_create_api_user(&self, matches: &clap::ArgMatches) { let mut request = self.client.create_api_user(); if let Some(value) = matches.get_one::("json-body") { @@ -867,6 +975,33 @@ impl Cli { } } + pub async fn execute_link_provider(&self, matches: &clap::ArgMatches) { + let mut request = self.client.link_provider(); + if let Some(value) = matches.get_one::("identifier") { + request = request.identifier(value.clone()); + } + + if let Some(value) = matches.get_one::("token") { + request = request.body_map(|body| body.token(value.clone())) + } + + if let Some(value) = matches.get_one::("json-body") { + let body_txt = std::fs::read_to_string(value).unwrap(); + let body_value = + serde_json::from_str::(&body_txt).unwrap(); + request = request.body(body_value); + } + + self.over + .execute_link_provider(matches, &mut request) + .unwrap(); + let result = request.send().await; + match result { + Ok(r) => self.output.output_link_provider(Ok(r.into_inner())), + Err(r) => self.output.output_link_provider(Err(r)), + } + } + pub async fn execute_list_api_user_tokens(&self, matches: &clap::ArgMatches) { let mut request = self.client.list_api_user_tokens(); if let Some(value) = matches.get_one::("identifier") { @@ -950,6 +1085,33 @@ impl Cli { } } + pub async fn execute_create_link_token(&self, matches: &clap::ArgMatches) { + let mut request = self.client.create_link_token(); + if let Some(value) = matches.get_one::("identifier") { + request = request.identifier(value.clone()); + } + + if let Some(value) = matches.get_one::("user-identifier") { + request = request.body_map(|body| body.user_identifier(value.clone())) + } + + if let Some(value) = matches.get_one::("json-body") { + let body_txt = std::fs::read_to_string(value).unwrap(); + let body_value = + serde_json::from_str::(&body_txt).unwrap(); + request = request.body(body_value); + } + + self.over + .execute_create_link_token(matches, &mut request) + .unwrap(); + let result = request.send().await; + match result { + Ok(r) => self.output.output_create_link_token(Ok(r.into_inner())), + Err(r) => self.output.output_create_link_token(Err(r)), + } + } + pub async fn execute_github_webhook(&self, matches: &clap::ArgMatches) { let mut request = self.client.github_webhook(); if let Some(value) = matches.get_one::("ref") { @@ -1404,6 +1566,22 @@ impl Cli { } pub trait CliOverride { + fn execute_jwks_json( + &self, + matches: &clap::ArgMatches, + request: &mut builder::JwksJson, + ) -> Result<(), String> { + Ok(()) + } + + fn execute_openid_configuration( + &self, + matches: &clap::ArgMatches, + request: &mut builder::OpenidConfiguration, + ) -> Result<(), String> { + Ok(()) + } + fn execute_create_api_user( &self, matches: &clap::ArgMatches, @@ -1444,6 +1622,14 @@ pub trait CliOverride { Ok(()) } + fn execute_link_provider( + &self, + matches: &clap::ArgMatches, + request: &mut builder::LinkProvider, + ) -> Result<(), String> { + Ok(()) + } + fn execute_list_api_user_tokens( &self, matches: &clap::ArgMatches, @@ -1476,6 +1662,14 @@ pub trait CliOverride { Ok(()) } + fn execute_create_link_token( + &self, + matches: &clap::ArgMatches, + request: &mut builder::CreateLinkToken, + ) -> Result<(), String> { + Ok(()) + } + fn execute_github_webhook( &self, matches: &clap::ArgMatches, @@ -1648,6 +1842,18 @@ pub trait CliOverride { impl CliOverride for () {} pub trait CliOutput { + fn output_jwks_json( + &self, + response: Result>, + ) { + } + + fn output_openid_configuration( + &self, + response: Result>, + ) { + } + fn output_create_api_user( &self, response: Result>, @@ -1656,7 +1862,7 @@ pub trait CliOutput { fn output_get_api_user( &self, - response: Result>, + response: Result>, ) { } @@ -1678,6 +1884,8 @@ pub trait CliOutput { ) { } + fn output_link_provider(&self, response: Result<(), progenitor_client::Error>) {} + fn output_list_api_user_tokens( &self, response: Result, progenitor_client::Error>, @@ -1702,6 +1910,12 @@ pub trait CliOutput { ) { } + fn output_create_link_token( + &self, + response: Result>, + ) { + } + fn output_github_webhook(&self, response: Result<(), progenitor_client::Error>) {} fn output_get_groups( @@ -1830,7 +2044,7 @@ pub trait CliOutput { fn output_get_self( &self, - response: Result>, + response: Result>, ) { } } @@ -1839,15 +2053,19 @@ impl CliOutput for () {} #[derive(Copy, Clone, Debug)] pub enum CliCommand { + JwksJson, + OpenidConfiguration, CreateApiUser, GetApiUser, UpdateApiUser, AddApiUserToGroup, RemoveApiUserFromGroup, + LinkProvider, ListApiUserTokens, CreateApiUserToken, GetApiUserToken, DeleteApiUserToken, + CreateLinkToken, GithubWebhook, GetGroups, CreateGroup, @@ -1874,15 +2092,19 @@ pub enum CliCommand { impl CliCommand { pub fn iter() -> impl Iterator { vec![ + CliCommand::JwksJson, + CliCommand::OpenidConfiguration, CliCommand::CreateApiUser, CliCommand::GetApiUser, CliCommand::UpdateApiUser, CliCommand::AddApiUserToGroup, CliCommand::RemoveApiUserFromGroup, + CliCommand::LinkProvider, CliCommand::ListApiUserTokens, CliCommand::CreateApiUserToken, CliCommand::GetApiUserToken, CliCommand::DeleteApiUserToken, + CliCommand::CreateLinkToken, CliCommand::GithubWebhook, CliCommand::GetGroups, CliCommand::CreateGroup, diff --git a/rfd-cli/src/main.rs b/rfd-cli/src/main.rs index f97231d..0bc1dd1 100644 --- a/rfd-cli/src/main.rs +++ b/rfd-cli/src/main.rs @@ -41,23 +41,27 @@ impl Context { }) } - pub fn client(&mut self) -> Result<&Client> { - if self.client.is_none() { - let mut default_headers = HeaderMap::new(); + pub fn new_client(token: Result<&str>, host: &str) -> Result { + let mut default_headers = HeaderMap::new(); - if let Ok(token) = self.config.token() { - let mut auth_header = - HeaderValue::from_str(&format!("Bearer {}", self.config.token()?))?; - auth_header.set_sensitive(true); - default_headers.insert(AUTHORIZATION, auth_header); - } + if let Ok(token) = token { + let mut auth_header = HeaderValue::from_str(&format!("Bearer {}", token))?; + auth_header.set_sensitive(true); + default_headers.insert(AUTHORIZATION, auth_header); + } + + let http_client = reqwest::Client::builder() + .default_headers(default_headers) + .connect_timeout(Duration::from_secs(5)) + .timeout(Duration::from_secs(10)) + .build()?; + + Ok(Client::new_with_client(host, http_client)) + } - let http_client = reqwest::Client::builder() - .default_headers(default_headers) - .connect_timeout(Duration::from_secs(5)) - .timeout(Duration::from_secs(10)) - .build()?; - self.client = Some(Client::new_with_client(self.config.host()?, http_client)); + pub fn client(&mut self) -> Result<&Client> { + if self.client.is_none() { + self.client = Some(Self::new_client(self.config.token(), self.config.host()?)?); } self.client @@ -106,6 +110,10 @@ fn cmd_path<'a>(cmd: &CliCommand) -> Option<&'a str> { CliCommand::ListApiUserTokens => Some("user token list"), CliCommand::UpdateApiUser => Some("user update"), + // Link commands are handled separately + CliCommand::CreateLinkToken => None, + CliCommand::LinkProvider => None, + // Group commands CliCommand::GetGroups => Some("group list"), CliCommand::CreateGroup => Some("group create"), @@ -134,6 +142,8 @@ fn cmd_path<'a>(cmd: &CliCommand) -> Option<&'a str> { CliCommand::AuthzCodeCallback => None, CliCommand::AuthzCodeExchange => None, CliCommand::GithubWebhook => None, + CliCommand::OpenidConfiguration => None, + CliCommand::JwksJson => None, } } @@ -190,6 +200,7 @@ async fn main() -> Result<(), Box> { ); cmd = cmd.subcommand(cmd::config::ConfigCmd::command()); + cmd = cmd.subcommand(auth::Link::command()); cmd = cmd.subcommand(auth::Login::command()); let mut ctx = Context::new()?; @@ -217,6 +228,12 @@ async fn main() -> Result<(), Box> { .run(&mut ctx) .await?; } + Some(("link", sub_matches)) => { + let _ = auth::Link::from_arg_matches(sub_matches) + .unwrap() + .run(&mut ctx) + .await?; + } Some(("login", sub_matches)) => { let _ = auth::Login::from_arg_matches(sub_matches) .unwrap() diff --git a/rfd-cli/src/printer/json.rs b/rfd-cli/src/printer/json.rs index b9f5f2a..48af885 100644 --- a/rfd-cli/src/printer/json.rs +++ b/rfd-cli/src/printer/json.rs @@ -25,7 +25,7 @@ impl CliOutput for RfdJsonPrinter { fn output_get_api_user( &self, - response: Result>, + response: Result>, ) { print_cli_output(&response) } @@ -224,7 +224,7 @@ impl CliOutput for RfdJsonPrinter { fn output_get_self( &self, - response: Result>, + response: Result>, ) { print_cli_output(&response) } diff --git a/rfd-cli/src/printer/mod.rs b/rfd-cli/src/printer/mod.rs index 01f40cf..ec3d3ac 100644 --- a/rfd-cli/src/printer/mod.rs +++ b/rfd-cli/src/printer/mod.rs @@ -25,7 +25,7 @@ impl CliOutput for Printer { fn output_get_api_user( &self, - response: Result>, + response: Result>, ) { match self { Printer::Json(printer) => printer.output_get_api_user(response), @@ -314,7 +314,7 @@ impl CliOutput for Printer { fn output_get_self( &self, - response: Result>, + response: Result>, ) { match self { Printer::Json(printer) => printer.output_get_self(response), diff --git a/rfd-cli/src/printer/tab.rs b/rfd-cli/src/printer/tab.rs index e8eb840..8e89ac9 100644 --- a/rfd-cli/src/printer/tab.rs +++ b/rfd-cli/src/printer/tab.rs @@ -1,5 +1,7 @@ use itertools::{EitherOrBoth, Itertools}; -use rfd_sdk::types::{AccessGroupForApiPermission, ApiUserForApiPermission, Error, ListRfd}; +use rfd_sdk::types::{ + AccessGroupForApiPermission, ApiUserForApiPermission, Error, GetApiUserResponse, ListRfd, +}; use std::{fs::File, io::Write, process::Command}; use tabwriter::TabWriter; @@ -121,7 +123,7 @@ impl CliOutput for RfdTabPrinter { fn output_get_self( &self, - response: Result>, + response: Result>, ) { match response { Ok(user) => print_user(&user), @@ -131,7 +133,7 @@ impl CliOutput for RfdTabPrinter { fn output_get_api_user( &self, - response: Result>, + response: Result>, ) { match response { Ok(user) => print_user(&user), @@ -219,7 +221,7 @@ fn print_error(error: progenitor_client::Error) { println!("{}", written); } -fn print_user(user: &ApiUserForApiPermission) { +fn print_user(user: &GetApiUserResponse) { let mut tw = TabWriter::new(vec![]).ansi(true); writeln!( @@ -233,7 +235,11 @@ fn print_user(user: &ApiUserForApiPermission) { HEADER_COLOR ); - let lines = user.permissions.iter().zip_longest(user.groups.iter()); + let lines = user + .info + .permissions + .iter() + .zip_longest(user.info.groups.iter()); for (i, line) in lines.enumerate() { let inner = match line { @@ -247,13 +253,13 @@ fn print_user(user: &ApiUserForApiPermission) { "{}{}\t{}\t{}", TEXT_COLOR, if i == 0 { - user.id.to_string() + user.info.id.to_string() } else { String::new() }, inner, if i == 0 { - user.created_at.to_string() + user.info.created_at.to_string() } else { String::new() }, diff --git a/rfd-model/migrations/2023-01-03-032421_api_user/down.sql b/rfd-model/migrations/2023-01-03-032421_api_user/down.sql index 89c425f..452354a 100644 --- a/rfd-model/migrations/2023-01-03-032421_api_user/down.sql +++ b/rfd-model/migrations/2023-01-03-032421_api_user/down.sql @@ -1,5 +1,5 @@ -DROP TABLE api_key; - DROP TABLE api_user_provider; +DROP TABLE api_key; + DROP TABLE api_user; diff --git a/rfd-model/migrations/2023-01-03-032421_api_user/up.sql b/rfd-model/migrations/2023-01-03-032421_api_user/up.sql index 1ea35e9..5dfaf70 100644 --- a/rfd-model/migrations/2023-01-03-032421_api_user/up.sql +++ b/rfd-model/migrations/2023-01-03-032421_api_user/up.sql @@ -20,8 +20,8 @@ CREATE TABLE api_key ( CREATE TABLE api_user_provider ( id UUID PRIMARY KEY, api_user_id UUID REFERENCES api_user (id) NOT NULL, - provider VARCHAR UNIQUE NOT NULL, - provider_id VARCHAR UNIQUE NOT NULL, + provider VARCHAR NOT NULL, + provider_id VARCHAR NOT NULL, emails TEXT[] NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), diff --git a/rfd-model/migrations/2023-10-06-181732_link_request/down.sql b/rfd-model/migrations/2023-10-06-181732_link_request/down.sql new file mode 100644 index 0000000..b048fa7 --- /dev/null +++ b/rfd-model/migrations/2023-10-06-181732_link_request/down.sql @@ -0,0 +1 @@ +DROP TABLE link_request; \ No newline at end of file diff --git a/rfd-model/migrations/2023-10-06-181732_link_request/up.sql b/rfd-model/migrations/2023-10-06-181732_link_request/up.sql new file mode 100644 index 0000000..0a5c4a4 --- /dev/null +++ b/rfd-model/migrations/2023-10-06-181732_link_request/up.sql @@ -0,0 +1,10 @@ +CREATE TABLE link_request ( + id UUID PRIMARY KEY, + source_provider_id UUID NOT NULL, + source_api_user_id UUID NOT NULL, + target_api_user_id UUID NOT NULL, + secret_signature VARCHAR NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + completed_at TIMESTAMPTZ +); \ No newline at end of file diff --git a/rfd-model/src/db.rs b/rfd-model/src/db.rs index d800a3e..15bb197 100644 --- a/rfd-model/src/db.rs +++ b/rfd-model/src/db.rs @@ -8,8 +8,8 @@ use crate::{ permissions::Permissions, schema::{ access_groups, api_key, api_user, api_user_access_token, api_user_provider, job, - login_attempt, mapper, oauth_client, oauth_client_redirect_uri, oauth_client_secret, rfd, - rfd_pdf, rfd_revision, + link_request, login_attempt, mapper, oauth_client, oauth_client_redirect_uri, + oauth_client_secret, rfd, rfd_pdf, rfd_revision, }, schema_ext::{ContentFormat, LoginAttemptState, PdfSource, Visibility}, }; @@ -192,3 +192,16 @@ pub struct MapperModel { pub created_at: DateTime, pub deleted_at: Option>, } + +#[derive(Debug, Deserialize, Serialize, Queryable, Insertable)] +#[diesel(table_name = link_request)] +pub struct LinkRequestModel { + pub id: Uuid, + pub source_provider_id: Uuid, + pub source_api_user_id: Uuid, + pub target_api_user_id: Uuid, + pub secret_signature: String, + pub created_at: DateTime, + pub expires_at: DateTime, + pub completed_at: Option>, +} diff --git a/rfd-model/src/lib.rs b/rfd-model/src/lib.rs index ce56ef9..c7a199b 100644 --- a/rfd-model/src/lib.rs +++ b/rfd-model/src/lib.rs @@ -5,8 +5,8 @@ use std::{ use chrono::{DateTime, Utc}; use db::{ - AccessGroupModel, JobModel, LoginAttemptModel, MapperModel, OAuthClientRedirectUriModel, - OAuthClientSecretModel, RfdModel, RfdPdfModel, RfdRevisionModel, + AccessGroupModel, JobModel, LinkRequestModel, LoginAttemptModel, MapperModel, + OAuthClientRedirectUriModel, OAuthClientSecretModel, RfdModel, RfdPdfModel, RfdRevisionModel, }; use partial_struct::partial; use permissions::Permissions; @@ -453,3 +453,32 @@ impl From for Mapper { } } } + +#[partial(NewLinkRequest)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct LinkRequest { + pub id: Uuid, + pub source_provider_id: Uuid, + pub source_api_user_id: Uuid, + pub target_api_user_id: Uuid, + pub secret_signature: String, + #[partial(NewLinkRequest(skip))] + pub created_at: DateTime, + pub expires_at: DateTime, + pub completed_at: Option>, +} + +impl From for LinkRequest { + fn from(value: LinkRequestModel) -> Self { + LinkRequest { + id: value.id, + source_provider_id: value.source_provider_id, + source_api_user_id: value.source_api_user_id, + target_api_user_id: value.target_api_user_id, + secret_signature: value.secret_signature, + created_at: value.created_at, + expires_at: value.expires_at, + completed_at: value.completed_at, + } + } +} diff --git a/rfd-model/src/schema.rs b/rfd-model/src/schema.rs index 4d03b1b..cdf00a4 100644 --- a/rfd-model/src/schema.rs +++ b/rfd-model/src/schema.rs @@ -91,6 +91,19 @@ diesel::table! { } } +diesel::table! { + link_request (id) { + id -> Uuid, + source_provider_id -> Uuid, + source_api_user_id -> Uuid, + target_api_user_id -> Uuid, + secret_signature -> Varchar, + created_at -> Timestamptz, + expires_at -> Timestamptz, + completed_at -> Nullable, + } +} + diesel::table! { use diesel::sql_types::*; use super::sql_types::AttemptState; @@ -224,6 +237,7 @@ diesel::allow_tables_to_appear_in_same_query!( api_user_access_token, api_user_provider, job, + link_request, login_attempt, mapper, oauth_client, diff --git a/rfd-model/src/storage/mod.rs b/rfd-model/src/storage/mod.rs index 44847e0..330793b 100644 --- a/rfd-model/src/storage/mod.rs +++ b/rfd-model/src/storage/mod.rs @@ -11,11 +11,11 @@ use uuid::Uuid; use crate::{ permissions::Permission, schema_ext::{LoginAttemptState, PdfSource}, - AccessGroup, AccessToken, ApiKey, ApiUser, ApiUserProvider, Job, LoginAttempt, Mapper, - NewAccessGroup, NewAccessToken, NewApiKey, NewApiUser, NewApiUserProvider, NewJob, - NewLoginAttempt, NewMapper, NewOAuthClient, NewOAuthClientRedirectUri, NewOAuthClientSecret, - NewRfd, NewRfdPdf, NewRfdRevision, OAuthClient, OAuthClientRedirectUri, OAuthClientSecret, Rfd, - RfdPdf, RfdRevision, + AccessGroup, AccessToken, ApiKey, ApiUser, ApiUserProvider, Job, LinkRequest, LoginAttempt, + Mapper, NewAccessGroup, NewAccessToken, NewApiKey, NewApiUser, NewApiUserProvider, NewJob, + NewLinkRequest, NewLoginAttempt, NewMapper, NewOAuthClient, NewOAuthClientRedirectUri, + NewOAuthClientSecret, NewRfd, NewRfdPdf, NewRfdRevision, OAuthClient, OAuthClientRedirectUri, + OAuthClientSecret, Rfd, RfdPdf, RfdRevision, }; pub mod postgres; @@ -304,6 +304,11 @@ pub trait ApiUserProviderStore { pagination: &ListPagination, ) -> Result, StoreError>; async fn upsert(&self, api_user: NewApiUserProvider) -> Result; + async fn transfer( + &self, + api_user: NewApiUserProvider, + current_api_user_id: Uuid, + ) -> Result; async fn delete(&self, id: &Uuid) -> Result, StoreError>; } @@ -427,3 +432,29 @@ pub trait MapperStore { async fn upsert(&self, new_mapper: &NewMapper) -> Result; async fn delete(&self, id: &Uuid) -> Result, StoreError>; } + +#[derive(Debug, Default, PartialEq)] +pub struct LinkRequestFilter { + pub id: Option>, + pub provider_id: Option>, + pub user_id: Option>, + pub expired: bool, + pub completed: bool, +} + +#[cfg_attr(feature = "mock", automock)] +#[async_trait] +pub trait LinkRequestStore { + async fn get( + &self, + id: &Uuid, + expired: bool, + completed: bool, + ) -> Result, StoreError>; + async fn list( + &self, + filter: LinkRequestFilter, + pagination: &ListPagination, + ) -> Result, StoreError>; + async fn upsert(&self, request: &NewLinkRequest) -> Result; +} diff --git a/rfd-model/src/storage/postgres.rs b/rfd-model/src/storage/postgres.rs index 3ba1a9a..22e784e 100644 --- a/rfd-model/src/storage/postgres.rs +++ b/rfd-model/src/storage/postgres.rs @@ -20,21 +20,22 @@ use uuid::Uuid; use crate::{ db::{ AccessGroupModel, ApiKeyModel, ApiUserAccessTokenModel, ApiUserModel, ApiUserProviderModel, - JobModel, LoginAttemptModel, MapperModel, OAuthClientModel, OAuthClientRedirectUriModel, - OAuthClientSecretModel, RfdModel, RfdPdfModel, RfdRevisionModel, + JobModel, LinkRequestModel, LoginAttemptModel, MapperModel, OAuthClientModel, + OAuthClientRedirectUriModel, OAuthClientSecretModel, RfdModel, RfdPdfModel, + RfdRevisionModel, }, permissions::{Permission, Permissions}, schema::{ access_groups, api_key, api_user, api_user_access_token, api_user_provider, job, - login_attempt, mapper, oauth_client, oauth_client_redirect_uri, oauth_client_secret, rfd, - rfd_pdf, rfd_revision, + link_request, login_attempt, mapper, oauth_client, oauth_client_redirect_uri, + oauth_client_secret, rfd, rfd_pdf, rfd_revision, }, - storage::StoreError, - AccessGroup, AccessToken, ApiKey, ApiUser, ApiUserProvider, Job, LoginAttempt, Mapper, - NewAccessGroup, NewAccessToken, NewApiKey, NewApiUser, NewApiUserProvider, NewJob, - NewLoginAttempt, NewMapper, NewOAuthClient, NewOAuthClientRedirectUri, NewOAuthClientSecret, - NewRfd, NewRfdPdf, NewRfdRevision, OAuthClient, OAuthClientRedirectUri, OAuthClientSecret, Rfd, - RfdPdf, RfdRevision, + storage::{LinkRequestFilter, LinkRequestStore, StoreError}, + AccessGroup, AccessToken, ApiKey, ApiUser, ApiUserProvider, Job, LinkRequest, LoginAttempt, + Mapper, NewAccessGroup, NewAccessToken, NewApiKey, NewApiUser, NewApiUserProvider, NewJob, + NewLinkRequest, NewLoginAttempt, NewMapper, NewOAuthClient, NewOAuthClientRedirectUri, + NewOAuthClientSecret, NewRfd, NewRfdPdf, NewRfdRevision, OAuthClient, OAuthClientRedirectUri, + OAuthClientSecret, Rfd, RfdPdf, RfdRevision, }; use super::{ @@ -813,7 +814,7 @@ impl ApiUserProviderStore for PostgresStore { } async fn upsert(&self, provider: NewApiUserProvider) -> Result { - tracing::info!(id = ?provider.id, api_user_id = ?provider.api_user_id, provider = ?provider, "Upserting user provider"); + tracing::trace!(id = ?provider.id, api_user_id = ?provider.api_user_id, provider = ?provider, "Inserting user provider"); let provider_m: ApiUserProviderModel = insert_into(api_user_provider::dsl::api_user_provider) @@ -827,7 +828,7 @@ impl ApiUserProviderStore for PostgresStore { .on_conflict(api_user_provider::id) .do_update() .set(( - api_user_provider::api_user_id.eq(excluded(api_user_provider::api_user_id)), + api_user_provider::emails.eq(excluded(api_user_provider::emails)), api_user_provider::updated_at.eq(Utc::now()), )) .get_result_async(&self.conn) @@ -845,6 +846,35 @@ impl ApiUserProviderStore for PostgresStore { }) } + async fn transfer( + &self, + provider: NewApiUserProvider, + current_api_user_id: Uuid, + ) -> Result { + tracing::trace!(id = ?provider.id, api_user_id = ?provider.api_user_id, provider = ?provider, "Updating user provider"); + + let provider_m: ApiUserProviderModel = update(api_user_provider::dsl::api_user_provider) + .set(( + api_user_provider::api_user_id.eq(provider.api_user_id), + api_user_provider::updated_at.eq(Utc::now()), + )) + .filter(api_user_provider::id.eq(provider.id)) + .filter(api_user_provider::api_user_id.eq(current_api_user_id)) + .get_result_async(&self.conn) + .await?; + + Ok(ApiUserProvider { + id: provider_m.id, + api_user_id: provider_m.api_user_id, + provider: provider_m.provider, + provider_id: provider_m.provider_id, + emails: provider_m.emails.into_iter().filter_map(|e| e).collect(), + created_at: provider_m.created_at, + updated_at: provider_m.updated_at, + deleted_at: provider_m.deleted_at, + }) + } + async fn delete(&self, id: &Uuid) -> Result, StoreError> { let _ = update(api_user::dsl::api_user) .filter(api_user::id.eq(*id)) @@ -1424,3 +1454,103 @@ impl MapperStore for PostgresStore { MapperStore::get(self, id, false, true).await } } + +#[async_trait] +impl LinkRequestStore for PostgresStore { + #[instrument(skip(self), err(Debug))] + async fn get( + &self, + id: &Uuid, + expired: bool, + completed: bool, + ) -> Result, StoreError> { + tracing::trace!("Get link request"); + + let client = LinkRequestStore::list( + self, + LinkRequestFilter { + id: Some(vec![*id]), + provider_id: None, + user_id: None, + expired, + completed, + }, + &ListPagination::default().limit(1), + ) + .await?; + + Ok(client.into_iter().nth(0)) + } + + #[instrument(skip(self), err(Debug))] + async fn list( + &self, + filter: LinkRequestFilter, + pagination: &ListPagination, + ) -> Result, StoreError> { + tracing::trace!("Listing link requests"); + + let mut query = link_request::dsl::link_request.into_boxed(); + + let LinkRequestFilter { + id, + provider_id, + user_id, + expired, + completed, + } = filter; + + if let Some(id) = id { + query = query.filter(link_request::id.eq_any(id)); + } + + if let Some(provider_id) = provider_id { + query = query.filter(link_request::source_provider_id.eq_any(provider_id)); + } + + if let Some(user_id) = user_id { + query = query.filter(link_request::target_api_user_id.eq_any(user_id)); + } + + if !expired { + query = query.filter(link_request::expires_at.gt(Utc::now())); + } + + if !completed { + query = query.filter(link_request::completed_at.is_null()); + } + + let results = query + .offset(pagination.offset) + .limit(pagination.limit) + .order(link_request::created_at.desc()) + .get_results_async::(&self.conn) + .await?; + + Ok(results.into_iter().map(|model| model.into()).collect()) + } + + #[instrument(skip(self), err(Debug))] + async fn upsert(&self, request: &NewLinkRequest) -> Result { + tracing::trace!("Upserting link request"); + + let link_request_m: LinkRequestModel = insert_into(link_request::dsl::link_request) + .values(( + link_request::id.eq(request.id), + link_request::source_provider_id.eq(request.source_provider_id), + link_request::source_api_user_id.eq(request.source_api_user_id), + link_request::target_api_user_id.eq(request.target_api_user_id), + link_request::secret_signature.eq(request.secret_signature.clone()), + link_request::created_at.eq(Utc::now()), + link_request::expires_at.eq(request.expires_at), + link_request::completed_at.eq(request.completed_at), + )) + .on_conflict(link_request::id) + .do_update() + .set((link_request::completed_at.eq(excluded(link_request::completed_at)),)) + .get_result_async(&self.conn) + .await?; + + Ok(link_request_m.into()) + } +} diff --git a/rfd-sdk/src/generated/sdk.rs b/rfd-sdk/src/generated/sdk.rs index 84ef999..53171e0 100644 --- a/rfd-sdk/src/generated/sdk.rs +++ b/rfd-sdk/src/generated/sdk.rs @@ -159,8 +159,13 @@ pub mod types { UpdateApiUserSelf, UpdateApiUserAssigned, UpdateApiUserAll, + CreateUserApiProviderLinkToken, ListGroups, CreateGroup, + ManageGroupMembershipAssigned, + ManageGroupMembershipAll, + ManageGroupsAssigned, + ManageGroupsAll, GetRfdsAssigned, GetRfdsAll, GetDiscussionsAssigned, @@ -181,7 +186,11 @@ pub mod types { UpdateGroup(uuid::Uuid), AddToGroup(uuid::Uuid), RemoveFromGroup(uuid::Uuid), + ManageGroupMembership(uuid::Uuid), + ManageGroupMemberships(Vec), DeleteGroup(uuid::Uuid), + ManageGroup(uuid::Uuid), + ManageGroups(Vec), GetRfd(i32), GetRfds(Vec), GetDiscussion(i32), @@ -223,6 +232,82 @@ pub mod types { } } + #[derive(Clone, Debug, Deserialize, Serialize, schemars :: JsonSchema)] + pub struct ApiUserLinkRequestPayload { + pub user_identifier: uuid::Uuid, + } + + impl From<&ApiUserLinkRequestPayload> for ApiUserLinkRequestPayload { + fn from(value: &ApiUserLinkRequestPayload) -> Self { + value.clone() + } + } + + impl ApiUserLinkRequestPayload { + pub fn builder() -> builder::ApiUserLinkRequestPayload { + builder::ApiUserLinkRequestPayload::default() + } + } + + #[derive(Clone, Debug, Deserialize, Serialize, schemars :: JsonSchema)] + pub struct ApiUserLinkRequestResponse { + pub token: String, + } + + impl From<&ApiUserLinkRequestResponse> for ApiUserLinkRequestResponse { + fn from(value: &ApiUserLinkRequestResponse) -> Self { + value.clone() + } + } + + impl ApiUserLinkRequestResponse { + pub fn builder() -> builder::ApiUserLinkRequestResponse { + builder::ApiUserLinkRequestResponse::default() + } + } + + #[derive(Clone, Debug, Deserialize, Serialize, schemars :: JsonSchema)] + pub struct ApiUserProvider { + pub api_user_id: uuid::Uuid, + pub created_at: chrono::DateTime, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub deleted_at: Option>, + pub emails: Vec, + pub id: uuid::Uuid, + pub provider: String, + pub provider_id: String, + pub updated_at: chrono::DateTime, + } + + impl From<&ApiUserProvider> for ApiUserProvider { + fn from(value: &ApiUserProvider) -> Self { + value.clone() + } + } + + impl ApiUserProvider { + pub fn builder() -> builder::ApiUserProvider { + builder::ApiUserProvider::default() + } + } + + #[derive(Clone, Debug, Deserialize, Serialize, schemars :: JsonSchema)] + pub struct ApiUserProviderLinkPayload { + pub token: String, + } + + impl From<&ApiUserProviderLinkPayload> for ApiUserProviderLinkPayload { + fn from(value: &ApiUserProviderLinkPayload) -> Self { + value.clone() + } + } + + impl ApiUserProviderLinkPayload { + pub fn builder() -> builder::ApiUserProviderLinkPayload { + builder::ApiUserProviderLinkPayload::default() + } + } + #[derive(Clone, Debug, Deserialize, Serialize, schemars :: JsonSchema)] pub struct ApiUserUpdateParams { pub groups: Vec, @@ -313,6 +398,24 @@ pub mod types { } } + #[derive(Clone, Debug, Deserialize, Serialize, schemars :: JsonSchema)] + pub struct GetApiUserResponse { + pub info: ApiUserForApiPermission, + pub providers: Vec, + } + + impl From<&GetApiUserResponse> for GetApiUserResponse { + fn from(value: &GetApiUserResponse) -> Self { + value.clone() + } + } + + impl GetApiUserResponse { + pub fn builder() -> builder::GetApiUserResponse { + builder::GetApiUserResponse::default() + } + } + #[derive(Clone, Debug, Deserialize, Serialize, schemars :: JsonSchema)] pub struct GitHubCommit { pub added: Vec, @@ -472,6 +575,45 @@ pub mod types { } } + #[derive(Clone, Debug, Deserialize, Serialize, schemars :: JsonSchema)] + pub struct Jwk { + pub e: String, + pub kid: String, + pub kty: String, + pub n: String, + #[serde(rename = "use")] + pub use_: String, + } + + impl From<&Jwk> for Jwk { + fn from(value: &Jwk) -> Self { + value.clone() + } + } + + impl Jwk { + pub fn builder() -> builder::Jwk { + builder::Jwk::default() + } + } + + #[derive(Clone, Debug, Deserialize, Serialize, schemars :: JsonSchema)] + pub struct Jwks { + pub keys: Vec, + } + + impl From<&Jwks> for Jwks { + fn from(value: &Jwks) -> Self { + value.clone() + } + } + + impl Jwks { + pub fn builder() -> builder::Jwks { + builder::Jwks::default() + } + } + #[derive(Clone, Debug, Deserialize, Serialize, schemars :: JsonSchema)] pub struct ListRfd { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -700,6 +842,23 @@ pub mod types { } } + #[derive(Clone, Debug, Deserialize, Serialize, schemars :: JsonSchema)] + pub struct OpenIdConfiguration { + pub jwks_uri: String, + } + + impl From<&OpenIdConfiguration> for OpenIdConfiguration { + fn from(value: &OpenIdConfiguration) -> Self { + value.clone() + } + } + + impl OpenIdConfiguration { + pub fn builder() -> builder::OpenIdConfiguration { + builder::OpenIdConfiguration::default() + } + } + #[derive(Clone, Debug, Deserialize, Serialize, schemars :: JsonSchema)] pub struct PermissionsForApiPermission(pub Vec); impl std::ops::Deref for PermissionsForApiPermission { @@ -1363,6 +1522,276 @@ pub mod types { } } + #[derive(Clone, Debug)] + pub struct ApiUserLinkRequestPayload { + user_identifier: Result, + } + + impl Default for ApiUserLinkRequestPayload { + fn default() -> Self { + Self { + user_identifier: Err("no value supplied for user_identifier".to_string()), + } + } + } + + impl ApiUserLinkRequestPayload { + pub fn user_identifier(mut self, value: T) -> Self + where + T: std::convert::TryInto, + T::Error: std::fmt::Display, + { + self.user_identifier = value.try_into().map_err(|e| { + format!("error converting supplied value for user_identifier: {}", e) + }); + self + } + } + + impl std::convert::TryFrom for super::ApiUserLinkRequestPayload { + type Error = String; + fn try_from(value: ApiUserLinkRequestPayload) -> Result { + Ok(Self { + user_identifier: value.user_identifier?, + }) + } + } + + impl From for ApiUserLinkRequestPayload { + fn from(value: super::ApiUserLinkRequestPayload) -> Self { + Self { + user_identifier: Ok(value.user_identifier), + } + } + } + + #[derive(Clone, Debug)] + pub struct ApiUserLinkRequestResponse { + token: Result, + } + + impl Default for ApiUserLinkRequestResponse { + fn default() -> Self { + Self { + token: Err("no value supplied for token".to_string()), + } + } + } + + impl ApiUserLinkRequestResponse { + pub fn token(mut self, value: T) -> Self + where + T: std::convert::TryInto, + T::Error: std::fmt::Display, + { + self.token = value + .try_into() + .map_err(|e| format!("error converting supplied value for token: {}", e)); + self + } + } + + impl std::convert::TryFrom for super::ApiUserLinkRequestResponse { + type Error = String; + fn try_from(value: ApiUserLinkRequestResponse) -> Result { + Ok(Self { + token: value.token?, + }) + } + } + + impl From for ApiUserLinkRequestResponse { + fn from(value: super::ApiUserLinkRequestResponse) -> Self { + Self { + token: Ok(value.token), + } + } + } + + #[derive(Clone, Debug)] + pub struct ApiUserProvider { + api_user_id: Result, + created_at: Result, String>, + deleted_at: Result>, String>, + emails: Result, String>, + id: Result, + provider: Result, + provider_id: Result, + updated_at: Result, String>, + } + + impl Default for ApiUserProvider { + fn default() -> Self { + Self { + api_user_id: Err("no value supplied for api_user_id".to_string()), + created_at: Err("no value supplied for created_at".to_string()), + deleted_at: Ok(Default::default()), + emails: Err("no value supplied for emails".to_string()), + id: Err("no value supplied for id".to_string()), + provider: Err("no value supplied for provider".to_string()), + provider_id: Err("no value supplied for provider_id".to_string()), + updated_at: Err("no value supplied for updated_at".to_string()), + } + } + } + + impl ApiUserProvider { + pub fn api_user_id(mut self, value: T) -> Self + where + T: std::convert::TryInto, + T::Error: std::fmt::Display, + { + self.api_user_id = value + .try_into() + .map_err(|e| format!("error converting supplied value for api_user_id: {}", e)); + self + } + pub fn created_at(mut self, value: T) -> Self + where + T: std::convert::TryInto>, + T::Error: std::fmt::Display, + { + self.created_at = value + .try_into() + .map_err(|e| format!("error converting supplied value for created_at: {}", e)); + self + } + pub fn deleted_at(mut self, value: T) -> Self + where + T: std::convert::TryInto>>, + T::Error: std::fmt::Display, + { + self.deleted_at = value + .try_into() + .map_err(|e| format!("error converting supplied value for deleted_at: {}", e)); + self + } + pub fn emails(mut self, value: T) -> Self + where + T: std::convert::TryInto>, + T::Error: std::fmt::Display, + { + self.emails = value + .try_into() + .map_err(|e| format!("error converting supplied value for emails: {}", e)); + self + } + pub fn id(mut self, value: T) -> Self + where + T: std::convert::TryInto, + T::Error: std::fmt::Display, + { + self.id = value + .try_into() + .map_err(|e| format!("error converting supplied value for id: {}", e)); + self + } + pub fn provider(mut self, value: T) -> Self + where + T: std::convert::TryInto, + T::Error: std::fmt::Display, + { + self.provider = value + .try_into() + .map_err(|e| format!("error converting supplied value for provider: {}", e)); + self + } + pub fn provider_id(mut self, value: T) -> Self + where + T: std::convert::TryInto, + T::Error: std::fmt::Display, + { + self.provider_id = value + .try_into() + .map_err(|e| format!("error converting supplied value for provider_id: {}", e)); + self + } + pub fn updated_at(mut self, value: T) -> Self + where + T: std::convert::TryInto>, + T::Error: std::fmt::Display, + { + self.updated_at = value + .try_into() + .map_err(|e| format!("error converting supplied value for updated_at: {}", e)); + self + } + } + + impl std::convert::TryFrom for super::ApiUserProvider { + type Error = String; + fn try_from(value: ApiUserProvider) -> Result { + Ok(Self { + api_user_id: value.api_user_id?, + created_at: value.created_at?, + deleted_at: value.deleted_at?, + emails: value.emails?, + id: value.id?, + provider: value.provider?, + provider_id: value.provider_id?, + updated_at: value.updated_at?, + }) + } + } + + impl From for ApiUserProvider { + fn from(value: super::ApiUserProvider) -> Self { + Self { + api_user_id: Ok(value.api_user_id), + created_at: Ok(value.created_at), + deleted_at: Ok(value.deleted_at), + emails: Ok(value.emails), + id: Ok(value.id), + provider: Ok(value.provider), + provider_id: Ok(value.provider_id), + updated_at: Ok(value.updated_at), + } + } + } + + #[derive(Clone, Debug)] + pub struct ApiUserProviderLinkPayload { + token: Result, + } + + impl Default for ApiUserProviderLinkPayload { + fn default() -> Self { + Self { + token: Err("no value supplied for token".to_string()), + } + } + } + + impl ApiUserProviderLinkPayload { + pub fn token(mut self, value: T) -> Self + where + T: std::convert::TryInto, + T::Error: std::fmt::Display, + { + self.token = value + .try_into() + .map_err(|e| format!("error converting supplied value for token: {}", e)); + self + } + } + + impl std::convert::TryFrom for super::ApiUserProviderLinkPayload { + type Error = String; + fn try_from(value: ApiUserProviderLinkPayload) -> Result { + Ok(Self { + token: value.token?, + }) + } + } + + impl From for ApiUserProviderLinkPayload { + fn from(value: super::ApiUserProviderLinkPayload) -> Self { + Self { + token: Ok(value.token), + } + } + } + #[derive(Clone, Debug)] pub struct ApiUserUpdateParams { groups: Result, String>, @@ -1759,6 +2188,63 @@ pub mod types { } } + #[derive(Clone, Debug)] + pub struct GetApiUserResponse { + info: Result, + providers: Result, String>, + } + + impl Default for GetApiUserResponse { + fn default() -> Self { + Self { + info: Err("no value supplied for info".to_string()), + providers: Err("no value supplied for providers".to_string()), + } + } + } + + impl GetApiUserResponse { + pub fn info(mut self, value: T) -> Self + where + T: std::convert::TryInto, + T::Error: std::fmt::Display, + { + self.info = value + .try_into() + .map_err(|e| format!("error converting supplied value for info: {}", e)); + self + } + pub fn providers(mut self, value: T) -> Self + where + T: std::convert::TryInto>, + T::Error: std::fmt::Display, + { + self.providers = value + .try_into() + .map_err(|e| format!("error converting supplied value for providers: {}", e)); + self + } + } + + impl std::convert::TryFrom for super::GetApiUserResponse { + type Error = String; + fn try_from(value: GetApiUserResponse) -> Result { + Ok(Self { + info: value.info?, + providers: value.providers?, + }) + } + } + + impl From for GetApiUserResponse { + fn from(value: super::GetApiUserResponse) -> Self { + Self { + info: Ok(value.info), + providers: Ok(value.providers), + } + } + } + #[derive(Clone, Debug)] pub struct GitHubCommit { added: Result, String>, @@ -2413,6 +2899,146 @@ pub mod types { } } + #[derive(Clone, Debug)] + pub struct Jwk { + e: Result, + kid: Result, + kty: Result, + n: Result, + use_: Result, + } + + impl Default for Jwk { + fn default() -> Self { + Self { + e: Err("no value supplied for e".to_string()), + kid: Err("no value supplied for kid".to_string()), + kty: Err("no value supplied for kty".to_string()), + n: Err("no value supplied for n".to_string()), + use_: Err("no value supplied for use_".to_string()), + } + } + } + + impl Jwk { + pub fn e(mut self, value: T) -> Self + where + T: std::convert::TryInto, + T::Error: std::fmt::Display, + { + self.e = value + .try_into() + .map_err(|e| format!("error converting supplied value for e: {}", e)); + self + } + pub fn kid(mut self, value: T) -> Self + where + T: std::convert::TryInto, + T::Error: std::fmt::Display, + { + self.kid = value + .try_into() + .map_err(|e| format!("error converting supplied value for kid: {}", e)); + self + } + pub fn kty(mut self, value: T) -> Self + where + T: std::convert::TryInto, + T::Error: std::fmt::Display, + { + self.kty = value + .try_into() + .map_err(|e| format!("error converting supplied value for kty: {}", e)); + self + } + pub fn n(mut self, value: T) -> Self + where + T: std::convert::TryInto, + T::Error: std::fmt::Display, + { + self.n = value + .try_into() + .map_err(|e| format!("error converting supplied value for n: {}", e)); + self + } + pub fn use_(mut self, value: T) -> Self + where + T: std::convert::TryInto, + T::Error: std::fmt::Display, + { + self.use_ = value + .try_into() + .map_err(|e| format!("error converting supplied value for use_: {}", e)); + self + } + } + + impl std::convert::TryFrom for super::Jwk { + type Error = String; + fn try_from(value: Jwk) -> Result { + Ok(Self { + e: value.e?, + kid: value.kid?, + kty: value.kty?, + n: value.n?, + use_: value.use_?, + }) + } + } + + impl From for Jwk { + fn from(value: super::Jwk) -> Self { + Self { + e: Ok(value.e), + kid: Ok(value.kid), + kty: Ok(value.kty), + n: Ok(value.n), + use_: Ok(value.use_), + } + } + } + + #[derive(Clone, Debug)] + pub struct Jwks { + keys: Result, String>, + } + + impl Default for Jwks { + fn default() -> Self { + Self { + keys: Err("no value supplied for keys".to_string()), + } + } + } + + impl Jwks { + pub fn keys(mut self, value: T) -> Self + where + T: std::convert::TryInto>, + T::Error: std::fmt::Display, + { + self.keys = value + .try_into() + .map_err(|e| format!("error converting supplied value for keys: {}", e)); + self + } + } + + impl std::convert::TryFrom for super::Jwks { + type Error = String; + fn try_from(value: Jwks) -> Result { + Ok(Self { keys: value.keys? }) + } + } + + impl From for Jwks { + fn from(value: super::Jwks) -> Self { + Self { + keys: Ok(value.keys), + } + } + } + #[derive(Clone, Debug)] pub struct ListRfd { authors: Result, String>, @@ -3202,6 +3828,49 @@ pub mod types { } } } + + #[derive(Clone, Debug)] + pub struct OpenIdConfiguration { + jwks_uri: Result, + } + + impl Default for OpenIdConfiguration { + fn default() -> Self { + Self { + jwks_uri: Err("no value supplied for jwks_uri".to_string()), + } + } + } + + impl OpenIdConfiguration { + pub fn jwks_uri(mut self, value: T) -> Self + where + T: std::convert::TryInto, + T::Error: std::fmt::Display, + { + self.jwks_uri = value + .try_into() + .map_err(|e| format!("error converting supplied value for jwks_uri: {}", e)); + self + } + } + + impl std::convert::TryFrom for super::OpenIdConfiguration { + type Error = String; + fn try_from(value: OpenIdConfiguration) -> Result { + Ok(Self { + jwks_uri: value.jwks_uri?, + }) + } + } + + impl From for OpenIdConfiguration { + fn from(value: super::OpenIdConfiguration) -> Self { + Self { + jwks_uri: Ok(value.jwks_uri), + } + } + } } } @@ -3268,6 +3937,28 @@ impl Client { } impl Client { + /// Sends a `GET` request to `/.well-known/jwks.json` + /// + /// ```ignore + /// let response = client.jwks_json() + /// .send() + /// .await; + /// ``` + pub fn jwks_json(&self) -> builder::JwksJson { + builder::JwksJson::new(self) + } + + /// Sends a `GET` request to `/.well-known/openid-configuration` + /// + /// ```ignore + /// let response = client.openid_configuration() + /// .send() + /// .await; + /// ``` + pub fn openid_configuration(&self) -> builder::OpenidConfiguration { + builder::OpenidConfiguration::new(self) + } + /// Create a new user with a given set of permissions /// /// Sends a `POST` request to `/api-user` @@ -3337,6 +4028,21 @@ impl Client { builder::RemoveApiUserFromGroup::new(self) } + /// Link an existing login provider to this user + /// + /// Sends a `POST` request to `/api-user/{identifier}/link` + /// + /// ```ignore + /// let response = client.link_provider() + /// .identifier(identifier) + /// .body(body) + /// .send() + /// .await; + /// ``` + pub fn link_provider(&self) -> builder::LinkProvider { + builder::LinkProvider::new(self) + } + /// List the active and expired API tokens for a given user /// /// Sends a `GET` request to `/api-user/{identifier}/token` @@ -3392,6 +4098,22 @@ impl Client { builder::DeleteApiUserToken::new(self) } + /// Create a new link token for linking this provider to a different api + /// user + /// + /// Sends a `POST` request to `/api-user-provider/{identifier}/link-token` + /// + /// ```ignore + /// let response = client.create_link_token() + /// .identifier(identifier) + /// .body(body) + /// .send() + /// .await; + /// ``` + pub fn create_link_token(&self) -> builder::CreateLinkToken { + builder::CreateLinkToken::new(self) + } + /// Sends a `GET` request to `/group` /// /// ```ignore @@ -3696,6 +4418,88 @@ pub mod builder { use super::{ encode_path, ByteStream, Error, HeaderMap, HeaderValue, RequestBuilderExt, ResponseValue, }; + /// Builder for [`Client::jwks_json`] + /// + /// [`Client::jwks_json`]: super::Client::jwks_json + #[derive(Debug, Clone)] + pub struct JwksJson<'a> { + client: &'a super::Client, + } + + impl<'a> JwksJson<'a> { + pub fn new(client: &'a super::Client) -> Self { + Self { client } + } + + /// Sends a `GET` request to `/.well-known/jwks.json` + pub async fn send(self) -> Result, Error> { + let Self { client } = self; + let url = format!("{}/.well-known/jwks.json", client.baseurl,); + let request = client + .client + .get(url) + .header( + reqwest::header::ACCEPT, + reqwest::header::HeaderValue::from_static("application/json"), + ) + .build()?; + let result = client.client.execute(request).await; + let response = result?; + match response.status().as_u16() { + 200u16 => ResponseValue::from_response(response).await, + 400u16..=499u16 => Err(Error::ErrorResponse( + ResponseValue::from_response(response).await?, + )), + 500u16..=599u16 => Err(Error::ErrorResponse( + ResponseValue::from_response(response).await?, + )), + _ => Err(Error::UnexpectedResponse(response)), + } + } + } + + /// Builder for [`Client::openid_configuration`] + /// + /// [`Client::openid_configuration`]: super::Client::openid_configuration + #[derive(Debug, Clone)] + pub struct OpenidConfiguration<'a> { + client: &'a super::Client, + } + + impl<'a> OpenidConfiguration<'a> { + pub fn new(client: &'a super::Client) -> Self { + Self { client } + } + + /// Sends a `GET` request to `/.well-known/openid-configuration` + pub async fn send( + self, + ) -> Result, Error> { + let Self { client } = self; + let url = format!("{}/.well-known/openid-configuration", client.baseurl,); + let request = client + .client + .get(url) + .header( + reqwest::header::ACCEPT, + reqwest::header::HeaderValue::from_static("application/json"), + ) + .build()?; + let result = client.client.execute(request).await; + let response = result?; + match response.status().as_u16() { + 200u16 => ResponseValue::from_response(response).await, + 400u16..=499u16 => Err(Error::ErrorResponse( + ResponseValue::from_response(response).await?, + )), + 500u16..=599u16 => Err(Error::ErrorResponse( + ResponseValue::from_response(response).await?, + )), + _ => Err(Error::UnexpectedResponse(response)), + } + } + } + /// Builder for [`Client::create_api_user`] /// /// [`Client::create_api_user`]: super::Client::create_api_user @@ -3797,7 +4601,7 @@ pub mod builder { /// Sends a `GET` request to `/api-user/{identifier}` pub async fn send( self, - ) -> Result, Error> { + ) -> Result, Error> { let Self { client, identifier } = self; let identifier = identifier.map_err(Error::InvalidRequest)?; let url = format!( @@ -4090,6 +4894,95 @@ pub mod builder { } } + /// Builder for [`Client::link_provider`] + /// + /// [`Client::link_provider`]: super::Client::link_provider + #[derive(Debug, Clone)] + pub struct LinkProvider<'a> { + client: &'a super::Client, + identifier: Result, + body: Result, + } + + impl<'a> LinkProvider<'a> { + pub fn new(client: &'a super::Client) -> Self { + Self { + client, + identifier: Err("identifier was not initialized".to_string()), + body: Ok(types::builder::ApiUserProviderLinkPayload::default()), + } + } + + pub fn identifier(mut self, value: V) -> Self + where + V: std::convert::TryInto, + { + self.identifier = value + .try_into() + .map_err(|_| "conversion to `uuid :: Uuid` for identifier failed".to_string()); + self + } + + pub fn body(mut self, value: V) -> Self + where + V: std::convert::TryInto, + { + self.body = value.try_into().map(From::from).map_err(|_| { + "conversion to `ApiUserProviderLinkPayload` for body failed".to_string() + }); + self + } + + pub fn body_map(mut self, f: F) -> Self + where + F: std::ops::FnOnce( + types::builder::ApiUserProviderLinkPayload, + ) -> types::builder::ApiUserProviderLinkPayload, + { + self.body = self.body.map(f); + self + } + + /// Sends a `POST` request to `/api-user/{identifier}/link` + pub async fn send(self) -> Result, Error> { + let Self { + client, + identifier, + body, + } = self; + let identifier = identifier.map_err(Error::InvalidRequest)?; + let body = body + .and_then(std::convert::TryInto::::try_into) + .map_err(Error::InvalidRequest)?; + let url = format!( + "{}/api-user/{}/link", + client.baseurl, + encode_path(&identifier.to_string()), + ); + let request = client + .client + .post(url) + .header( + reqwest::header::ACCEPT, + reqwest::header::HeaderValue::from_static("application/json"), + ) + .json(&body) + .build()?; + let result = client.client.execute(request).await; + let response = result?; + match response.status().as_u16() { + 204u16 => Ok(ResponseValue::empty(response)), + 400u16..=499u16 => Err(Error::ErrorResponse( + ResponseValue::from_response(response).await?, + )), + 500u16..=599u16 => Err(Error::ErrorResponse( + ResponseValue::from_response(response).await?, + )), + _ => Err(Error::UnexpectedResponse(response)), + } + } + } + /// Builder for [`Client::list_api_user_tokens`] /// /// [`Client::list_api_user_tokens`]: super::Client::list_api_user_tokens @@ -4403,6 +5296,98 @@ pub mod builder { } } + /// Builder for [`Client::create_link_token`] + /// + /// [`Client::create_link_token`]: super::Client::create_link_token + #[derive(Debug, Clone)] + pub struct CreateLinkToken<'a> { + client: &'a super::Client, + identifier: Result, + body: Result, + } + + impl<'a> CreateLinkToken<'a> { + pub fn new(client: &'a super::Client) -> Self { + Self { + client, + identifier: Err("identifier was not initialized".to_string()), + body: Ok(types::builder::ApiUserLinkRequestPayload::default()), + } + } + + pub fn identifier(mut self, value: V) -> Self + where + V: std::convert::TryInto, + { + self.identifier = value + .try_into() + .map_err(|_| "conversion to `uuid :: Uuid` for identifier failed".to_string()); + self + } + + pub fn body(mut self, value: V) -> Self + where + V: std::convert::TryInto, + { + self.body = value.try_into().map(From::from).map_err(|_| { + "conversion to `ApiUserLinkRequestPayload` for body failed".to_string() + }); + self + } + + pub fn body_map(mut self, f: F) -> Self + where + F: std::ops::FnOnce( + types::builder::ApiUserLinkRequestPayload, + ) -> types::builder::ApiUserLinkRequestPayload, + { + self.body = self.body.map(f); + self + } + + /// Sends a `POST` request to + /// `/api-user-provider/{identifier}/link-token` + pub async fn send( + self, + ) -> Result, Error> { + let Self { + client, + identifier, + body, + } = self; + let identifier = identifier.map_err(Error::InvalidRequest)?; + let body = body + .and_then(std::convert::TryInto::::try_into) + .map_err(Error::InvalidRequest)?; + let url = format!( + "{}/api-user-provider/{}/link-token", + client.baseurl, + encode_path(&identifier.to_string()), + ); + let request = client + .client + .post(url) + .header( + reqwest::header::ACCEPT, + reqwest::header::HeaderValue::from_static("application/json"), + ) + .json(&body) + .build()?; + let result = client.client.execute(request).await; + let response = result?; + match response.status().as_u16() { + 200u16 => ResponseValue::from_response(response).await, + 400u16..=499u16 => Err(Error::ErrorResponse( + ResponseValue::from_response(response).await?, + )), + 500u16..=599u16 => Err(Error::ErrorResponse( + ResponseValue::from_response(response).await?, + )), + _ => Err(Error::UnexpectedResponse(response)), + } + } + } + /// Builder for [`ClientHiddenExt::github_webhook`] /// /// [`ClientHiddenExt::github_webhook`]: super::ClientHiddenExt::github_webhook @@ -5835,7 +6820,7 @@ pub mod builder { /// Sends a `GET` request to `/self` pub async fn send( self, - ) -> Result, Error> { + ) -> Result, Error> { let Self { client } = self; let url = format!("{}/self", client.baseurl,); let request = client diff --git a/rfd-sdk/src/lib.rs b/rfd-sdk/src/lib.rs index cb6f0fd..b7cae1d 100644 --- a/rfd-sdk/src/lib.rs +++ b/rfd-sdk/src/lib.rs @@ -30,12 +30,35 @@ impl Display for ApiPermission { Self::UpdateApiUserSelf => write!(f, "update-user-self"), Self::UpdateApiUserAssigned => write!(f, "update-user-assigned"), Self::UpdateApiUserAll => write!(f, "update-user-all"), + Self::CreateUserApiProviderLinkToken => write!(f, "create-link-request"), Self::ListGroups => write!(f, "list-groups"), Self::CreateGroup => write!(f, "create-group"), Self::UpdateGroup(id) => write!(f, "update-group:{}", id), Self::AddToGroup(id) => write!(f, "add-group-membership:{}", id), Self::RemoveFromGroup(id) => write!(f, "remove-group-membership:{}", id), + Self::ManageGroupMembership(id) => write!(f, "manage-group-membership:{}", id), + Self::ManageGroupMemberships(ids) => write!( + f, + "manage-group-memberships:{}", + ids.iter() + .map(|i| i.to_string()) + .collect::>() + .join(",") + ), + Self::ManageGroupMembershipAssigned => write!(f, "manage-group-membership-assigned"), + Self::ManageGroupMembershipAll => write!(f, "manage-group-membership-all"), Self::DeleteGroup(id) => write!(f, "delete-group:{}", id), + Self::ManageGroup(id) => write!(f, "manage-group:{}", id), + Self::ManageGroups(ids) => write!( + f, + "manage-groups:{}", + ids.iter() + .map(|i| i.to_string()) + .collect::>() + .join(",") + ), + Self::ManageGroupsAssigned => write!(f, "manage-groups-assigned"), + Self::ManageGroupsAll => write!(f, "manage-groups-all"), Self::GetRfd(number) => write!(f, "get-rfd:{}", number), Self::GetRfds(numbers) => write!( f,