Skip to content

Commit 8a38a12

Browse files
authored
feat: gateway command to sync permit (#1705)
* 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
1 parent e33329b commit 8a38a12

File tree

16 files changed

+265
-100
lines changed

16 files changed

+265
-100
lines changed

.circleci/config.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,9 @@ jobs:
356356
gateway-admin-key:
357357
description: "Admin API key that authorizes gateway requests to auth service, for key to jwt conversion."
358358
type: string
359+
permit-api-key:
360+
description: "Permit.io API key for the Permit environment that matches the current ${SHUTTLE_ENV}."
361+
type: string
359362
steps:
360363
- checkout
361364
- set-git-tag
@@ -383,6 +386,7 @@ jobs:
383386
AUTH_JWTSIGNING_PRIVATE_KEY=${<< parameters.jwt-signing-private-key >>} \
384387
CONTROL_DB_POSTGRES_URI=${<< parameters.control-db-postgres-uri >>} \
385388
GATEWAY_ADMIN_KEY=${<< parameters.gateway-admin-key >>} \
389+
PERMIT_API_KEY=${<< parameters.permit-api-key >>} \
386390
make deploy
387391
- when:
388392
condition:
@@ -748,6 +752,7 @@ workflows:
748752
jwt-signing-private-key: DEV_AUTH_JWTSIGNING_PRIVATE_KEY
749753
control-db-postgres-uri: DEV_CONTROL_DB_POSTGRES_URI
750754
gateway-admin-key: DEV_GATEWAY_ADMIN_KEY
755+
permit-api-key: STAGING_PERMIT_API_KEY
751756
requires:
752757
- build-and-push-unstable
753758
- approve-deploy-images-unstable
@@ -832,6 +837,7 @@ workflows:
832837
jwt-signing-private-key: PROD_AUTH_JWTSIGNING_PRIVATE_KEY
833838
control-db-postgres-uri: PROD_CONTROL_DB_POSTGRES_URI
834839
gateway-admin-key: PROD_GATEWAY_ADMIN_KEY
840+
permit-api-key: PROD_PERMIT_API_KEY
835841
ssh-fingerprint: 6a:c5:33:fe:5b:c9:06:df:99:64:ca:17:0d:32:18:2e
836842
ssh-config-script: production-ssh-config.sh
837843
ssh-host: shuttle.prod.internal

Cargo.lock

Lines changed: 0 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Makefile

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ STRIPE_SECRET_KEY?=""
4343
AUTH_JWTSIGNING_PRIVATE_KEY?=""
4444
PERMIT_API_KEY?=""
4545

46+
# log level set in all backends
47+
RUST_LOG?=shuttle=debug,info
48+
49+
# production/staging/dev
50+
SHUTTLE_ENV?=dev
4651
DD_ENV=$(SHUTTLE_ENV)
4752
ifeq ($(SHUTTLE_ENV),production)
4853
DOCKER_COMPOSE_FILES=docker-compose.yml
@@ -53,8 +58,8 @@ CONTAINER_REGISTRY=public.ecr.aws/shuttle
5358
# make sure we only ever go to production with `--tls=enable`
5459
USE_TLS=enable
5560
CARGO_PROFILE=release
56-
RUST_LOG?=shuttle=debug,info
5761
else
62+
# add local development overrides to compose
5863
DOCKER_COMPOSE_FILES=docker-compose.yml docker-compose.dev.yml
5964
STACK?=shuttle-dev
6065
APPS_FQDN=unstable.shuttleapp.rs
@@ -63,7 +68,10 @@ CONTAINER_REGISTRY=public.ecr.aws/shuttle-dev
6368
USE_TLS?=disable
6469
# default for local run
6570
CARGO_PROFILE?=debug
66-
RUST_LOG?=shuttle=debug,info
71+
ifeq ($(CI),true)
72+
# use release builds for staging deploys so that the DLC cache can be re-used for prod deploys
73+
CARGO_PROFILE=release
74+
endif
6775
DEV_SUFFIX=-dev
6876
DEPLOYS_API_KEY?=gateway4deployes
6977
GATEWAY_ADMIN_KEY?=dh9z58jttoes3qvt
@@ -79,11 +87,6 @@ LOGGER_POSTGRES_PASSWORD?=postgres
7987
LOGGER_POSTGRES_URI?=postgres://postgres:${LOGGER_POSTGRES_PASSWORD}@logger-postgres:5432/postgres
8088
endif
8189

82-
ifeq ($(CI),true)
83-
# default for staging
84-
CARGO_PROFILE=release
85-
endif
86-
8790
POSTGRES_EXTRA_PATH?=./extras/postgres
8891
POSTGRES_TAG?=14
8992

auth/src/error.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ pub enum Error {
2626
Stripe(#[from] StripeError),
2727
#[error("Failed to communicate with service API.")]
2828
ServiceApi(#[from] client::Error),
29+
#[error("Failed to communicate with Permit API.")]
30+
PermitApi(#[from] client::permit::Error),
2931
}
3032

3133
impl Serialize for Error {

auth/src/lib.rs

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ mod user;
66

77
use anyhow::Result;
88
use args::{CopyPermitEnvArgs, StartArgs, SyncArgs};
9-
use shuttle_backends::client::{permit, PermissionsDal};
9+
use http::StatusCode;
10+
use shuttle_backends::client::{
11+
permit::{self, Error, ResponseContent},
12+
PermissionsDal,
13+
};
1014
use shuttle_common::{claims::AccountTier, ApiKey};
1115
use sqlx::{migrate::Migrator, query, PgPool};
1216
use tracing::info;
@@ -54,37 +58,38 @@ pub async fn sync(pool: PgPool, args: SyncArgs) -> Result<()> {
5458
match permit_client.get_user(&user.id).await {
5559
Ok(p_user) => {
5660
// Update tier if out of sync
61+
let wanted_tier = user.account_tier.as_permit_account_tier();
5762
if !p_user
5863
.roles
59-
.is_some_and(|rs| rs.iter().any(|r| r.role == user.account_tier.to_string()))
64+
.is_some_and(|rs| rs.iter().any(|r| r.role == wanted_tier.to_string()))
6065
{
61-
match user.account_tier {
62-
AccountTier::Basic
63-
| AccountTier::PendingPaymentPro
64-
| AccountTier::CancelledPro
65-
| AccountTier::Team
66-
| AccountTier::Admin
67-
| AccountTier::Deployer => {
66+
println!("updating tier for user: {}", user.id);
67+
match wanted_tier {
68+
AccountTier::Basic => {
6869
permit_client.make_basic(&user.id).await?;
6970
}
7071
AccountTier::Pro => {
7172
permit_client.make_pro(&user.id).await?;
7273
}
74+
_ => unreachable!(),
7375
}
7476
}
7577
}
76-
Err(_) => {
77-
// FIXME: Make the error type better so that this is only done on 404s
78-
78+
Err(Error::ResponseError(ResponseContent {
79+
status: StatusCode::NOT_FOUND,
80+
..
81+
})) => {
82+
// Add users that are not in permit
7983
println!("creating user: {}", user.id);
8084

81-
// Add users that are not in permit
8285
permit_client.new_user(&user.id).await?;
83-
84-
if user.account_tier == AccountTier::Pro {
86+
if user.account_tier.as_permit_account_tier() == AccountTier::Pro {
8587
permit_client.make_pro(&user.id).await?;
8688
}
8789
}
90+
Err(e) => {
91+
println!("failed to fetch user {}. skipping. error: {e}", user.id);
92+
}
8893
}
8994
}
9095

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

103-
client.copy_environment(&args.target).await
108+
Ok(client.copy_environment(&args.target).await?)
104109
}
105110

106111
pub async fn init(pool: PgPool, args: InitArgs, tier: AccountTier) -> Result<()> {

backends/src/client/permit.rs

Lines changed: 77 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1-
use anyhow::Error;
1+
use std::fmt::{Debug, Display};
2+
23
use async_trait::async_trait;
4+
use http::StatusCode;
35
use permit_client_rs::{
46
apis::{
57
resource_instances_api::{create_resource_instance, delete_resource_instance},
68
role_assignments_api::{assign_role, unassign_role},
79
users_api::{create_user, delete_user, get_user},
10+
Error as PermitClientError,
811
},
912
models::{
1013
ResourceInstanceCreate, RoleAssignmentCreate, RoleAssignmentRemove, UserCreate, UserRead,
@@ -17,6 +20,7 @@ use permit_pdp_client_rs::{
1720
},
1821
data_updater_api::trigger_policy_data_update_data_updater_trigger_post,
1922
policy_updater_api::trigger_policy_update_policy_updater_trigger_post,
23+
Error as PermitPDPClientError,
2024
},
2125
models::{AuthorizationQuery, Resource, User, UserPermissionsQuery, UserPermissionsResult},
2226
};
@@ -143,7 +147,7 @@ impl PermissionsDal for Client {
143147
}
144148

145149
async fn create_project(&self, user_id: &str, project_id: &str) -> Result<(), Error> {
146-
create_resource_instance(
150+
if let Err(e) = create_resource_instance(
147151
&self.api,
148152
&self.proj_id,
149153
&self.env_id,
@@ -154,7 +158,18 @@ impl PermissionsDal for Client {
154158
attributes: None,
155159
},
156160
)
157-
.await?;
161+
.await
162+
{
163+
// Early return all errors except 409's (project already exists)
164+
let e: Error = e.into();
165+
if let Error::ResponseError(ref re) = e {
166+
if re.status != StatusCode::CONFLICT {
167+
return Err(e);
168+
}
169+
} else {
170+
return Err(e);
171+
}
172+
}
158173

159174
self.assign_resource_role(user_id, format!("Project:{project_id}"), "admin")
160175
.await?;
@@ -492,7 +507,7 @@ impl Client {
492507
}
493508
}
494509

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

507522
impl Client {
508-
/// Copy and overwrite the policies of one env to another existing one
523+
/// Copy and overwrite a permit env's policies to another env.
524+
/// Requires a project level API key.
509525
pub async fn copy_environment(&self, target_env: &str) -> Result<(), Error> {
510526
copy_environment(
511527
&self.api,
@@ -543,3 +559,59 @@ mod admin {
543559
}
544560
}
545561
}
562+
563+
/// Dumbed down and unified version of the client's errors to get rid of the genereic <T>
564+
#[derive(thiserror::Error, Debug)]
565+
pub enum Error {
566+
#[error("reqwest error: {0}")]
567+
Reqwest(reqwest::Error),
568+
#[error("serde error: {0}")]
569+
Serde(serde_json::Error),
570+
#[error("io error: {0}")]
571+
Io(std::io::Error),
572+
#[error("response error: {0}")]
573+
ResponseError(ResponseContent),
574+
}
575+
#[derive(Debug)]
576+
pub struct ResponseContent {
577+
pub status: reqwest::StatusCode,
578+
pub content: String,
579+
pub entity: String,
580+
}
581+
impl Display for ResponseContent {
582+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
583+
write!(
584+
f,
585+
"status: {}, content: {}, entity: {}",
586+
self.status, self.content, self.entity
587+
)
588+
}
589+
}
590+
impl<T: Debug> From<PermitClientError<T>> for Error {
591+
fn from(value: PermitClientError<T>) -> Self {
592+
match value {
593+
PermitClientError::Reqwest(e) => Self::Reqwest(e),
594+
PermitClientError::Serde(e) => Self::Serde(e),
595+
PermitClientError::Io(e) => Self::Io(e),
596+
PermitClientError::ResponseError(e) => Self::ResponseError(ResponseContent {
597+
status: e.status,
598+
content: e.content,
599+
entity: format!("{:?}", e.entity),
600+
}),
601+
}
602+
}
603+
}
604+
impl<T: Debug> From<PermitPDPClientError<T>> for Error {
605+
fn from(value: PermitPDPClientError<T>) -> Self {
606+
match value {
607+
PermitPDPClientError::Reqwest(e) => Self::Reqwest(e),
608+
PermitPDPClientError::Serde(e) => Self::Serde(e),
609+
PermitPDPClientError::Io(e) => Self::Io(e),
610+
PermitPDPClientError::ResponseError(e) => Self::ResponseError(ResponseContent {
611+
status: e.status,
612+
content: e.content,
613+
entity: format!("{:?}", e.entity),
614+
}),
615+
}
616+
}
617+
}

backends/src/test_utils/gateway.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
use std::sync::Arc;
22

3-
use anyhow::Error;
43
use async_trait::async_trait;
54
use permit_client_rs::models::UserRead;
65
use permit_pdp_client_rs::models::UserPermissionsResult;
@@ -12,7 +11,7 @@ use wiremock::{
1211
Mock, MockServer, Request, ResponseTemplate,
1312
};
1413

15-
use crate::client::PermissionsDal;
14+
use crate::client::{permit::Error, PermissionsDal};
1615

1716
pub async fn get_mocked_gateway_server() -> MockServer {
1817
let mock_server = MockServer::start().await;

backends/tests/integration/permit_tests.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
mod needs_docker {
22
use std::sync::OnceLock;
33

4+
use http::StatusCode;
45
use permit_client_rs::apis::{
56
resource_instances_api::{delete_resource_instance, list_resource_instances},
67
users_api::list_users,
78
};
89
use serial_test::serial;
9-
use shuttle_backends::client::{permit::Client, PermissionsDal};
10+
use shuttle_backends::client::{
11+
permit::{Client, Error, ResponseContent},
12+
PermissionsDal,
13+
};
1014
use shuttle_common::claims::AccountTier;
1115
use shuttle_common_tests::permit_pdp::DockerInstance;
1216
use test_context::{test_context, AsyncTestContext};
@@ -116,7 +120,13 @@ mod needs_docker {
116120
client.delete_user(u).await.unwrap();
117121
let res = client.get_user(u).await;
118122

119-
assert!(res.is_err());
123+
assert!(matches!(
124+
res,
125+
Err(Error::ResponseError(ResponseContent {
126+
status: StatusCode::NOT_FOUND,
127+
..
128+
}))
129+
));
120130
}
121131

122132
#[test_context(Wrap)]

common/src/claims.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,9 +169,9 @@ impl ScopeBuilder {
169169
)]
170170
#[serde(rename_all = "lowercase")]
171171
#[cfg_attr(feature = "display", derive(strum::Display))]
172+
#[cfg_attr(feature = "display", strum(serialize_all = "lowercase"))]
172173
#[cfg_attr(feature = "persist", derive(sqlx::Type))]
173174
#[cfg_attr(feature = "persist", sqlx(rename_all = "lowercase"))]
174-
#[cfg_attr(feature = "display", strum(serialize_all = "lowercase"))]
175175
pub enum AccountTier {
176176
#[default]
177177
Basic,
@@ -184,6 +184,23 @@ pub enum AccountTier {
184184
Deployer,
185185
}
186186

187+
impl AccountTier {
188+
/// The tier that this user should have in Permit.io.
189+
/// Permit should only store the tier that determines permissions,
190+
/// with the exception of 'admin', which is an override and not checked against Permit.
191+
pub fn as_permit_account_tier(&self) -> Self {
192+
match self {
193+
Self::Basic
194+
| Self::PendingPaymentPro
195+
| Self::CancelledPro
196+
| Self::Team
197+
| Self::Admin
198+
| Self::Deployer => Self::Basic,
199+
Self::Pro => Self::Pro,
200+
}
201+
}
202+
}
203+
187204
impl From<AccountTier> for Vec<Scope> {
188205
fn from(tier: AccountTier) -> Self {
189206
let mut builder = ScopeBuilder::new();

0 commit comments

Comments
 (0)