Skip to content

Commit c07ade3

Browse files
committed
libsql-server: enforce namespace authorization on gRPC replication RPCs
The replication path validated the JWT signature but discarded the returned Authenticated, so any valid token could replicate any namespace regardless of its ns claim. Check is_namespace_authorized against the served namespace and add a negative cross-namespace test.
1 parent 30f4d57 commit c07ade3

2 files changed

Lines changed: 121 additions & 1 deletion

File tree

  • libsql-server

libsql-server/src/rpc/replication/auth.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,12 @@ pub async fn authenticate<T>(
3939
if let Some(auth) = auth {
4040
let context = parse_grpc_auth_header(req.metadata(), &auth.user_strategy.required_fields())
4141
.map_err(|e| tonic::Status::internal(format!("Error parsing auth header: {}", e)))?;
42-
auth.authenticate(context)?;
42+
let authenticated = auth.authenticate(context)?;
43+
if !authenticated.is_namespace_authorized(&namespace) {
44+
return Err(tonic::Status::permission_denied(
45+
"not authorized to replicate this namespace",
46+
));
47+
}
4348
}
4449

4550
Ok(())

libsql-server/tests/embedded_replica/mod.rs

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1796,3 +1796,118 @@ fn remote_replica_namespace_header_support() {
17961796

17971797
sim.run().unwrap();
17981798
}
1799+
1800+
#[test]
1801+
fn replicate_cross_namespace_denied() {
1802+
init_tracing();
1803+
let mut sim = Builder::new()
1804+
.simulation_duration(Duration::from_secs(1000))
1805+
.build();
1806+
1807+
let (encoding, decoding) = common::auth::key_pair();
1808+
sim.host("primary", {
1809+
let decoding = decoding.clone();
1810+
move || {
1811+
let decoding = decoding.clone();
1812+
async move {
1813+
let tmp = tempdir()?;
1814+
let jwt_keys =
1815+
vec![jsonwebtoken::DecodingKey::from_ed_components(&decoding).unwrap()];
1816+
let auth = Auth::new(user_auth_strategies::Jwt::new(jwt_keys));
1817+
let server = TestServer {
1818+
path: tmp.path().to_owned().into(),
1819+
user_api_config: UserApiConfig {
1820+
hrana_ws_acceptor: None,
1821+
auth_strategy: auth,
1822+
..Default::default()
1823+
},
1824+
admin_api_config: Some(AdminApiConfig {
1825+
acceptor: TurmoilAcceptor::bind(([0, 0, 0, 0], 9090)).await?,
1826+
connector: TurmoilConnector,
1827+
disable_metrics: true,
1828+
auth_key: None,
1829+
}),
1830+
rpc_server_config: Some(RpcServerConfig {
1831+
acceptor: TurmoilAcceptor::bind(([0, 0, 0, 0], 4567)).await?,
1832+
tls_config: None,
1833+
}),
1834+
disable_namespaces: false,
1835+
disable_default_namespace: true,
1836+
..Default::default()
1837+
};
1838+
1839+
server.start_sim(8080).await?;
1840+
1841+
Ok(())
1842+
}
1843+
}
1844+
});
1845+
1846+
sim.client("client", async move {
1847+
// Create two distinct namespaces.
1848+
let client = Client::new();
1849+
client
1850+
.post("http://primary:9090/v1/namespaces/ns1/create", json!({}))
1851+
.await?;
1852+
client
1853+
.post("http://primary:9090/v1/namespaces/ns2/create", json!({}))
1854+
.await?;
1855+
1856+
// Token scoped to ns1 only.
1857+
let ns1_token = encode(
1858+
&serde_json::json!({
1859+
"p": { "ro": { "ns": ["ns1"] } },
1860+
}),
1861+
&encoding,
1862+
);
1863+
1864+
// Sanity: ns1 token works against ns1.
1865+
let tmp = tempdir().unwrap();
1866+
let db = Database::open_with_remote_sync_connector(
1867+
tmp.path().join("embedded-ns1").to_str().unwrap(),
1868+
"http://ns1.primary:8080",
1869+
ns1_token.clone(),
1870+
TurmoilConnector,
1871+
false,
1872+
None,
1873+
)
1874+
.await?;
1875+
assert!(db.sync().await.is_ok());
1876+
1877+
// Negative: ns1 token must NOT replicate ns2.
1878+
let tmp = tempdir().unwrap();
1879+
let db = Database::open_with_remote_sync_connector(
1880+
tmp.path().join("embedded-ns2").to_str().unwrap(),
1881+
"http://ns2.primary:8080",
1882+
ns1_token.clone(),
1883+
TurmoilConnector,
1884+
false,
1885+
None,
1886+
)
1887+
.await?;
1888+
assert!(db.sync().await.is_err());
1889+
1890+
// Token scoped to both namespaces can replicate both (control).
1891+
let both_token = encode(
1892+
&serde_json::json!({
1893+
"p": { "ro": { "ns": ["ns1", "ns2"] } },
1894+
}),
1895+
&encoding,
1896+
);
1897+
let tmp = tempdir().unwrap();
1898+
let db = Database::open_with_remote_sync_connector(
1899+
tmp.path().join("embedded-ns2-both").to_str().unwrap(),
1900+
"http://ns2.primary:8080",
1901+
both_token.clone(),
1902+
TurmoilConnector,
1903+
false,
1904+
None,
1905+
)
1906+
.await?;
1907+
assert!(db.sync().await.is_ok());
1908+
1909+
Ok(())
1910+
});
1911+
1912+
sim.run().unwrap();
1913+
}

0 commit comments

Comments
 (0)