feat: account part II, handle refresh tokens, clarify oauth UI (#196)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
-- Add down migration script here
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Add up migration script here
|
||||
ALTER TABLE account ADD COLUMN owner VARCHAR(50) NOT NULL;
|
||||
ALTER TABLE account ADD COLUMN client VARCHAR(50) NOT NULL;
|
||||
ALTER TABLE resource ADD COLUMN is_oauth BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE variable ADD COLUMN is_oauth BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE resource DROP COLUMN account;
|
||||
|
||||
ALTER TABLE account ALTER COLUMN expires_at TYPE TIMESTAMP WITH TIME ZONE;
|
||||
|
||||
ALTER TABLE account ALTER COLUMN expires_at SET NOT NULL;
|
||||
ALTER TABLE account ALTER COLUMN refresh_token SET NOT NULL;
|
||||
|
||||
GRANT ALL ON account TO app;
|
||||
GRANT ALL ON account TO admin;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO admin;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO app;
|
||||
|
||||
ALTER TABLE account ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
|
||||
CREATE POLICY see_own ON account FOR ALL
|
||||
USING (SPLIT_PART(account.owner, '/', 1) = 'u' AND SPLIT_PART(account.owner, '/', 2) = current_setting('session.user'));
|
||||
|
||||
CREATE POLICY see_member ON account FOR ALL
|
||||
USING (SPLIT_PART(account.owner, '/', 1) = 'g' AND SPLIT_PART(account.owner, '/', 2) = any(regexp_split_to_array(current_setting('session.groups'), ',')::text[]));
|
||||
@@ -13,5 +13,19 @@
|
||||
"scopes": [
|
||||
"api"
|
||||
]
|
||||
},
|
||||
"bitbucket": {
|
||||
"auth_url": "https://bitbucket.org/site/oauth2/authorize",
|
||||
"token_url": "https://bitbucket.org/site/oauth2/access_token",
|
||||
"scopes": [
|
||||
"repository"
|
||||
]
|
||||
},
|
||||
"slack": {
|
||||
"auth_url": "https://slack.com/oauth/authorize",
|
||||
"token_url": "https://slack.com/api/oauth.access",
|
||||
"scopes": [
|
||||
"chat:write:user"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1067,12 +1067,75 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
token:
|
||||
type: string
|
||||
$ref: "#/components/schemas/TokenResponse"
|
||||
|
||||
/w/{workspace}/oauth/disconnect/{account_id}:
|
||||
/w/{workspace}/oauth/create_account:
|
||||
post:
|
||||
summary: create OAuth account
|
||||
operationId: createAccount
|
||||
tags:
|
||||
- oauth
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
description: code endpoint
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
refresh_token:
|
||||
type: string
|
||||
expires_in:
|
||||
type: integer
|
||||
owner:
|
||||
type: string
|
||||
client:
|
||||
type: string
|
||||
required:
|
||||
- refresh_token
|
||||
- expires_in
|
||||
- owner
|
||||
- client
|
||||
responses:
|
||||
"200":
|
||||
description: account set
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/oauth/refresh_token/{id}:
|
||||
post:
|
||||
summary: refresh token
|
||||
operationId: refreshToken
|
||||
tags:
|
||||
- oauth
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/AccountId"
|
||||
requestBody:
|
||||
description: variable path
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
path:
|
||||
type: string
|
||||
required:
|
||||
- path
|
||||
responses:
|
||||
"200":
|
||||
description: token refreshed
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/oauth/disconnect/{id}:
|
||||
post:
|
||||
summary: disconnect account
|
||||
operationId: disconnectAccount
|
||||
@@ -2690,7 +2753,7 @@ components:
|
||||
schema:
|
||||
type: string
|
||||
AccountId:
|
||||
name: account
|
||||
name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
@@ -3187,6 +3250,10 @@ components:
|
||||
type: boolean
|
||||
description:
|
||||
type: string
|
||||
account:
|
||||
type: string
|
||||
is_oauth:
|
||||
type: boolean
|
||||
extra_perms:
|
||||
type: object
|
||||
additionalProperties:
|
||||
@@ -3222,6 +3289,10 @@ components:
|
||||
type: boolean
|
||||
description:
|
||||
type: string
|
||||
account:
|
||||
type: integer
|
||||
is_oauth:
|
||||
type: boolean
|
||||
required:
|
||||
- path
|
||||
- value
|
||||
@@ -3352,6 +3423,8 @@ components:
|
||||
type: string
|
||||
resource_type:
|
||||
type: string
|
||||
is_oauth:
|
||||
type: boolean
|
||||
required:
|
||||
- path
|
||||
- value
|
||||
@@ -3380,6 +3453,8 @@ components:
|
||||
type: string
|
||||
value:
|
||||
type: object
|
||||
is_oauth:
|
||||
type: boolean
|
||||
extra_perms:
|
||||
type: object
|
||||
additionalProperties:
|
||||
@@ -3387,6 +3462,7 @@ components:
|
||||
required:
|
||||
- path
|
||||
- resource_type
|
||||
- is_oauth
|
||||
|
||||
ResourceType:
|
||||
type: object
|
||||
@@ -3787,3 +3863,23 @@ components:
|
||||
- team_id
|
||||
- team_name
|
||||
- bot
|
||||
|
||||
TokenResponse:
|
||||
type: object
|
||||
properties:
|
||||
access_token:
|
||||
type: string
|
||||
expires_in:
|
||||
type: integer
|
||||
refresh_token:
|
||||
type: string
|
||||
scope:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
|
||||
required:
|
||||
- access_token
|
||||
- expires_in
|
||||
- refresh_token
|
||||
- scope
|
||||
|
||||
@@ -555,6 +555,23 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"27eb5f99dc9289670673fb999ba9e67abccba212b506c30ba47e6c4fef6d53c4": {
|
||||
"query": "INSERT INTO resource\n (workspace_id, path, value, description, resource_type, is_oauth)\n VALUES ($1, $2, $3, $4, $5, $6)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Jsonb",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
}
|
||||
},
|
||||
"28c042adef65c3055edc324fbbd2f267285d3566cbec58404983323d410ace27": {
|
||||
"query": "SELECT super_admin FROM password WHERE email = $1",
|
||||
"describe": {
|
||||
@@ -646,6 +663,24 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"2e4115bb2e6c8c85ad1492ad135d6b0454b342126cb5fa17e58caf71b32ee755": {
|
||||
"query": "INSERT INTO variable\n (workspace_id, path, value, is_secret, description, account, is_oauth)\n VALUES ($1, $2, $3, $4, $5, $6, $7)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"Varchar",
|
||||
"Int4",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
}
|
||||
},
|
||||
"37d3ee8009055e869941e548a6d5a352053a5d7782f662c34b94706488abccb6": {
|
||||
"query": "UPDATE queue SET running = false WHERE last_ping < $1 RETURNING id",
|
||||
"describe": {
|
||||
@@ -1697,22 +1732,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"8a80333c2fbf7b50fed305882de6e4ffda985d5c648cd617add6c9e6a9c03f34": {
|
||||
"query": "INSERT INTO resource\n (workspace_id, path, value, description, resource_type)\n VALUES ($1, $2, $3, $4, $5)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Jsonb",
|
||||
"Text",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
}
|
||||
},
|
||||
"8ad6a17eecce77f61236e0585ba89b99a32e07ad37b97662007db35acb59d139": {
|
||||
"query": "SELECT * from resource_type WHERE (workspace_id = $1 OR workspace_id = 'starter') ORDER BY name",
|
||||
"describe": {
|
||||
@@ -1937,8 +1956,8 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "account",
|
||||
"type_info": "Int4"
|
||||
"name": "is_oauth",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -1954,7 +1973,7 @@
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
false
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -2278,22 +2297,6 @@
|
||||
"nullable": []
|
||||
}
|
||||
},
|
||||
"afe9ea97d6e4c45453e21dc3d218fe8927f9faa58921666ceb7014e1f695d900": {
|
||||
"query": "INSERT INTO variable\n (workspace_id, path, value, is_secret, description)\n VALUES ($1, $2, $3, $4, $5)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
}
|
||||
},
|
||||
"b20977e70ebac7ccbaec5a2a1e940301dd331a5f9a4be67a27cfbff8619ac8f0": {
|
||||
"query": "INSERT INTO usr\n (workspace_id, email, username, is_admin)\n VALUES ($1, $2, $3, true)",
|
||||
"describe": {
|
||||
@@ -2395,6 +2398,27 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"bb56e61c7cfb09c0a28fb3226dfe91704c70d9fe15eda18e6889adfd7496f80b": {
|
||||
"query": "DELETE FROM account WHERE workspace_id = $1 AND id = $2 RETURNING id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
}
|
||||
},
|
||||
"be33c6eb702c149044650d49b3c50493d7538d590be3f4ff6242fea85c57c667": {
|
||||
"query": "INSERT INTO script (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13)",
|
||||
"describe": {
|
||||
@@ -2598,6 +2622,21 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"cac594031a21b4806de9c4616317d3541522ef9712a83ecff7bd8b5f6e870748": {
|
||||
"query": "UPDATE account SET refresh_token = $1, expires_at = $2 WHERE workspace_id = $3 AND id = $4",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Timestamptz",
|
||||
"Text",
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
}
|
||||
},
|
||||
"cb12aee5f8e04cb196d4b8fad81699fcbb1ae7b0c84090d5705b14eac76074ff": {
|
||||
"query": "INSERT INTO group_\n VALUES ($1, 'all', 'The group that always contains all users of this workspace')",
|
||||
"describe": {
|
||||
@@ -2610,6 +2649,30 @@
|
||||
"nullable": []
|
||||
}
|
||||
},
|
||||
"d1513cfa13037cbfe076d184cc698b896add2daba02b8add600f643249b95ad2": {
|
||||
"query": "INSERT INTO account (workspace_id, client, owner, expires_at, refresh_token) VALUES ($1, $2, $3, $4, $5) RETURNING id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Timestamptz",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
}
|
||||
},
|
||||
"d2dcf69b20488d610599c309862722f805049e479035be6a416d05d73528a8e1": {
|
||||
"query": "INSERT INTO group_\n (workspace_id, name, summary)\n VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
|
||||
"describe": {
|
||||
@@ -3076,8 +3139,8 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "account",
|
||||
"type_info": "Int4"
|
||||
"name": "is_oauth",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -3092,8 +3155,49 @@
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
false
|
||||
]
|
||||
}
|
||||
},
|
||||
"fbe14569717b4c4937b74e6dc6f6b4ea29a2b0c2b1cde48ef06d687f6f9e8f15": {
|
||||
"query": "SELECT client, refresh_token FROM account WHERE workspace_id = $1 AND id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "client",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "refresh_token",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
}
|
||||
},
|
||||
"fcb6ce4fab662602827281e3e85fffaa48542f8d590100d0e7eead2ad4ad4873": {
|
||||
"query": "UPDATE variable SET value = $1 WHERE workspace_id = $2 AND path = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use axum::extract::{Extension, FromRequest, Path, Query, RequestParts};
|
||||
use axum::response::Redirect;
|
||||
use axum::routing::{get, post};
|
||||
use axum::{async_trait, Json, Router};
|
||||
use chrono::{Duration, Utc};
|
||||
use hyper::StatusCode;
|
||||
use itertools::Itertools;
|
||||
|
||||
@@ -16,16 +17,18 @@ use reqwest::Client;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use slack_http_verifier::SlackVerifier;
|
||||
use sqlx::{Postgres, Transaction};
|
||||
use tokio::fs::File;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tower_cookies::{Cookie, Cookies};
|
||||
|
||||
use crate::audit::{audit_log, ActionKind};
|
||||
use crate::db::{UserDB, DB};
|
||||
use crate::error::{self, to_anyhow, Result};
|
||||
use crate::error::{self, to_anyhow, Error, Result};
|
||||
use crate::jobs;
|
||||
use crate::jobs::{get_latest_hash_for_path, JobPayload};
|
||||
use crate::users::Authed;
|
||||
use crate::utils::not_found_if_none;
|
||||
use crate::workspaces::WorkspaceSettings;
|
||||
use crate::BaseUrl;
|
||||
|
||||
@@ -47,9 +50,12 @@ pub fn global_service() -> Router {
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
.route("/disconnect/:account_id", post(disconnect))
|
||||
.route("/disconnect/:id", post(disconnect))
|
||||
.route("/disconnect_slack", post(disconnect_slack))
|
||||
.route("/set_workspace_slack", post(set_workspace_slack))
|
||||
.route("/create_account", post(create_account))
|
||||
.route("/delete_account/:id", post(delete_account))
|
||||
.route("/refresh_token/:id", post(refresh_token))
|
||||
}
|
||||
|
||||
pub struct ClientWithScopes {
|
||||
@@ -209,7 +215,7 @@ pub struct SlackTokenResponse {
|
||||
bot: SlackBotToken,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct TokenResponse {
|
||||
access_token: AccessToken,
|
||||
expires_in: Option<u64>,
|
||||
@@ -244,6 +250,68 @@ async fn connect(
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreateAccount {
|
||||
client: String,
|
||||
owner: String,
|
||||
refresh_token: String,
|
||||
expires_in: i64,
|
||||
}
|
||||
async fn create_account(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path(w_id): Path<String>,
|
||||
Json(payload): Json<CreateAccount>,
|
||||
) -> error::Result<String> {
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let expires_at = chrono::Utc::now() + Duration::seconds(payload.expires_in);
|
||||
let id = sqlx::query_scalar!(
|
||||
"INSERT INTO account (workspace_id, client, owner, expires_at, refresh_token) VALUES ($1, $2, $3, $4, $5) RETURNING id",
|
||||
w_id,
|
||||
payload.client,
|
||||
payload.owner,
|
||||
expires_at,
|
||||
payload.refresh_token
|
||||
)
|
||||
.fetch_one(&mut tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(id.to_string())
|
||||
}
|
||||
|
||||
async fn delete_account(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Query((w_id, id)): Query<(String, i32)>,
|
||||
) -> error::Result<String> {
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let exists = sqlx::query!(
|
||||
"DELETE FROM account WHERE workspace_id = $1 AND id = $2 RETURNING id",
|
||||
w_id,
|
||||
id,
|
||||
)
|
||||
.fetch_optional(&mut tx)
|
||||
.await?;
|
||||
|
||||
let id_str = id.to_string();
|
||||
not_found_if_none(exists, "Account", &id_str)?;
|
||||
|
||||
audit_log(
|
||||
&mut tx,
|
||||
&authed.username,
|
||||
"account.delete",
|
||||
ActionKind::Delete,
|
||||
&w_id,
|
||||
Some(&id_str),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(format!("Deleted account id {id}"))
|
||||
}
|
||||
|
||||
async fn list_logins(
|
||||
Extension(clients): Extension<Arc<AllClients>>,
|
||||
) -> error::JsonResult<Vec<String>> {
|
||||
@@ -333,24 +401,96 @@ async fn login(
|
||||
oauth_redirect(clients, client_name, cookies, None)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct VariablePath {
|
||||
path: String,
|
||||
}
|
||||
async fn refresh_token(
|
||||
authed: Authed,
|
||||
Path((w_id, id)): Path<(String, i32)>,
|
||||
Json(VariablePath { path }): Json<VariablePath>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Extension(clients): Extension<Arc<AllClients>>,
|
||||
Extension(http_client): Extension<Client>,
|
||||
) -> error::Result<String> {
|
||||
let tx = user_db.begin(&authed).await?;
|
||||
|
||||
_refresh_token(tx, &path, w_id, id, clients, http_client).await?;
|
||||
|
||||
Ok(format!("Token at path {path} refreshed"))
|
||||
}
|
||||
|
||||
pub async fn _refresh_token<'c>(
|
||||
mut tx: Transaction<'c, Postgres>,
|
||||
path: &str,
|
||||
w_id: String,
|
||||
id: i32,
|
||||
clients: Arc<AllClients>,
|
||||
http_client: Client,
|
||||
) -> error::Result<String> {
|
||||
let account = sqlx::query!(
|
||||
"SELECT client, refresh_token FROM account WHERE workspace_id = $1 AND id = $2",
|
||||
w_id,
|
||||
id,
|
||||
)
|
||||
.fetch_optional(&mut tx)
|
||||
.await?;
|
||||
let account = not_found_if_none(account, "Account", &id.to_string())?;
|
||||
let client = (&clients
|
||||
.connects
|
||||
.get(&account.client)
|
||||
.ok_or_else(|| error::Error::BadRequest("invalid client".to_string()))?
|
||||
.client)
|
||||
.to_owned();
|
||||
let token = client
|
||||
.exchange_refresh_token(&RefreshToken::from(account.refresh_token))
|
||||
.with_client(&http_client)
|
||||
.execute::<TokenResponse>()
|
||||
.await
|
||||
.map_err(to_anyhow)?;
|
||||
let expires_at = Utc::now()
|
||||
+ chrono::Duration::seconds(
|
||||
token
|
||||
.expires_in
|
||||
.ok_or_else(|| Error::InternalErr("expires_in exepcted and not found".to_string()))?
|
||||
.try_into()
|
||||
.unwrap(),
|
||||
);
|
||||
sqlx::query!(
|
||||
"UPDATE account SET refresh_token = $1, expires_at = $2 WHERE workspace_id = $3 AND id = $4",
|
||||
token.refresh_token.map(|x| x.to_string()),
|
||||
expires_at,
|
||||
w_id,
|
||||
id
|
||||
)
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
let token = token.access_token.to_string();
|
||||
sqlx::query!(
|
||||
"UPDATE variable SET value = $1 WHERE workspace_id = $2 AND path = $3",
|
||||
token,
|
||||
w_id,
|
||||
path
|
||||
)
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct OAuthCallback {
|
||||
code: String,
|
||||
state: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ConnectResponse {
|
||||
token: String,
|
||||
}
|
||||
|
||||
async fn connect_callback(
|
||||
cookies: Cookies,
|
||||
Path(client_name): Path<String>,
|
||||
Json(callback): Json<OAuthCallback>,
|
||||
Extension(clients): Extension<Arc<AllClients>>,
|
||||
Extension(http_client): Extension<Client>,
|
||||
) -> error::JsonResult<ConnectResponse> {
|
||||
) -> error::JsonResult<TokenResponse> {
|
||||
let client = (&clients
|
||||
.connects
|
||||
.get(&client_name)
|
||||
@@ -358,12 +498,10 @@ async fn connect_callback(
|
||||
.client)
|
||||
.to_owned();
|
||||
|
||||
let token = exchange_code::<TokenResponse>(callback, &cookies, client, &http_client)
|
||||
.await?
|
||||
.access_token
|
||||
.to_string();
|
||||
let token_response =
|
||||
exchange_code::<TokenResponse>(callback, &cookies, client, &http_client).await?;
|
||||
|
||||
Ok(Json(ConnectResponse { token }))
|
||||
Ok(Json(token_response))
|
||||
}
|
||||
|
||||
async fn connect_slack_callback(
|
||||
|
||||
@@ -67,7 +67,7 @@ pub struct Resource {
|
||||
pub description: Option<String>,
|
||||
pub resource_type: String,
|
||||
pub extra_perms: serde_json::Value,
|
||||
pub account: Option<i32>,
|
||||
pub is_oauth: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -76,6 +76,7 @@ pub struct CreateResource {
|
||||
pub value: Option<serde_json::Value>,
|
||||
pub description: Option<String>,
|
||||
pub resource_type: String,
|
||||
pub is_oauth: Option<bool>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct EditResource {
|
||||
@@ -105,7 +106,7 @@ async fn list_resources(
|
||||
"description",
|
||||
"resource_type",
|
||||
"extra_perms",
|
||||
"account",
|
||||
"is_oauth",
|
||||
])
|
||||
.order_by("path", true)
|
||||
.and_where("workspace_id = ? OR workspace_id = 'starter'".bind(&w_id))
|
||||
@@ -180,13 +181,14 @@ async fn create_resource(
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO resource
|
||||
(workspace_id, path, value, description, resource_type)
|
||||
VALUES ($1, $2, $3, $4, $5)",
|
||||
(workspace_id, path, value, description, resource_type, is_oauth)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)",
|
||||
w_id,
|
||||
resource.path,
|
||||
resource.value,
|
||||
resource.description,
|
||||
resource.resource_type,
|
||||
resource.is_oauth
|
||||
)
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
|
||||
@@ -698,7 +698,7 @@ async fn decline_invite(
|
||||
&mut tx,
|
||||
&email,
|
||||
"users.decline_invite",
|
||||
ActionKind::Create,
|
||||
ActionKind::Delete,
|
||||
&nu.workspace_id,
|
||||
Some(&email),
|
||||
None,
|
||||
|
||||
@@ -5,10 +5,13 @@
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
audit::{audit_log, ActionKind},
|
||||
db::{UserDB, DB},
|
||||
error::{Error, JsonResult, Result},
|
||||
oauth2::{AllClients, _refresh_token},
|
||||
users::Authed,
|
||||
utils::StripPath,
|
||||
};
|
||||
@@ -20,6 +23,7 @@ use axum::{
|
||||
use hyper::StatusCode;
|
||||
|
||||
use magic_crypt::{MagicCrypt256, MagicCryptTrait};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{FromRow, Postgres, Transaction};
|
||||
|
||||
@@ -51,6 +55,8 @@ pub struct ListableVariable {
|
||||
pub description: String,
|
||||
pub extra_perms: serde_json::Value,
|
||||
pub account: Option<i32>,
|
||||
pub is_oauth: bool,
|
||||
pub is_expired: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -59,6 +65,8 @@ pub struct CreateVariable {
|
||||
pub value: String,
|
||||
pub is_secret: bool,
|
||||
pub description: String,
|
||||
pub account: Option<i32>,
|
||||
pub is_oauth: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -152,7 +160,7 @@ async fn list_variables(
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let rows = sqlx::query_as::<_, ListableVariable>(
|
||||
"SELECT workspace_id, path, CASE WHEN is_secret IS TRUE THEN null ELSE value::text END as value, is_secret, description, extra_perms, account from variable
|
||||
"SELECT workspace_id, path, CASE WHEN is_secret IS TRUE THEN null ELSE value::text END as value, is_secret, description, extra_perms, account, is_oauth, false as is_expired from variable
|
||||
WHERE (workspace_id = $1 OR (is_secret IS NOT TRUE AND workspace_id = 'starter')) ORDER BY path",
|
||||
)
|
||||
.bind(&w_id)
|
||||
@@ -173,12 +181,17 @@ async fn get_variable(
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Query(q): Query<GetVariableQuery>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
Extension(clients): Extension<Arc<AllClients>>,
|
||||
Extension(http_client): Extension<Client>,
|
||||
) -> JsonResult<ListableVariable> {
|
||||
let path = path.to_path();
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let variable_o = sqlx::query_as::<_, ListableVariable>(
|
||||
"SELECT * from variable WHERE path = $1 AND (workspace_id = $2 OR (is_secret IS NOT TRUE AND workspace_id = 'starter'))",
|
||||
"SELECT variable.*, (now() > account.expires_at) as is_expired from variable
|
||||
LEFT JOIN account ON variable.account = account.id
|
||||
WHERE variable.path = $1 AND (variable.workspace_id = $2 OR (is_secret IS NOT TRUE AND variable.workspace_id = 'starter'))
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(&path)
|
||||
.bind(&w_id)
|
||||
@@ -202,8 +215,22 @@ async fn get_variable(
|
||||
.await?;
|
||||
let value = variable.value.unwrap_or_else(|| "".to_string());
|
||||
ListableVariable {
|
||||
value: if !value.is_empty() && decrypt_secret {
|
||||
value: if variable.is_expired.unwrap_or(false) && variable.account.is_some() {
|
||||
Some(
|
||||
_refresh_token(
|
||||
tx,
|
||||
&variable.path,
|
||||
w_id,
|
||||
variable.account.unwrap(),
|
||||
clients,
|
||||
http_client,
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
} else if !value.is_empty() && decrypt_secret {
|
||||
let mc = build_crypt(&mut tx, &w_id).await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Some(
|
||||
mc.decrypt_base64_to_string(value)
|
||||
.map_err(|e| Error::InternalErr(e.to_string()))?,
|
||||
@@ -216,7 +243,6 @@ async fn get_variable(
|
||||
} else {
|
||||
variable
|
||||
};
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Json(r))
|
||||
}
|
||||
@@ -238,13 +264,15 @@ async fn create_variable(
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO variable
|
||||
(workspace_id, path, value, is_secret, description)
|
||||
VALUES ($1, $2, $3, $4, $5)",
|
||||
(workspace_id, path, value, is_secret, description, account, is_oauth)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)",
|
||||
&w_id,
|
||||
variable.path,
|
||||
value,
|
||||
variable.is_secret,
|
||||
variable.description
|
||||
variable.description,
|
||||
variable.account,
|
||||
variable.is_oauth.unwrap_or(false),
|
||||
)
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
|
||||
@@ -13,18 +13,18 @@
|
||||
import { workspaceStore, userStore, oauthStore } from '$lib/stores'
|
||||
import { faMinus, faPlus } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
import { OauthService, ResourceService, VariableService } from '$lib/gen'
|
||||
import { OauthService, ResourceService, VariableService, type TokenResponse } from '$lib/gen'
|
||||
|
||||
import { createEventDispatcher, onMount } from 'svelte'
|
||||
import Modal from './Modal.svelte'
|
||||
import Icon from 'svelte-awesome'
|
||||
import Path from './Path.svelte'
|
||||
import Password from './Password.svelte'
|
||||
import { sendUserToast, truncate, truncateRev } from '$lib/utils'
|
||||
import { goto } from '$app/navigation'
|
||||
import { sendUserToast, truncateRev } from '$lib/utils'
|
||||
|
||||
let manual = false
|
||||
let value = ''
|
||||
let value: string = ''
|
||||
let valueToken: TokenResponse
|
||||
let connects: Record<string, string[]> = {}
|
||||
let connectsManual: [string, { img?: string; instructions: string }][] = []
|
||||
|
||||
@@ -46,7 +46,8 @@
|
||||
|
||||
export function openFromOauth(rt: string) {
|
||||
resource_type = rt
|
||||
value = $oauthStore!
|
||||
value = $oauthStore?.access_token!
|
||||
valueToken = $oauthStore!
|
||||
$oauthStore = undefined
|
||||
manual = false
|
||||
step = 3
|
||||
@@ -95,13 +96,30 @@
|
||||
if (exists) {
|
||||
throw Error(`Resource at path ${path} already exists. Delete it or pick another path`)
|
||||
}
|
||||
|
||||
let account: number | undefined = undefined
|
||||
if (valueToken.refresh_token != undefined && valueToken.expires_in != undefined) {
|
||||
account = Number(
|
||||
await OauthService.createAccount({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
refresh_token: valueToken.refresh_token!,
|
||||
expires_in: valueToken.expires_in!,
|
||||
owner: path.split('/').slice(0, 2).join('/'),
|
||||
client: resource_type
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
await VariableService.createVariable({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
path,
|
||||
value,
|
||||
is_secret: true,
|
||||
description: `OAuth token for ${resource_type}`
|
||||
description: `OAuth token for ${resource_type}`,
|
||||
is_oauth: true,
|
||||
account: account
|
||||
}
|
||||
})
|
||||
await ResourceService.createResource({
|
||||
@@ -110,7 +128,8 @@
|
||||
resource_type,
|
||||
path,
|
||||
value: { token: `$var:${path}` },
|
||||
description: `OAuth token for ${resource_type}`
|
||||
description: `OAuth token for ${resource_type}`,
|
||||
is_oauth: true
|
||||
}
|
||||
})
|
||||
dispatch('refresh')
|
||||
@@ -147,7 +166,7 @@
|
||||
<div slot="title">Connect an app</div>
|
||||
<div slot="content">
|
||||
{#if step == 1}
|
||||
<PageHeader title="Oauth apps" />
|
||||
<PageHeader title="OAuth apps" />
|
||||
<div class="grid sm:grid-cols-2 md:grid-cols-3 gap-x-2 gap-y-1 items-center mb-2">
|
||||
{#each Object.entries(connects) as [key, values]}
|
||||
<button
|
||||
|
||||
@@ -2,6 +2,7 @@ import { browser } from '$app/env'
|
||||
import { writable } from 'svelte/store'
|
||||
import type { UserWorkspaceList } from '$lib/gen/models/UserWorkspaceList.js'
|
||||
import { getUserExt } from './user'
|
||||
import type { TokenResponse } from './gen'
|
||||
|
||||
export interface UserExt {
|
||||
email: string
|
||||
@@ -14,7 +15,7 @@ export interface UserExt {
|
||||
|
||||
let persistedWorkspace = browser && localStorage.getItem('workspace')
|
||||
|
||||
export const oauthStore = writable<string | undefined>(undefined)
|
||||
export const oauthStore = writable<TokenResponse | undefined>(undefined)
|
||||
export const userStore = writable<UserExt | undefined>(undefined)
|
||||
export const workspaceStore = writable<string | undefined>(
|
||||
persistedWorkspace ? String(persistedWorkspace) : undefined
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
clientName: client_name,
|
||||
requestBody: { code, state }
|
||||
})
|
||||
$oauthStore = res.token
|
||||
$oauthStore = res
|
||||
goto(`/resources?resource_type=${client_name}`)
|
||||
} catch (e) {
|
||||
sendUserToast(`Error parsing the response token, ${e.body}`, true)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { canWrite, emptySchema, sendUserToast } from '$lib/utils'
|
||||
import { ResourceService } from '$lib/gen'
|
||||
import { ResourceService, VariableService } from '$lib/gen'
|
||||
import type { Resource, ResourceType } from '$lib/gen'
|
||||
import PageHeader from '$lib/components/PageHeader.svelte'
|
||||
import ResourceEditor from '$lib/components/ResourceEditor.svelte'
|
||||
@@ -19,7 +19,7 @@
|
||||
import type { Schema } from '$lib/common'
|
||||
import SchemaViewer from '$lib/components/SchemaViewer.svelte'
|
||||
import Dropdown from '$lib/components/Dropdown.svelte'
|
||||
import { faEdit, faPlus, faShare, faTrash } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faEdit, faPlus, faShare, faTrash, faCircle } from '@fortawesome/free-solid-svg-icons'
|
||||
import CenteredPage from '$lib/components/CenteredPage.svelte'
|
||||
import Icon from 'svelte-awesome'
|
||||
import Required from '$lib/components/Required.svelte'
|
||||
@@ -69,7 +69,10 @@
|
||||
)
|
||||
}
|
||||
|
||||
async function deleteResource(path: string): Promise<void> {
|
||||
async function deleteResource(path: string, is_oauth: boolean): Promise<void> {
|
||||
if (is_oauth) {
|
||||
await VariableService.deleteVariable({ workspace: $workspaceStore!, path })
|
||||
}
|
||||
await ResourceService.deleteResource({ workspace: $workspaceStore!, path })
|
||||
loadResources()
|
||||
}
|
||||
@@ -146,11 +149,12 @@
|
||||
<th>path</th>
|
||||
<th>resource_type</th>
|
||||
<th>description</th>
|
||||
<th>OAuth</th>
|
||||
<th />
|
||||
</tr>
|
||||
<tbody slot="body">
|
||||
{#if resources}
|
||||
{#each resources as { path, description, resource_type, extra_perms, canWrite }}
|
||||
{#each resources as { path, description, resource_type, extra_perms, canWrite, is_oauth }}
|
||||
<tr>
|
||||
<td class="my-12"
|
||||
><a
|
||||
@@ -171,6 +175,16 @@
|
||||
</td>
|
||||
<td><IconedResourceType name={resource_type} /></td>
|
||||
<td><SvelteMarkdown source={description ?? ''} /></td>
|
||||
<td>
|
||||
{#if is_oauth}
|
||||
<Icon
|
||||
class="text-green-600"
|
||||
data={faCircle}
|
||||
scale={0.7}
|
||||
label="Resource is tied to an OAuth app"
|
||||
/>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
<Dropdown
|
||||
dropdownItems={[
|
||||
@@ -196,7 +210,7 @@
|
||||
icon: faTrash,
|
||||
type: 'delete',
|
||||
action: () => {
|
||||
deleteResource(path)
|
||||
deleteResource(path, is_oauth)
|
||||
}
|
||||
}
|
||||
]}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import { onDestroy } from 'svelte'
|
||||
import { JobService, Job, CompletedJob } from '$lib/gen'
|
||||
import { displayDate, displayDaysAgo, forLater, truncateHash } from '$lib/utils'
|
||||
import Icon from 'svelte-awesome'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { canWrite, sendUserToast } from '$lib/utils'
|
||||
import { VariableService } from '$lib/gen'
|
||||
import { OauthService, VariableService } from '$lib/gen'
|
||||
import type { ListableVariable, ContextualVariable } from '$lib/gen'
|
||||
import Dropdown from '$lib/components/Dropdown.svelte'
|
||||
import PageHeader from '$lib/components/PageHeader.svelte'
|
||||
@@ -12,7 +12,7 @@
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import CenteredPage from '$lib/components/CenteredPage.svelte'
|
||||
import Icon from 'svelte-awesome'
|
||||
import { faPlus } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faPlus, faCircle } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
type ListableVariableW = ListableVariable & { canWrite: boolean }
|
||||
|
||||
@@ -37,7 +37,10 @@
|
||||
})
|
||||
}
|
||||
|
||||
async function deleteVariable(path: string): Promise<void> {
|
||||
async function deleteVariable(path: string, account?: string): Promise<void> {
|
||||
if (account) {
|
||||
OauthService.disconnectAccount({ workspace: $workspaceStore!, id: account })
|
||||
}
|
||||
await VariableService.deleteVariable({ workspace: $workspaceStore!, path })
|
||||
loadVariables()
|
||||
sendUserToast(`Variable ${path} was deleted`)
|
||||
@@ -71,10 +74,11 @@
|
||||
<th>value</th>
|
||||
<th>secret</th>
|
||||
<th>description</th>
|
||||
<th>OAuth</th>
|
||||
<th />
|
||||
</tr>
|
||||
<tbody slot="body">
|
||||
{#each variables as { path, value, is_secret, description, extra_perms, canWrite }}
|
||||
{#each variables as { path, value, is_secret, description, extra_perms, canWrite, account, is_oauth }}
|
||||
<tr>
|
||||
<td
|
||||
><a
|
||||
@@ -87,6 +91,16 @@
|
||||
<td>{value ?? '******'}</td>
|
||||
<td>{is_secret ? 'secret' : 'visible'}</td>
|
||||
<td>{description}</td>
|
||||
<td>
|
||||
{#if is_oauth}
|
||||
<Icon
|
||||
class="text-green-600"
|
||||
data={faCircle}
|
||||
scale={0.7}
|
||||
label="Variable is tied to an OAuth app"
|
||||
/>
|
||||
{/if}
|
||||
</td>
|
||||
<td
|
||||
><Dropdown
|
||||
dropdownItems={[
|
||||
@@ -97,7 +111,7 @@
|
||||
},
|
||||
{
|
||||
displayName: 'Delete',
|
||||
action: () => deleteVariable(path),
|
||||
action: () => deleteVariable(path, account),
|
||||
disabled: !canWrite
|
||||
},
|
||||
{
|
||||
@@ -106,7 +120,24 @@
|
||||
shareModal.openModal(path)
|
||||
},
|
||||
disabled: !canWrite
|
||||
}
|
||||
},
|
||||
...(account != undefined
|
||||
? [
|
||||
{
|
||||
displayName: 'Refresh token',
|
||||
action: async () => {
|
||||
await OauthService.refreshToken({
|
||||
workspace: $workspaceStore ?? '',
|
||||
id: account,
|
||||
requestBody: {
|
||||
path
|
||||
}
|
||||
})
|
||||
sendUserToast('Token refreshed')
|
||||
}
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]}
|
||||
relative={false}
|
||||
/></td
|
||||
|
||||
Reference in New Issue
Block a user