Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions migrations/067_team_policy.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
ALTER TABLE teams
ADD COLUMN IF NOT EXISTS policy JSONB NULL,
ADD COLUMN IF NOT EXISTS policy_revision BIGINT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS policy_updated_at TIMESTAMPTZ NULL,
ADD COLUMN IF NOT EXISTS policy_updated_by UUID NULL REFERENCES users(id) ON DELETE SET NULL;

UPDATE teams SET policy = jsonb_build_object('version', 1, 'policy', jsonb_build_object('security.lockTimeoutMinutes', jsonb_build_object('$range', jsonb_build_object('max', lock_max_minutes))) || CASE WHEN lock_force_vault THEN jsonb_build_object('security.lockAction', 'vault') ELSE '{}'::jsonb END), policy_revision = 1 WHERE lock_max_minutes IS NOT NULL AND policy IS NULL;
45 changes: 44 additions & 1 deletion src/db.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
use sqlx::migrate::Migrator;
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use tracing::{error, info};

/// Ignores applied versions this build lacks, so a rollback to an older image still boots.
pub(crate) fn migrator() -> Migrator {
let mut migrator = sqlx::migrate!("./migrations");
migrator.set_ignore_missing(true);
migrator
}

pub async fn create_pool() -> PgPool {
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");

Expand All @@ -18,7 +26,7 @@ pub async fn create_pool() -> PgPool {

info!("PostgreSQL connection pool is ready");

sqlx::migrate!("./migrations")
migrator()
.run(&pool)
.await
.unwrap_or_else(|err| {
Expand All @@ -28,10 +36,21 @@ pub async fn create_pool() -> PgPool {
info!("Migrations applied successfully");

reconcile_legacy_grants(&pool).await;
backfill_team_policies(&pool).await;

pool
}

pub(crate) async fn backfill_team_policies(pool: &PgPool) {
match sqlx::query(crate::team_policy::LOCK_POLICY_BACKFILL)
.execute(pool)
.await
{
Ok(res) => info!(filled = res.rows_affected(), "Backfilled team policy documents"),
Err(err) => error!(error = %err, "Failed to backfill team policy documents"),
}
}

/// Migration 037's backfill is one-shot: a rollback-then-forward-again cycle
/// can leave live invite_link sessions with an `invite_token` but no grant,
/// since nothing reads that column outside the grants path any more. Runs
Expand Down Expand Up @@ -120,4 +139,28 @@ mod tests {
.unwrap();
assert_eq!(count, 1, "second run must insert nothing new");
}

#[tokio::test]
async fn migrator_boots_past_versions_this_build_lacks() {
let pool = test_pool_or_skip!();
let mut tx = pool.begin().await.unwrap();
sqlx::query(
"INSERT INTO _sqlx_migrations (version, description, success, checksum, execution_time) \
VALUES (9000000000000000000, 'from a newer image', TRUE, '\\x00'::bytea, 0)",
)
.execute(&mut *tx)
.await
.unwrap();

let mut strict = sqlx::migrate!("./migrations");
strict.set_locking(false);
assert!(matches!(
strict.run(&mut *tx).await,
Err(sqlx::migrate::MigrateError::VersionMissing(9000000000000000000))
));
let mut lenient = migrator();
lenient.set_locking(false);
lenient.run(&mut *tx).await.unwrap();
tx.rollback().await.unwrap();
}
}
7 changes: 7 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ mod session_grants;
mod team_authority;
mod team_join_grants;
mod team_plan;
mod team_policy;
mod vault_key_exposure;
mod sync_notifier;
mod terminal_manager;
Expand Down Expand Up @@ -456,6 +457,12 @@ async fn main() {
"/v1/teams/:team_id/lock-policy",
put(routes::team_lock_policy::set_lock_policy).delete(routes::team_lock_policy::clear_lock_policy),
)
.route(
"/v1/teams/:team_id/policy",
get(routes::team_policy::get_policy)
.put(routes::team_policy::put_policy)
.delete(routes::team_policy::delete_policy),
)
.route(
"/v1/teams/:team_id/members/:user_id",
delete(routes::teams::remove_member),
Expand Down
1 change: 1 addition & 0 deletions src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ pub mod session_codes;
pub mod sync;
pub mod team_grants;
pub mod team_lock_policy;
pub mod team_policy;
pub mod team_sync;
pub mod team_objects;
pub mod team_object_prefs;
Expand Down
89 changes: 14 additions & 75 deletions src/routes/team_lock_policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,16 @@ use axum::{
http::StatusCode,
Extension, Json,
};
use serde::{Deserialize, Serialize};
use serde_json::json;
use sqlx::PgPool;
use tracing::error;
use uuid::Uuid;

use crate::auth::AuthUser;
use crate::routes::audit::write_audit_event;
use crate::routes::teams::{notify_team_members_changed, require_vault_manager};
use crate::routes::team_policy::{announce, LockedPolicy};
use crate::routes::teams::require_vault_manager;
use crate::sync_notifier::SyncNotifier;
use crate::team_plan::require_granular;

const LOCK_MINUTES: [i32; 6] = [0, 5, 15, 30, 60, 240];

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct LockPolicy {
pub max_minutes: i32,
pub force_vault: bool,
}

impl LockPolicy {
pub fn from_columns(max_minutes: Option<i32>, force_vault: bool) -> Option<Self> {
max_minutes.map(|max_minutes| Self {
max_minutes,
force_vault,
})
}
}
use crate::team_policy::{with_lock_policy, LockPolicy, LOCK_MINUTES};

pub async fn set_lock_policy(
State(pool): State<PgPool>,
Expand Down Expand Up @@ -65,54 +47,28 @@ async fn write_policy(
actor: Uuid,
policy: Option<LockPolicy>,
) -> Result<StatusCode, StatusCode> {
sqlx::query("UPDATE teams SET lock_max_minutes = $2, lock_force_vault = $3 WHERE id = $1")
.bind(team_id)
.bind(policy.map(|p| p.max_minutes))
.bind(policy.is_some_and(|p| p.force_vault))
.execute(pool)
.await
.map_err(|e| {
error!(error = %e, team_id = %team_id, "Failed to write team lock policy");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let mut locked = LockedPolicy::lock(pool, team_id).await?;
let doc = with_lock_policy(locked.doc.take(), policy);
locked.replace(actor, doc.as_ref()).await?;
let (action, metadata) = match policy {
Some(p) => ("team.lock_policy_set", Some(json!(p))),
None => ("team.lock_policy_removed", None),
};
write_audit_event(
pool.clone(),
team_id,
actor,
action,
Some("team"),
Some(team_id.to_string()),
None,
metadata,
)
.await;
notify_team_members_changed(pool, notifier, team_id).await;
announce(pool, notifier, team_id, actor, action, metadata).await;
Ok(StatusCode::NO_CONTENT)
}

#[cfg(test)]
mod tests {
pub(crate) mod tests {
use super::*;
use crate::permissions::{PERM_MANAGE_MEMBERS, PERM_MANAGE_VAULT};
use crate::routes::teams::list_teams;
use crate::sync_notifier::SyncEvent;
use crate::test_support::{
member_with_role, seed_team_with_roles, seed_user, set_user_tier, BillingMode,
listed_team, member_with_role, seed_team_with_roles, seed_user, set_user_tier,
team_audit_count as audit_count, team_members_told, BillingMode,
};

async fn listed(pool: &PgPool, team: Uuid, user: Uuid) -> Option<LockPolicy> {
let Json(teams) = list_teams(State(pool.clone()), Extension(AuthUser(user)))
.await
.unwrap();
teams
.into_iter()
.find(|t| t.id == team)
.unwrap()
.lock_policy
listed_team(pool, team, user).await.lock_policy
}

#[tokio::test]
Expand All @@ -139,7 +95,7 @@ mod tests {
);
}

async fn put(
pub(crate) async fn put(
pool: &PgPool,
team: Uuid,
user: Uuid,
Expand All @@ -159,7 +115,7 @@ mod tests {
.await
}

async fn clear(pool: &PgPool, team: Uuid, user: Uuid) -> Result<StatusCode, StatusCode> {
pub(crate) async fn clear(pool: &PgPool, team: Uuid, user: Uuid) -> Result<StatusCode, StatusCode> {
clear_lock_policy(
State(pool.clone()),
Extension(AuthUser(user)),
Expand All @@ -169,15 +125,6 @@ mod tests {
.await
}

async fn audit_count(pool: &PgPool, team: Uuid, action: &str) -> i64 {
sqlx::query_scalar("SELECT count(*) FROM audit_logs WHERE team_id = $1 AND action = $2")
.bind(team)
.bind(action)
.fetch_one(pool)
.await
.unwrap()
}

#[tokio::test]
async fn owner_sets_and_clears_the_policy_every_member_lists() {
let _env = BillingMode::self_hosted();
Expand Down Expand Up @@ -328,15 +275,7 @@ mod tests {
.await
.unwrap();

let want = format!("team_members:{team}");
let mut told = std::collections::HashSet::new();
while let Ok(ev) = rx.try_recv() {
if let SyncEvent::BlobPushed { user_id, device_id } = ev {
if device_id == want {
told.insert(user_id);
}
}
}
let told = team_members_told(&mut rx, team);
assert!(told.contains(&owner) && told.contains(&member));
}
}
Loading
Loading