Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
bf5d700
feat(perms): add per-member permission override table
kipavy Sep 10, 2026
60f35d1
refactor(perms): share one effective-permission query between both re…
kipavy Sep 10, 2026
efa9c1a
feat(perms): apply per-member allow/deny overlay in the team resolver
kipavy Sep 10, 2026
d9d9069
fix(perms): resolve multi-team permission checks per team, not across…
kipavy Sep 10, 2026
264ac3a
feat(teams): serve per-member permission masks on the team and member…
kipavy Sep 10, 2026
c59f5ab
feat(teams): add endpoint to set a member's permission overrides
kipavy Sep 10, 2026
f2d001a
feat(teams): guard permission-override writes by ownership, self, gra…
kipavy Sep 10, 2026
4e8e4d9
test(teams): pin the self-edit guardrail with a role-holding actor
kipavy Sep 10, 2026
b61ce3c
test(teams): pin owner immunity independently and cover the roleless-…
kipavy Sep 10, 2026
ad217ef
test(teams): catch a swapped actor/target at the permissions handler …
kipavy Sep 10, 2026
a047a20
feat(teams): queue a key rotation when a read-class permission is denied
kipavy Sep 10, 2026
bda6356
fix(teams): make the permission write and its rotation request atomic
kipavy Sep 10, 2026
b19a1ec
fix(terminal): route vault-session visibility through the permission …
kipavy Sep 11, 2026
2f68fde
fix(teams): delete member role rows on removal
kipavy Sep 11, 2026
622dba3
fix(teams): trigger key rotation on lost effective read access, not j…
kipavy Sep 11, 2026
d7a7efe
fix(teams): make override_guardrails' escalation check symmetric
kipavy Sep 11, 2026
d9db0bc
refactor(permissions): drop the body-less effective_permissions_for w…
kipavy Sep 11, 2026
e3eedff
docs(teams): note why a roleless actor has no position
kipavy Sep 11, 2026
96450b9
fix(teams): rotate only when the vault-key gate is actually crossed
kipavy Sep 11, 2026
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
11 changes: 11 additions & 0 deletions migrations/042_team_member_permission_overrides.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
CREATE TABLE team_member_permission_overrides (
team_id UUID NOT NULL,
user_id UUID NOT NULL,
allow_mask BIGINT NOT NULL DEFAULT 0,
deny_mask BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_by UUID NOT NULL REFERENCES users(id),
PRIMARY KEY (team_id, user_id),
FOREIGN KEY (team_id, user_id)
REFERENCES team_members(team_id, user_id) ON DELETE CASCADE
);
4 changes: 4 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,10 @@ async fn main() {
"/v1/teams/:team_id/members/:user_id/roles/:role_id",
delete(routes::teams::remove_member_role),
)
.route(
"/v1/teams/:team_id/members/:user_id/permissions",
put(routes::teams::set_member_permissions),
)
.route("/v1/users/search", get(routes::teams::search_users))
.route(
"/v1/users/:user_id/public-key",
Expand Down
2 changes: 2 additions & 0 deletions src/models/team.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,6 @@ pub struct TeamMember {
pub invited_by_display_name: Option<String>,
pub joined_at: DateTime<Utc>,
pub role_ids: Vec<Uuid>,
pub permission_allow: i64,
pub permission_deny: i64,
}
245 changes: 208 additions & 37 deletions src/permissions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,29 +51,38 @@ pub const BUILTIN_ROLES: &[(&str, i64, i32)] = &[
("connect-only", 28676, 4), // no edit perms today
];

/// Union of all role permission bits granted to (team_id, user_id).
/// Returns 0 if the user has no roles in the team (or is not a member).
async fn effective_permissions(
const PERMISSION_JOINS: &str = r#"
FROM team_members tm
LEFT JOIN team_member_roles tmr ON tmr.team_id = tm.team_id AND tmr.user_id = tm.user_id
LEFT JOIN team_roles tr ON tr.id = tmr.role_id
LEFT JOIN team_member_permission_overrides o
ON o.team_id = tm.team_id AND o.user_id = tm.user_id
"#;

// MAX pulls the single override row (join is one-to-at-most-one) into the aggregate.
const EFFECTIVE_EXPR: &str = "(COALESCE(bit_or(tr.permissions), 0) | COALESCE(MAX(o.allow_mask), 0)) \
& ~COALESCE(MAX(o.deny_mask), 0)";

/// `(roleUnion | allow) & ~deny`. Returns 0 if the user is not a member.
pub async fn effective_permissions(
pool: &PgPool,
team_id: Uuid,
user_id: Uuid,
) -> Result<i64, StatusCode> {
sqlx::query_scalar::<_, i64>(
r#"
SELECT COALESCE(bit_or(tr.permissions), 0)
FROM team_member_roles tmr
JOIN team_roles tr ON tr.id = tmr.role_id
WHERE tmr.team_id = $1 AND tmr.user_id = $2
"#,
)
.bind(team_id)
.bind(user_id)
.fetch_one(pool)
.await
.map_err(|e| {
error!(error = %e, team_id = %team_id, user_id = %user_id, "Failed to check team permission");
StatusCode::INTERNAL_SERVER_ERROR
})
let sql = format!(
"SELECT {EFFECTIVE_EXPR} {PERMISSION_JOINS} \
WHERE tm.team_id = $1 AND tm.user_id = $2"
);
sqlx::query_scalar::<_, i64>(&sql)
.bind(team_id)
.bind(user_id)
.fetch_optional(pool)
.await
.map(|v| v.unwrap_or(0))
.map_err(|e| {
error!(error = %e, team_id = %team_id, user_id = %user_id, "Failed to check team permission");
StatusCode::INTERNAL_SERVER_ERROR
})
}

/// Returns true if any of (team_id, user_id)'s roles grant `permission`.
Expand Down Expand Up @@ -171,24 +180,26 @@ pub async fn has_any_team_permission(
if team_ids.is_empty() {
return Ok(false);
}
let effective = sqlx::query_scalar::<_, i64>(
r#"
SELECT COALESCE(bit_or(tr.permissions), 0)
FROM team_member_roles tmr
JOIN team_roles tr ON tr.id = tmr.role_id
WHERE tmr.team_id = ANY($1) AND tmr.user_id = $2
"#,
)
.bind(team_ids)
.bind(user_id)
.fetch_one(pool)
.await
.map_err(|e| {
error!(error = %e, user_id = %user_id, "Failed to check any-team permission");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let sql = format!(
"SELECT COALESCE(bool_or(granted), false) FROM ( \
SELECT (({EFFECTIVE_EXPR}) & $3) <> 0 AS granted \
{PERMISSION_JOINS} \
WHERE tm.team_id = ANY($1) AND tm.user_id = $2 \
GROUP BY tm.team_id \
) per_team"
);
let granted = sqlx::query_scalar::<_, bool>(&sql)
.bind(team_ids)
.bind(user_id)
.bind(permission)
.fetch_one(pool)
.await
.map_err(|e| {
error!(error = %e, user_id = %user_id, "Failed to check any-team permission");
StatusCode::INTERNAL_SERVER_ERROR
})?;

Ok((effective & permission) != 0)
Ok(granted)
}

#[cfg(test)]
Expand All @@ -200,7 +211,7 @@ mod db_tests {
//! Requires `TEST_DATABASE_URL`; otherwise each test skips.
use super::*;
use crate::test_pool_or_skip;
use crate::test_support::{add_member, assign_role, seed_role, seed_team, seed_user};
use crate::test_support::{add_member, assign_role, seed_role, seed_team, seed_user, set_member_overrides};

#[tokio::test]
async fn has_team_permission_reflects_granted_bit() {
Expand Down Expand Up @@ -321,4 +332,164 @@ mod db_tests {
.unwrap()
);
}

#[tokio::test]
async fn overrides_cascade_when_member_is_removed() {
let pool = test_pool_or_skip!();
let owner = seed_user(&pool).await;
let member = seed_user(&pool).await;
let team = seed_team(&pool, owner).await;
add_member(&pool, team, member).await;
set_member_overrides(&pool, team, member, PERM_VIEW_SECRETS, 0).await;

sqlx::query("DELETE FROM team_members WHERE team_id = $1 AND user_id = $2")
.bind(team)
.bind(member)
.execute(&pool)
.await
.unwrap();

let remaining: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM team_member_permission_overrides WHERE team_id = $1 AND user_id = $2",
)
.bind(team)
.bind(member)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(remaining, 0);
}

#[tokio::test]
async fn allow_override_grants_a_bit_no_role_provides() {
let pool = test_pool_or_skip!();
let user = seed_user(&pool).await;
let team = seed_team(&pool, user).await;
let role = seed_role(&pool, team, "r", PERM_CONNECT).await;
add_member(&pool, team, user).await;
assign_role(&pool, team, user, role).await;
crate::test_support::set_member_overrides(&pool, team, user, PERM_VIEW_SECRETS, 0).await;

assert!(has_team_permission(&pool, team, user, PERM_VIEW_SECRETS).await.unwrap());
assert!(has_team_permission(&pool, team, user, PERM_CONNECT).await.unwrap());
}

#[tokio::test]
async fn allow_override_works_for_a_member_with_no_roles() {
let pool = test_pool_or_skip!();
let owner = seed_user(&pool).await;
let member = seed_user(&pool).await;
let team = seed_team(&pool, owner).await;
add_member(&pool, team, member).await;
crate::test_support::set_member_overrides(&pool, team, member, PERM_CONNECT, 0).await;

assert!(has_team_permission(&pool, team, member, PERM_CONNECT).await.unwrap());
}

#[tokio::test]
async fn deny_override_beats_a_granting_role() {
let pool = test_pool_or_skip!();
let user = seed_user(&pool).await;
let team = seed_team(&pool, user).await;
let role = seed_role(&pool, team, "r", PERM_VIEW_SECRETS | PERM_CONNECT).await;
add_member(&pool, team, user).await;
assign_role(&pool, team, user, role).await;
crate::test_support::set_member_overrides(&pool, team, user, 0, PERM_VIEW_SECRETS).await;

assert!(!has_team_permission(&pool, team, user, PERM_VIEW_SECRETS).await.unwrap());
assert!(has_team_permission(&pool, team, user, PERM_CONNECT).await.unwrap());
}

#[tokio::test]
async fn deny_survives_a_newly_assigned_role_granting_the_same_bit() {
let pool = test_pool_or_skip!();
let user = seed_user(&pool).await;
let team = seed_team(&pool, user).await;
let role_a = seed_role(&pool, team, "a", PERM_CONNECT).await;
add_member(&pool, team, user).await;
assign_role(&pool, team, user, role_a).await;
crate::test_support::set_member_overrides(&pool, team, user, 0, PERM_VIEW_SECRETS).await;

let role_b = seed_role(&pool, team, "b", PERM_VIEW_SECRETS).await;
assign_role(&pool, team, user, role_b).await;

assert!(!has_team_permission(&pool, team, user, PERM_VIEW_SECRETS).await.unwrap());
}

#[tokio::test]
async fn deny_wins_when_a_bit_is_in_both_masks() {
let pool = test_pool_or_skip!();
let user = seed_user(&pool).await;
let team = seed_team(&pool, user).await;
add_member(&pool, team, user).await;
crate::test_support::set_member_overrides(
&pool, team, user, PERM_VIEW_SECRETS, PERM_VIEW_SECRETS,
)
.await;

assert!(!has_team_permission(&pool, team, user, PERM_VIEW_SECRETS).await.unwrap());
}

#[tokio::test]
async fn deny_in_one_team_does_not_suppress_a_grant_in_another() {
let pool = test_pool_or_skip!();
let user = seed_user(&pool).await;
let team_a = seed_team(&pool, user).await;
let team_b = seed_team(&pool, user).await;

let role_a = seed_role(&pool, team_a, "a", PERM_VIEW_AUDIT_LOG).await;
add_member(&pool, team_a, user).await;
assign_role(&pool, team_a, user, role_a).await;
crate::test_support::set_member_overrides(&pool, team_a, user, 0, PERM_VIEW_AUDIT_LOG).await;

let role_b = seed_role(&pool, team_b, "b", PERM_VIEW_AUDIT_LOG).await;
add_member(&pool, team_b, user).await;
assign_role(&pool, team_b, user, role_b).await;

assert!(
has_any_team_permission(&pool, &[team_a, team_b], user, PERM_VIEW_AUDIT_LOG)
.await
.unwrap(),
"team_b still grants the bit; team_a's deny must not reach across teams"
);
assert!(
!has_team_permission(&pool, team_a, user, PERM_VIEW_AUDIT_LOG).await.unwrap(),
"team_a's own deny still applies inside team_a"
);
}

#[tokio::test]
async fn deny_override_applies_to_multi_team_checks() {
let pool = test_pool_or_skip!();
let user = seed_user(&pool).await;
let team = seed_team(&pool, user).await;
let role = seed_role(&pool, team, "r", PERM_VIEW_AUDIT_LOG).await;
add_member(&pool, team, user).await;
assign_role(&pool, team, user, role).await;
crate::test_support::set_member_overrides(&pool, team, user, 0, PERM_VIEW_AUDIT_LOG).await;

assert!(
!has_any_team_permission(&pool, &[team], user, PERM_VIEW_AUDIT_LOG)
.await
.unwrap(),
"a deny override must be honoured on the multi-team path, not only the single-team one"
);
}

#[tokio::test]
async fn allow_override_applies_to_multi_team_checks() {
let pool = test_pool_or_skip!();
let owner = seed_user(&pool).await;
let member = seed_user(&pool).await;
let team = seed_team(&pool, owner).await;
add_member(&pool, team, member).await;
crate::test_support::set_member_overrides(&pool, team, member, PERM_VIEW_AUDIT_LOG, 0).await;

assert!(
has_any_team_permission(&pool, &[team], member, PERM_VIEW_AUDIT_LOG)
.await
.unwrap(),
"an allow override must grant on the multi-team path"
);
}
}
Loading