#896 kanidm_unixd UX updoots (#1024)

This commit is contained in:
James Hodgkinson 2022-09-08 13:37:03 +10:00 committed by GitHub
parent 8416069c61
commit 66954213db
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 255 additions and 98 deletions

View file

@ -0,0 +1,5 @@
# This should be at /etc/kanidm/config or ~/.config/kanidm, and configures the kanidm command line tool
# to point at the local dev server and ignore TLS security.
uri = "https://localhost:8443"
verify_ca = false
verify_hostnames = false

12
examples/unixd.macos Normal file
View file

@ -0,0 +1,12 @@
# this example configures kanidm-unixd for testing on macos
db_path = "/tmp/kanidm-unixd"
sock_path = "/tmp/kanimd_unixd.sock"
task_sock_path = "/tmp/kanimd_unidx_task.sock"
# some documentation is here: https://github.com/kanidm/kanidm/blob/master/kanidm_book/src/pam_and_nsswitch.md
pam_allowed_login_groups = ["posix_group"]
# default_shell = "/bin/sh"
# home_prefix = "/home/"
# home_attr = "uuid"
# home_alias = "spn"
# uid_attr_map = "spn"
# gid_attr_map = "spn"

View file

@ -18,6 +18,7 @@ use serde::de::DeserializeOwned;
use serde::Deserialize;
use serde::Serialize;
use serde_json::error::Error as SerdeJsonError;
use std::fmt::{Display, Formatter};
use std::fs::File;
#[cfg(target_family = "unix")] // not needed for windows builds
use std::fs::{metadata, Metadata};
@ -81,6 +82,26 @@ pub struct KanidmClientBuilder {
use_system_proxies: bool,
}
impl Display for KanidmClientBuilder {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match &self.address {
Some(value) => writeln!(f, "address: {}", value)?,
None => writeln!(f, "address: unset")?,
}
writeln!(f, "verify_ca: {}", self.verify_ca)?;
writeln!(f, "verify_hostnames: {}", self.verify_hostnames)?;
match &self.ca {
Some(value) => writeln!(f, "ca: {:#?}", value)?,
None => writeln!(f, "ca: unset")?,
}
match self.connect_timeout {
Some(value) => writeln!(f, "connect_timeout: {}", value)?,
None => writeln!(f, "connect_timeout: unset")?,
}
writeln!(f, "use_system_proxies: {}", self.use_system_proxies)
}
}
#[derive(Debug)]
pub struct KanidmClient {
pub(crate) client: reqwest::Client,

View file

@ -0,0 +1,6 @@
/// Because consistency is great!
/// The "system" path for Kanidm client config
pub const DEFAULT_CLIENT_CONFIG_PATH: &str = "/etc/kanidm/config";
/// The user-owned path for Kanidm client config
pub const DEFAULT_CLIENT_CONFIG_PATH_HOME: &str = "~/.config/kanidm";

View file

@ -8,6 +8,7 @@
#![deny(clippy::needless_pass_by_value)]
#![deny(clippy::trivially_copy_pass_by_ref)]
pub mod constants;
pub mod messages;
pub mod oauth2;
pub mod utils;

View file

@ -3,23 +3,24 @@ use crate::CommonOpt;
use compact_jwt::{Jws, JwsUnverified};
use dialoguer::{theme::ColorfulTheme, Select};
use kanidm_client::{KanidmClient, KanidmClientBuilder};
use kanidm_proto::constants::{DEFAULT_CLIENT_CONFIG_PATH, DEFAULT_CLIENT_CONFIG_PATH_HOME};
use kanidm_proto::v1::UserAuthToken;
use std::str::FromStr;
impl CommonOpt {
pub fn to_unauth_client(&self) -> KanidmClient {
let config_path: String = shellexpand::tilde("~/.config/kanidm").into_owned();
let config_path: String = shellexpand::tilde(DEFAULT_CLIENT_CONFIG_PATH_HOME).into_owned();
let client_builder = KanidmClientBuilder::new()
.read_options_from_optional_config("/etc/kanidm/config")
.read_options_from_optional_config(DEFAULT_CLIENT_CONFIG_PATH)
.and_then(|cb| cb.read_options_from_optional_config(&config_path))
.unwrap_or_else(|e| {
error!("Failed to parse config (if present) -- {:?}", e);
std::process::exit(1);
});
debug!(
"Successfully loaded configuration, looked in /etc/kanidm/config and {} - client builder state: {:?}",
&config_path, &client_builder
"Successfully loaded configuration, looked in {} and {} - client builder state: {:?}",
DEFAULT_CLIENT_CONFIG_PATH, DEFAULT_CLIENT_CONFIG_PATH_HOME, &client_builder
);
let client_builder = match &self.addr {

View file

@ -13,6 +13,7 @@ use std::path::PathBuf;
use clap::Parser;
use kanidm_client::{ClientError, KanidmClientBuilder};
use kanidm_proto::constants::{DEFAULT_CLIENT_CONFIG_PATH, DEFAULT_CLIENT_CONFIG_PATH_HOME};
use tracing::{debug, error};
include!("opt/ssh_authorizedkeys.rs");
@ -29,10 +30,10 @@ async fn main() {
}
tracing_subscriber::fmt::init();
let config_path: String = shellexpand::tilde("~/.config/kanidm").into_owned();
debug!("Attempting to use config {}", "/etc/kanidm/config");
let config_path: String = shellexpand::tilde(DEFAULT_CLIENT_CONFIG_PATH_HOME).into_owned();
debug!("Attempting to use config {}", DEFAULT_CLIENT_CONFIG_PATH);
let client_builder = KanidmClientBuilder::new()
.read_options_from_optional_config("/etc/kanidm/config")
.read_options_from_optional_config(DEFAULT_CLIENT_CONFIG_PATH)
.and_then(|cb| {
debug!("Attempting to use config {}", config_path);
cb.read_options_from_optional_config(config_path)

View file

@ -61,7 +61,7 @@ bytes = "^1.1.0"
libc = "^0.2.127"
serde = { version = "^1.0.142", features = ["derive"] }
serde_json = "^1.0.83"
clap = { version = "^3.2", features = ["derive"] }
clap = { version = "^3.2", features = ["derive", "env"] }
libsqlite3-sys = "0.25.0"
rusqlite = "^0.28.0"

View file

@ -38,7 +38,7 @@ async fn main() {
{
Ok(c) => c,
Err(_e) => {
error!("Failed to parse /etc/kanidm/unixd");
error!("Failed to parse {}", DEFAULT_CONFIG_PATH);
std::process::exit(1);
}
};

View file

@ -38,7 +38,7 @@ async fn main() {
{
Ok(c) => c,
Err(_e) => {
error!("Failed to parse /etc/kanidm/unixd");
error!("Failed to parse {}", DEFAULT_CONFIG_PATH);
std::process::exit(1);
}
};

View file

@ -10,39 +10,35 @@
#![deny(clippy::needless_pass_by_value)]
#![deny(clippy::trivially_copy_pass_by_ref)]
use users::{get_current_gid, get_current_uid, get_effective_gid, get_effective_uid};
use bytes::{BufMut, BytesMut};
use clap::{Arg, ArgAction, Command};
use futures::SinkExt;
use futures::StreamExt;
use kanidm::utils::file_permissions_readonly;
use kanidm_client::KanidmClientBuilder;
use kanidm_proto::constants::DEFAULT_CLIENT_CONFIG_PATH;
use kanidm_unix_common::cache::CacheLayer;
use kanidm_unix_common::constants::DEFAULT_CONFIG_PATH;
use kanidm_unix_common::unix_config::KanidmUnixdConfig;
use kanidm_unix_common::unix_proto::{ClientRequest, ClientResponse, TaskRequest, TaskResponse};
use libc::umask;
use sketching::tracing_forest::{self, traits::*, util::*};
use std::error::Error;
use std::fs::metadata;
use std::io;
use std::io::Error as IoError;
use std::io::ErrorKind;
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
use bytes::{BufMut, BytesMut};
use futures::SinkExt;
use futures::StreamExt;
use libc::umask;
use sketching::tracing_forest::{self, traits::*, util::*};
use std::error::Error;
use std::io;
use std::sync::Arc;
use std::time::Duration;
use tokio::net::{UnixListener, UnixStream};
use tokio::sync::mpsc::{channel, Receiver, Sender};
use tokio::sync::oneshot;
use tokio::time;
use tokio_util::codec::Framed;
use tokio_util::codec::{Decoder, Encoder};
use kanidm_client::KanidmClientBuilder;
use kanidm_unix_common::cache::CacheLayer;
use kanidm_unix_common::constants::DEFAULT_CONFIG_PATH;
use kanidm_unix_common::unix_config::KanidmUnixdConfig;
use kanidm_unix_common::unix_proto::{ClientRequest, ClientResponse, TaskRequest, TaskResponse};
use kanidm::utils::file_permissions_readonly;
use users::{get_current_gid, get_current_uid, get_effective_gid, get_effective_uid};
//=== the codec
@ -124,6 +120,7 @@ impl TaskCodec {
}
}
/// Pass this a file path and it'll look for the file and remove it if it's there.
fn rm_if_exist(p: &str) {
if Path::new(p).exists() {
debug!("Removing requested file {:?}", p);
@ -305,8 +302,7 @@ async fn handle_client(
.await
{
Ok(()) => {
// Now wait for the other end OR
// timeout.
// Now wait for the other end OR timeout.
match time::timeout_at(
time::Instant::now() + Duration::from_millis(1000),
rx,
@ -374,9 +370,63 @@ async fn main() {
let cgid = get_current_gid();
let cegid = get_effective_gid();
let clap_args = Command::new("kanidm_unixd")
.version(env!("CARGO_PKG_VERSION"))
.about("Kanidm Unix daemon")
.arg(
Arg::new("skip-root-check")
.help("Allow running as root. Don't use this in production as it is risky!")
.short('r')
.long("skip-root-check")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("debug")
.help("Show extra debug information")
.short('d')
.long("debug")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("configtest")
.help("Display the configuration and exit")
.short('t')
.long("configtest")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("unixd-config")
.takes_value(true)
.help("Set the unixd config file path")
.short('u')
.long("unixd-config")
.default_value(DEFAULT_CONFIG_PATH)
.env("KANIDM_UNIX_CONFIG")
.action(ArgAction::StoreValue),
)
.arg(
Arg::new("client-config")
.takes_value(true)
.help("Set the client config file path")
.short('c')
.long("client-config")
.default_value(DEFAULT_CLIENT_CONFIG_PATH)
.env("KANIDM_CLIENT_CONFIG")
.action(ArgAction::StoreValue),
)
.get_matches();
if clap_args.get_flag("skip-root-check") {
warn!("Skipping root user check, if you're running this for testing, ensure you clean up temporary files.")
// TODO: this wording is not great m'kay.
} else {
if cuid == 0 || ceuid == 0 || cgid == 0 || cegid == 0 {
eprintln!("Refusing to run - this process must not operate as root.");
std::process::exit(1);
error!("Refusing to run - this process must not operate as root.");
return;
}
};
if clap_args.get_flag("debug") {
std::env::set_var("RUST_LOG", "debug");
}
tracing_forest::worker_task()
@ -393,29 +443,24 @@ async fn main() {
debug!("Profile -> {}", env!("KANIDM_PROFILE_NAME"));
debug!("CPU Flags -> {}", env!("KANIDM_CPU_FLAGS"));
let cfg_path = Path::new("/etc/kanidm/config");
let cfg_path_str = match cfg_path.to_str() {
Some(cps) => cps,
None => {
error!("Unable to turn cfg_path to str");
std::process::exit(1);
}
};
#[allow(clippy::expect_used)]
let cfg_path_str = clap_args.get_one::<String>("client-config").expect("Failed to pull the client config path");
let cfg_path: PathBuf = PathBuf::from(cfg_path_str);
if !cfg_path.exists() {
// there's no point trying to start up if we can't read a usable config!
error!(
"Client config missing from {} - cannot start up. Quitting.",
cfg_path_str
);
std::process::exit(1);
}
if cfg_path.exists() {
return
} else {
let cfg_meta = match metadata(&cfg_path) {
Ok(v) => v,
Err(e) => {
error!("Unable to read metadata for {} - {:?}", cfg_path_str, e);
std::process::exit(1);
return
}
};
if !file_permissions_readonly(&cfg_meta) {
@ -431,27 +476,23 @@ async fn main() {
}
}
let unixd_path = Path::new(DEFAULT_CONFIG_PATH);
let unixd_path_str = match unixd_path.to_str() {
Some(cps) => cps,
None => {
error!("Unable to turn unixd_path to str");
std::process::exit(1);
}
};
#[allow(clippy::expect_used)]
let unixd_path_str = clap_args.get_one::<String>("unixd-config").expect("Failed to pull the unixd config path");
let unixd_path = PathBuf::from(unixd_path_str);
if !unixd_path.exists() {
// there's no point trying to start up if we can't read a usable config!
error!(
"unixd config missing from {} - cannot start up. Quitting.",
unixd_path_str
);
std::process::exit(1);
return
} else {
let unixd_meta = match metadata(&unixd_path) {
Ok(v) => v,
Err(e) => {
error!("Unable to read metadata for {} - {:?}", unixd_path_str, e);
std::process::exit(1);
return
}
};
if !file_permissions_readonly(&unixd_meta) {
@ -467,37 +508,40 @@ async fn main() {
}
// setup
let cb = match KanidmClientBuilder::new().read_options_from_optional_config(cfg_path) {
let cb = match KanidmClientBuilder::new().read_options_from_optional_config(&cfg_path) {
Ok(v) => v,
Err(_) => {
error!("Failed to parse {}", cfg_path_str);
std::process::exit(1);
return
}
};
let cfg = match KanidmUnixdConfig::new().read_options_from_optional_config(unixd_path) {
let cfg = match KanidmUnixdConfig::new().read_options_from_optional_config(&unixd_path) {
Ok(v) => v,
Err(_) => {
error!("Failed to parse {}", unixd_path_str);
std::process::exit(1);
return
}
};
if clap_args.get_flag("configtest") {
eprintln!("###################################");
eprintln!("Dumping configs:\n###################################");
eprintln!("kanidm_unixd config (from {:#?})", &unixd_path);
eprintln!("{}", cfg);
eprintln!("###################################");
eprintln!("Client config (from {:#?})", &cfg_path);
eprintln!("{}", cb);
return;
}
debug!("🧹 Cleaning up sockets from previous invocations");
rm_if_exist(cfg.sock_path.as_str());
rm_if_exist(cfg.task_sock_path.as_str());
let cb = cb.connect_timeout(cfg.conn_timeout);
let rsclient = match cb.build() {
Ok(rsc) => rsc,
Err(_e) => {
error!("Failed to build async client");
std::process::exit(1);
}
};
// Check the pb path will be okay.
// Check the db path will be okay.
if cfg.db_path != "" {
let db_path = PathBuf::from(cfg.db_path.as_str());
// We only need to check the parent folder path permissions as the db itself may not exist yet.
@ -509,7 +553,7 @@ async fn main() {
.to_str()
.unwrap_or_else(|| "<db_parent_path invalid>")
);
std::process::exit(1);
return
}
let db_par_path_buf = db_parent_path.to_path_buf();
@ -524,7 +568,7 @@ async fn main() {
.unwrap_or_else(|| "<db_par_path_buf invalid>"),
e
);
std::process::exit(1);
return
}
};
@ -535,7 +579,7 @@ async fn main() {
.to_str()
.unwrap_or_else(|| "<db_par_path_buf invalid>")
);
std::process::exit(1);
return
}
if !file_permissions_readonly(&i_meta) {
warn!("WARNING: DB folder permissions on {} indicate it may not be RW. This could cause the server start up to fail!", db_par_path_buf.to_str()
@ -557,7 +601,7 @@ async fn main() {
"Refusing to run - DB path {} already exists and is not a file.",
db_path.to_str().unwrap_or_else(|| "<db_path invalid>")
);
std::process::exit(1);
return
};
match metadata(&db_path) {
@ -568,13 +612,24 @@ async fn main() {
db_path.to_str().unwrap_or_else(|| "<db_path invalid>"),
e
);
std::process::exit(1);
return
}
};
// TODO: permissions dance to enumerate the user's ability to write to the file? ref #456 - r2d2 will happily keep trying to do things without bailing.
};
}
let cb = cb.connect_timeout(cfg.conn_timeout);
let rsclient = match cb.build() {
Ok(rsc) => rsc,
Err(_e) => {
error!("Failed to build async client");
return
}
};
let cl_inner = match CacheLayer::new(
cfg.db_path.as_str(), // The sqlite db path
cfg.cache_timeout,
@ -592,7 +647,7 @@ async fn main() {
Ok(c) => c,
Err(_e) => {
error!("Failed to build cache layer.");
std::process::exit(1);
return
}
};
@ -603,8 +658,8 @@ async fn main() {
let listener = match UnixListener::bind(cfg.sock_path.as_str()) {
Ok(l) => l,
Err(_e) => {
error!("Failed to bind unix socket.");
std::process::exit(1);
error!("Failed to bind UNIX socket at {}", cfg.sock_path.as_str());
return
}
};
// Setup the root-only socket. Take away all others.
@ -612,8 +667,8 @@ async fn main() {
let task_listener = match UnixListener::bind(cfg.task_sock_path.as_str()) {
Ok(l) => l,
Err(_e) => {
error!("Failed to bind unix socket.");
std::process::exit(1);
error!("Failed to bind UNIX socket {}", cfg.sock_path.as_str());
return
}
};
@ -677,7 +732,7 @@ async fn main() {
});
}
Err(err) => {
error!("Accept error -> {:?}", err);
error!("Error while handling connection -> {:?}", err);
}
}
}
@ -688,4 +743,5 @@ async fn main() {
server.await;
})
.await;
// TODO: can we catch signals to clean up sockets etc, especially handy when running as root
}

View file

@ -39,7 +39,7 @@ fn main() {
{
Ok(c) => c,
Err(_e) => {
error!("Failed to parse /etc/kanidm/unixd");
error!("Failed to parse {}", DEFAULT_CONFIG_PATH);
std::process::exit(1);
}
};

View file

@ -39,7 +39,7 @@ async fn main() {
{
Ok(c) => c,
Err(e) => {
error!("Failed to parse /etc/kanidm/unixd: {:?}", e);
error!("Failed to parse {}: {:?}", DEFAULT_CONFIG_PATH, e);
std::process::exit(1);
}
};

View file

@ -27,13 +27,13 @@ async fn main() {
}
sketching::tracing_subscriber::fmt::init();
debug!("Starting pam auth tester tool ...");
debug!("Starting PAM auth tester tool ...");
let cfg = KanidmUnixdConfig::new()
.read_options_from_optional_config(DEFAULT_CONFIG_PATH)
.expect("Failed to parse /etc/kanidm/unixd");
.expect(&format!("Failed to parse {}", DEFAULT_CONFIG_PATH));
let password = rpassword::prompt_password("Enter unix password: ").unwrap();
let password = rpassword::prompt_password("Enter Unix password: ").unwrap();
let req = ClientRequest::PamAuthenticate(opt.account_id.clone(), password);
let sereq = ClientRequest::PamAccountAllowed(opt.account_id);

View file

@ -4,6 +4,8 @@ use crate::constants::{
DEFAULT_TASK_SOCK_PATH, DEFAULT_UID_ATTR_MAP,
};
use serde::Deserialize;
use std::env;
use std::fmt::{Display, Formatter};
use std::fs::File;
use std::io::{ErrorKind, Read};
use std::path::Path;
@ -31,12 +33,39 @@ pub enum HomeAttr {
Name,
}
impl Display for HomeAttr {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
match self {
HomeAttr::Uuid => "UUID",
HomeAttr::Spn => "SPN",
HomeAttr::Name => "Name",
}
)
}
}
#[derive(Debug, Copy, Clone)]
pub enum UidAttr {
Name,
Spn,
}
impl Display for UidAttr {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
match self {
UidAttr::Name => "Name",
UidAttr::Spn => "SPN",
}
)
}
}
#[derive(Debug)]
pub struct KanidmUnixdConfig {
pub db_path: String,
@ -59,10 +88,39 @@ impl Default for KanidmUnixdConfig {
}
}
impl Display for KanidmUnixdConfig {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
writeln!(f, "db_path: {}", &self.db_path)?;
writeln!(f, "sock_path: {}", self.sock_path)?;
writeln!(f, "task_sock_path: {}", self.task_sock_path)?;
writeln!(f, "conn_timeout: {}", self.conn_timeout)?;
writeln!(f, "cache_timeout: {}", self.cache_timeout)?;
writeln!(
f,
"pam_allowed_login_groups: {:#?}",
self.pam_allowed_login_groups
)?;
writeln!(f, "default_shell: {}", self.default_shell)?;
writeln!(f, "home_prefix: {}", self.home_prefix)?;
writeln!(f, "home_attr: {}", self.home_attr)?;
match self.home_alias {
Some(val) => writeln!(f, "home_alias: {}", val)?,
None => writeln!(f, "home_alias: unset")?,
}
writeln!(f, "uid_attr_map: {}", self.uid_attr_map)?;
writeln!(f, "gid_attr_map: {}", self.gid_attr_map)
}
}
impl KanidmUnixdConfig {
pub fn new() -> Self {
let db_path = match env::var("KANIDM_DB_PATH") {
Ok(val) => val,
Err(_) => DEFAULT_DB_PATH.into(),
};
KanidmUnixdConfig {
db_path: DEFAULT_DB_PATH.to_string(),
db_path,
sock_path: DEFAULT_SOCK_PATH.to_string(),
task_sock_path: DEFAULT_TASK_SOCK_PATH.to_string(),
conn_timeout: DEFAULT_CONN_TIMEOUT,

View file

@ -6,6 +6,7 @@ use kanidm;
use kanidm::entry::{Entry, EntryInit, EntryNew};
use kanidm::entry_init;
use kanidm::idm::server::{IdmServer, IdmServerDelayed};
use kanidm::macros::run_idm_test_no_logging;
use kanidm::server::QueryServer;
use kanidm::utils::duration_from_epoch_now;
use kanidm::value::Value;
@ -28,7 +29,7 @@ pub fn scaling_user_create_single(c: &mut Criterion) {
println!("iters, size -> {:?}, {:?}", iters, size);
for _i in 0..iters {
kanidm::macros::run_idm_test_no_logging(
run_idm_test_no_logging(
|_qs: &QueryServer, idms: &IdmServer, _idms_delayed: &IdmServerDelayed| {
let ct = duration_from_epoch_now();

View file

@ -98,7 +98,6 @@ impl BackendConfig {
}
}
#[cfg(test)]
pub(crate) fn new_test() -> Self {
BackendConfig {
pool_size: 1,

View file

@ -47,9 +47,9 @@ use tokio::sync::Semaphore;
use async_std::task;
#[cfg(test)]
// #[cfg(any(test,bench))]
use core::task::{Context, Poll};
#[cfg(test)]
// #[cfg(any(test,bench))]
use futures::task as futures_task;
use concread::{
@ -362,7 +362,7 @@ impl IdmServer {
}
impl IdmServerDelayed {
#[cfg(test)]
// #[cfg(any(test,bench))]
pub(crate) fn check_is_empty_or_panic(&mut self) {
let waker = futures_task::noop_waker();
let mut cx = Context::from_waker(&waker);

View file

@ -1,4 +1,3 @@
#[cfg(test)]
macro_rules! setup_test {
() => {{
let _ = sketching::test_init();
@ -126,7 +125,6 @@ macro_rules! entry_str_to_account {
}};
}
#[cfg(test)]
macro_rules! run_idm_test_inner {
($test_fn:expr) => {{
#[allow(unused_imports)]
@ -168,7 +166,6 @@ macro_rules! run_idm_test {
}};
}
#[cfg(test)]
pub fn run_idm_test_no_logging<F>(mut test_fn: F)
where
F: FnMut(

View file

@ -205,8 +205,8 @@ impl EntryChangelog {
EntryChangelog { anchors, changes }
}
// TODO: work out if the below comment about uncommenting is still valid
// Uncomment this once we have a real on-disk storage of the changelog
// #[cfg(test)]
pub fn new_without_schema(cid: Cid, attrs: Eattrs) -> Self {
// I think we need to reduce the attrs based on what is / is not replicated.?

View file

@ -1590,7 +1590,6 @@ impl Schema {
}
}
#[cfg(test)]
pub(crate) fn write_blocking(&self) -> SchemaWriteTransaction<'_> {
self.write()
}