Skip to content
Closed
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
22 changes: 22 additions & 0 deletions migrations/V24__scope_grants.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
-- Scope grants: cross-user read/write access control.
--
-- A scope grant allows one user to read (or read+write) another user's
-- workspace data. The `scope` column is the target user_id whose data
-- becomes accessible. `writable = true` means the grantee can also
-- write to that scope (via writable memory layers).
--
-- Example: (user_id='andrew', scope='household', writable=true) means
-- Andrew can read and write data stored under the 'household' user scope.

CREATE TABLE IF NOT EXISTS scope_grants (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
scope TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
writable BOOLEAN NOT NULL DEFAULT FALSE,
granted_by TEXT REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ,
PRIMARY KEY (user_id, scope)
);

CREATE INDEX IF NOT EXISTS idx_scope_grants_user ON scope_grants(user_id);
CREATE INDEX IF NOT EXISTS idx_scope_grants_scope ON scope_grants(scope);
1 change: 1 addition & 0 deletions migrations/checksums.lock
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,4 @@ V20__pairing_requests = 17756233693090004940
V21__backfill_conversation_source_channel = 4041142068103384561
V22__sandbox_restart_params = 12611649486554869350
V23__list_workspace_files_escape_like = 12519024535161914473
V24__scope_grants = 9316053744612676705
188 changes: 187 additions & 1 deletion src/channels/web/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ pub struct UserIdentity {
pub role: String,
/// Additional user scopes this identity can read from.
pub workspace_read_scopes: Vec<String>,
/// Scopes this identity can write to (via writable memory layers).
/// Populated from scope grants with `writable = true`.
pub workspace_write_scopes: Vec<String>,
}

/// Hash a token with SHA-256 for constant-size, timing-safe storage.
Expand Down Expand Up @@ -110,6 +113,7 @@ impl MultiAuthState {
user_id,
role: "admin".to_string(),
workspace_read_scopes: Vec::new(),
workspace_write_scopes: Vec::new(),
},
)],
display_token: Some(token),
Expand Down Expand Up @@ -250,10 +254,40 @@ impl DbAuthenticator {
}
};

// Resolve cross-scope access from DB scope grants.
// On failure, degrade to empty scopes (auth still succeeds).
// Filter out expired grants so they stop taking effect immediately.
let now = chrono::Utc::now();
let grants: Vec<_> = self
.store
.list_scope_grants(&user_record.id)
.await
.unwrap_or_else(|e| {
tracing::warn!(
user_id = %user_record.id,
"Failed to load scope grants during auth: {e}"
);
Vec::new()
})
.into_iter()
.filter(|g| match g.expires_at {
Some(exp) => exp > now,
None => true,
})
.collect();
let workspace_read_scopes: Vec<String> =
grants.iter().map(|g| g.scope.clone()).collect();
let workspace_write_scopes: Vec<String> = grants
.iter()
.filter(|g| g.writable)
.map(|g| g.scope.clone())
.collect();

let identity = UserIdentity {
user_id: user_record.id.clone(),
role: user_record.role.clone(),
workspace_read_scopes: Vec::new(),
workspace_read_scopes,
workspace_write_scopes,
};

// Record token usage (best-effort, don't block auth)
Expand Down Expand Up @@ -1073,6 +1107,7 @@ pub async fn auth_middleware(
user_id: sub,
role: "member".to_string(),
workspace_read_scopes: Vec::new(),
workspace_write_scopes: Vec::new(),
};
request.extensions_mut().insert(identity);
return next.run(request).await;
Expand Down Expand Up @@ -1116,6 +1151,7 @@ mod tests {
user_id: "alice".to_string(),
role: "admin".to_string(),
workspace_read_scopes: Vec::new(),
workspace_write_scopes: Vec::new(),
},
);
tokens.insert(
Expand All @@ -1124,6 +1160,7 @@ mod tests {
user_id: "bob".to_string(),
role: "admin".to_string(),
workspace_read_scopes: Vec::new(),
workspace_write_scopes: Vec::new(),
},
);
let state = MultiAuthState::multi(tokens);
Expand Down Expand Up @@ -1724,6 +1761,7 @@ mod tests {
user_id: "alice".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec!["shared".to_string()],
workspace_write_scopes: vec![],
},
);
tokens.insert(
Expand All @@ -1732,6 +1770,7 @@ mod tests {
user_id: "bob".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec!["shared".to_string(), "alice".to_string()],
workspace_write_scopes: vec![],
},
);
tokens
Expand Down Expand Up @@ -2461,4 +2500,151 @@ mod tests {
"should attempt fetch, not backoff: {err_msg}"
);
}

// ── DbAuthenticator scope grant filtering tests ─────────────────────

#[cfg(feature = "libsql")]
mod db_auth_scope_grants {
use super::*;
use crate::db::Database;

async fn setup_db_auth() -> (
DbAuthenticator,
Arc<dyn Database>,
tempfile::TempDir,
) {
let (db, dir) = crate::testing::test_db().await;
let now = chrono::Utc::now();
let user = crate::db::UserRecord {
id: "alice".to_string(),
email: None,
display_name: "Alice".to_string(),
status: "active".to_string(),
role: "member".to_string(),
created_at: now,
updated_at: now,
last_login_at: None,
created_by: None,
metadata: serde_json::Value::Null,
};
db.create_user(&user).await.unwrap();

let token = "alice-test-token-123";
let hash = hash_token(token);
db.create_api_token("alice", "test", &hash, &token[..8], None)
.await
.unwrap();

let auth = DbAuthenticator::new(Arc::clone(&db));
(auth, db, dir)
}

#[tokio::test]
async fn expired_grants_filtered_during_auth() {
let (auth, db, _dir) = setup_db_auth().await;

// One current grant, one expired
let future = chrono::Utc::now() + chrono::Duration::hours(1);
let past = chrono::Utc::now() - chrono::Duration::hours(1);
db.set_scope_grant("alice", "current-scope", false, Some("admin"), Some(future))
.await
.unwrap();
db.set_scope_grant("alice", "expired-scope", false, Some("admin"), Some(past))
.await
.unwrap();

let identity = auth
.authenticate("alice-test-token-123")
.await
.unwrap()
.unwrap();

assert!(
identity.workspace_read_scopes.contains(&"current-scope".to_string()),
"current grant should be in read scopes: {:?}",
identity.workspace_read_scopes
);
assert!(
!identity.workspace_read_scopes.contains(&"expired-scope".to_string()),
"expired grant should NOT be in read scopes: {:?}",
identity.workspace_read_scopes
);
}

#[tokio::test]
async fn all_expired_grants_result_in_empty_scopes() {
let (auth, db, _dir) = setup_db_auth().await;

let past = chrono::Utc::now() - chrono::Duration::hours(1);
db.set_scope_grant("alice", "scope-a", false, Some("admin"), Some(past))
.await
.unwrap();
db.set_scope_grant("alice", "scope-b", true, Some("admin"), Some(past))
.await
.unwrap();

let identity = auth
.authenticate("alice-test-token-123")
.await
.unwrap()
.unwrap();

assert!(
identity.workspace_read_scopes.is_empty(),
"all-expired grants should result in empty read scopes: {:?}",
identity.workspace_read_scopes
);
assert!(
identity.workspace_write_scopes.is_empty(),
"all-expired grants should result in empty write scopes: {:?}",
identity.workspace_write_scopes
);
}

#[tokio::test]
async fn writable_grant_populates_write_scopes() {
let (auth, db, _dir) = setup_db_auth().await;

db.set_scope_grant("alice", "readonly-scope", false, Some("admin"), None)
.await
.unwrap();
db.set_scope_grant("alice", "writable-scope", true, Some("admin"), None)
.await
.unwrap();

let identity = auth
.authenticate("alice-test-token-123")
.await
.unwrap()
.unwrap();

// Both should be in read scopes
assert!(identity.workspace_read_scopes.contains(&"readonly-scope".to_string()));
assert!(identity.workspace_read_scopes.contains(&"writable-scope".to_string()));

// Only writable should be in write scopes
assert!(
!identity.workspace_write_scopes.contains(&"readonly-scope".to_string()),
"read-only grant should not be in write scopes"
);
assert!(
identity.workspace_write_scopes.contains(&"writable-scope".to_string()),
"writable grant should be in write scopes"
);
}

#[tokio::test]
async fn no_grants_result_in_empty_scopes() {
let (auth, _db, _dir) = setup_db_auth().await;

let identity = auth
.authenticate("alice-test-token-123")
.await
.unwrap()
.unwrap();

assert!(identity.workspace_read_scopes.is_empty());
assert!(identity.workspace_write_scopes.is_empty());
}
}
}
1 change: 1 addition & 0 deletions src/channels/web/handlers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ pub mod jobs;
pub mod llm;
pub mod memory;
pub mod routines;
pub mod scope_grants;
pub mod secrets;
pub mod skills;
pub mod system_prompt;
Expand Down
Loading
Loading