Skip to content

Commit

Permalink
feat: gateway command to sync permit (#1705)
Browse files Browse the repository at this point in the history
* nit: name

* refactor: args

* feat: sync projects loop

* fix: permit client error model

* fix: auth user & tier sync

* nit: unused runtime deps

* fix

* fix: improve test

* ci: add permit key (TODO)

* ci: use correct staging permit key

* feat: permit health check

* todo

* fix: ignore project create 409s

* feat: sync projects by user

* fix: hashmap inserts
  • Loading branch information
jonaro00 authored Apr 2, 2024
1 parent e33329b commit 8a38a12
Show file tree
Hide file tree
Showing 16 changed files with 265 additions and 100 deletions.
6 changes: 6 additions & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,9 @@ jobs:
gateway-admin-key:
description: "Admin API key that authorizes gateway requests to auth service, for key to jwt conversion."
type: string
permit-api-key:
description: "Permit.io API key for the Permit environment that matches the current ${SHUTTLE_ENV}."
type: string
steps:
- checkout
- set-git-tag
Expand Down Expand Up @@ -383,6 +386,7 @@ jobs:
AUTH_JWTSIGNING_PRIVATE_KEY=${<< parameters.jwt-signing-private-key >>} \
CONTROL_DB_POSTGRES_URI=${<< parameters.control-db-postgres-uri >>} \
GATEWAY_ADMIN_KEY=${<< parameters.gateway-admin-key >>} \
PERMIT_API_KEY=${<< parameters.permit-api-key >>} \
make deploy
- when:
condition:
Expand Down Expand Up @@ -748,6 +752,7 @@ workflows:
jwt-signing-private-key: DEV_AUTH_JWTSIGNING_PRIVATE_KEY
control-db-postgres-uri: DEV_CONTROL_DB_POSTGRES_URI
gateway-admin-key: DEV_GATEWAY_ADMIN_KEY
permit-api-key: STAGING_PERMIT_API_KEY
requires:
- build-and-push-unstable
- approve-deploy-images-unstable
Expand Down Expand Up @@ -832,6 +837,7 @@ workflows:
jwt-signing-private-key: PROD_AUTH_JWTSIGNING_PRIVATE_KEY
control-db-postgres-uri: PROD_CONTROL_DB_POSTGRES_URI
gateway-admin-key: PROD_GATEWAY_ADMIN_KEY
permit-api-key: PROD_PERMIT_API_KEY
ssh-fingerprint: 6a:c5:33:fe:5b:c9:06:df:99:64:ca:17:0d:32:18:2e
ssh-config-script: production-ssh-config.sh
ssh-host: shuttle.prod.internal
Expand Down
3 changes: 0 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 10 additions & 7 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ STRIPE_SECRET_KEY?=""
AUTH_JWTSIGNING_PRIVATE_KEY?=""
PERMIT_API_KEY?=""

# log level set in all backends
RUST_LOG?=shuttle=debug,info

# production/staging/dev
SHUTTLE_ENV?=dev
DD_ENV=$(SHUTTLE_ENV)
ifeq ($(SHUTTLE_ENV),production)
DOCKER_COMPOSE_FILES=docker-compose.yml
Expand All @@ -53,8 +58,8 @@ CONTAINER_REGISTRY=public.ecr.aws/shuttle
# make sure we only ever go to production with `--tls=enable`
USE_TLS=enable
CARGO_PROFILE=release
RUST_LOG?=shuttle=debug,info
else
# add local development overrides to compose
DOCKER_COMPOSE_FILES=docker-compose.yml docker-compose.dev.yml
STACK?=shuttle-dev
APPS_FQDN=unstable.shuttleapp.rs
Expand All @@ -63,7 +68,10 @@ CONTAINER_REGISTRY=public.ecr.aws/shuttle-dev
USE_TLS?=disable
# default for local run
CARGO_PROFILE?=debug
RUST_LOG?=shuttle=debug,info
ifeq ($(CI),true)
# use release builds for staging deploys so that the DLC cache can be re-used for prod deploys
CARGO_PROFILE=release
endif
DEV_SUFFIX=-dev
DEPLOYS_API_KEY?=gateway4deployes
GATEWAY_ADMIN_KEY?=dh9z58jttoes3qvt
Expand All @@ -79,11 +87,6 @@ LOGGER_POSTGRES_PASSWORD?=postgres
LOGGER_POSTGRES_URI?=postgres://postgres:${LOGGER_POSTGRES_PASSWORD}@logger-postgres:5432/postgres
endif

ifeq ($(CI),true)
# default for staging
CARGO_PROFILE=release
endif

POSTGRES_EXTRA_PATH?=./extras/postgres
POSTGRES_TAG?=14

Expand Down
2 changes: 2 additions & 0 deletions auth/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ pub enum Error {
Stripe(#[from] StripeError),
#[error("Failed to communicate with service API.")]
ServiceApi(#[from] client::Error),
#[error("Failed to communicate with Permit API.")]
PermitApi(#[from] client::permit::Error),
}

impl Serialize for Error {
Expand Down
37 changes: 21 additions & 16 deletions auth/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ mod user;

use anyhow::Result;
use args::{CopyPermitEnvArgs, StartArgs, SyncArgs};
use shuttle_backends::client::{permit, PermissionsDal};
use http::StatusCode;
use shuttle_backends::client::{
permit::{self, Error, ResponseContent},
PermissionsDal,
};
use shuttle_common::{claims::AccountTier, ApiKey};
use sqlx::{migrate::Migrator, query, PgPool};
use tracing::info;
Expand Down Expand Up @@ -54,37 +58,38 @@ pub async fn sync(pool: PgPool, args: SyncArgs) -> Result<()> {
match permit_client.get_user(&user.id).await {
Ok(p_user) => {
// Update tier if out of sync
let wanted_tier = user.account_tier.as_permit_account_tier();
if !p_user
.roles
.is_some_and(|rs| rs.iter().any(|r| r.role == user.account_tier.to_string()))
.is_some_and(|rs| rs.iter().any(|r| r.role == wanted_tier.to_string()))
{
match user.account_tier {
AccountTier::Basic
| AccountTier::PendingPaymentPro
| AccountTier::CancelledPro
| AccountTier::Team
| AccountTier::Admin
| AccountTier::Deployer => {
println!("updating tier for user: {}", user.id);
match wanted_tier {
AccountTier::Basic => {
permit_client.make_basic(&user.id).await?;
}
AccountTier::Pro => {
permit_client.make_pro(&user.id).await?;
}
_ => unreachable!(),
}
}
}
Err(_) => {
// FIXME: Make the error type better so that this is only done on 404s

Err(Error::ResponseError(ResponseContent {
status: StatusCode::NOT_FOUND,
..
})) => {
// Add users that are not in permit
println!("creating user: {}", user.id);

// Add users that are not in permit
permit_client.new_user(&user.id).await?;

if user.account_tier == AccountTier::Pro {
if user.account_tier.as_permit_account_tier() == AccountTier::Pro {
permit_client.make_pro(&user.id).await?;
}
}
Err(e) => {
println!("failed to fetch user {}. skipping. error: {e}", user.id);
}
}
}

Expand All @@ -100,7 +105,7 @@ pub async fn copy_environment(args: CopyPermitEnvArgs) -> Result<()> {
args.permit.permit_api_key,
);

client.copy_environment(&args.target).await
Ok(client.copy_environment(&args.target).await?)
}

pub async fn init(pool: PgPool, args: InitArgs, tier: AccountTier) -> Result<()> {
Expand Down
82 changes: 77 additions & 5 deletions backends/src/client/permit.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
use anyhow::Error;
use std::fmt::{Debug, Display};

use async_trait::async_trait;
use http::StatusCode;
use permit_client_rs::{
apis::{
resource_instances_api::{create_resource_instance, delete_resource_instance},
role_assignments_api::{assign_role, unassign_role},
users_api::{create_user, delete_user, get_user},
Error as PermitClientError,
},
models::{
ResourceInstanceCreate, RoleAssignmentCreate, RoleAssignmentRemove, UserCreate, UserRead,
Expand All @@ -17,6 +20,7 @@ use permit_pdp_client_rs::{
},
data_updater_api::trigger_policy_data_update_data_updater_trigger_post,
policy_updater_api::trigger_policy_update_policy_updater_trigger_post,
Error as PermitPDPClientError,
},
models::{AuthorizationQuery, Resource, User, UserPermissionsQuery, UserPermissionsResult},
};
Expand Down Expand Up @@ -143,7 +147,7 @@ impl PermissionsDal for Client {
}

async fn create_project(&self, user_id: &str, project_id: &str) -> Result<(), Error> {
create_resource_instance(
if let Err(e) = create_resource_instance(
&self.api,
&self.proj_id,
&self.env_id,
Expand All @@ -154,7 +158,18 @@ impl PermissionsDal for Client {
attributes: None,
},
)
.await?;
.await
{
// Early return all errors except 409's (project already exists)
let e: Error = e.into();
if let Error::ResponseError(ref re) = e {
if re.status != StatusCode::CONFLICT {
return Err(e);
}
} else {
return Err(e);
}
}

self.assign_resource_role(user_id, format!("Project:{project_id}"), "admin")
.await?;
Expand Down Expand Up @@ -492,7 +507,7 @@ impl Client {
}
}

// #[cfg(feature = "admin")]
/// Higher level management methods. Use with care.
mod admin {
use permit_client_rs::{
apis::environments_api::copy_environment,
Expand All @@ -505,7 +520,8 @@ mod admin {
use super::*;

impl Client {
/// Copy and overwrite the policies of one env to another existing one
/// Copy and overwrite a permit env's policies to another env.
/// Requires a project level API key.
pub async fn copy_environment(&self, target_env: &str) -> Result<(), Error> {
copy_environment(
&self.api,
Expand Down Expand Up @@ -543,3 +559,59 @@ mod admin {
}
}
}

/// Dumbed down and unified version of the client's errors to get rid of the genereic <T>
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("reqwest error: {0}")]
Reqwest(reqwest::Error),
#[error("serde error: {0}")]
Serde(serde_json::Error),
#[error("io error: {0}")]
Io(std::io::Error),
#[error("response error: {0}")]
ResponseError(ResponseContent),
}
#[derive(Debug)]
pub struct ResponseContent {
pub status: reqwest::StatusCode,
pub content: String,
pub entity: String,
}
impl Display for ResponseContent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"status: {}, content: {}, entity: {}",
self.status, self.content, self.entity
)
}
}
impl<T: Debug> From<PermitClientError<T>> for Error {
fn from(value: PermitClientError<T>) -> Self {
match value {
PermitClientError::Reqwest(e) => Self::Reqwest(e),
PermitClientError::Serde(e) => Self::Serde(e),
PermitClientError::Io(e) => Self::Io(e),
PermitClientError::ResponseError(e) => Self::ResponseError(ResponseContent {
status: e.status,
content: e.content,
entity: format!("{:?}", e.entity),
}),
}
}
}
impl<T: Debug> From<PermitPDPClientError<T>> for Error {
fn from(value: PermitPDPClientError<T>) -> Self {
match value {
PermitPDPClientError::Reqwest(e) => Self::Reqwest(e),
PermitPDPClientError::Serde(e) => Self::Serde(e),
PermitPDPClientError::Io(e) => Self::Io(e),
PermitPDPClientError::ResponseError(e) => Self::ResponseError(ResponseContent {
status: e.status,
content: e.content,
entity: format!("{:?}", e.entity),
}),
}
}
}
3 changes: 1 addition & 2 deletions backends/src/test_utils/gateway.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
use std::sync::Arc;

use anyhow::Error;
use async_trait::async_trait;
use permit_client_rs::models::UserRead;
use permit_pdp_client_rs::models::UserPermissionsResult;
Expand All @@ -12,7 +11,7 @@ use wiremock::{
Mock, MockServer, Request, ResponseTemplate,
};

use crate::client::PermissionsDal;
use crate::client::{permit::Error, PermissionsDal};

pub async fn get_mocked_gateway_server() -> MockServer {
let mock_server = MockServer::start().await;
Expand Down
14 changes: 12 additions & 2 deletions backends/tests/integration/permit_tests.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
mod needs_docker {
use std::sync::OnceLock;

use http::StatusCode;
use permit_client_rs::apis::{
resource_instances_api::{delete_resource_instance, list_resource_instances},
users_api::list_users,
};
use serial_test::serial;
use shuttle_backends::client::{permit::Client, PermissionsDal};
use shuttle_backends::client::{
permit::{Client, Error, ResponseContent},
PermissionsDal,
};
use shuttle_common::claims::AccountTier;
use shuttle_common_tests::permit_pdp::DockerInstance;
use test_context::{test_context, AsyncTestContext};
Expand Down Expand Up @@ -116,7 +120,13 @@ mod needs_docker {
client.delete_user(u).await.unwrap();
let res = client.get_user(u).await;

assert!(res.is_err());
assert!(matches!(
res,
Err(Error::ResponseError(ResponseContent {
status: StatusCode::NOT_FOUND,
..
}))
));
}

#[test_context(Wrap)]
Expand Down
19 changes: 18 additions & 1 deletion common/src/claims.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,9 +169,9 @@ impl ScopeBuilder {
)]
#[serde(rename_all = "lowercase")]
#[cfg_attr(feature = "display", derive(strum::Display))]
#[cfg_attr(feature = "display", strum(serialize_all = "lowercase"))]
#[cfg_attr(feature = "persist", derive(sqlx::Type))]
#[cfg_attr(feature = "persist", sqlx(rename_all = "lowercase"))]
#[cfg_attr(feature = "display", strum(serialize_all = "lowercase"))]
pub enum AccountTier {
#[default]
Basic,
Expand All @@ -184,6 +184,23 @@ pub enum AccountTier {
Deployer,
}

impl AccountTier {
/// The tier that this user should have in Permit.io.
/// Permit should only store the tier that determines permissions,
/// with the exception of 'admin', which is an override and not checked against Permit.
pub fn as_permit_account_tier(&self) -> Self {
match self {
Self::Basic
| Self::PendingPaymentPro
| Self::CancelledPro
| Self::Team
| Self::Admin
| Self::Deployer => Self::Basic,
Self::Pro => Self::Pro,
}
}
}

impl From<AccountTier> for Vec<Scope> {
fn from(tier: AccountTier) -> Self {
let mut builder = ScopeBuilder::new();
Expand Down
Loading

0 comments on commit 8a38a12

Please sign in to comment.