Skip to content

Commit 4164c31

Browse files
committed
userborn: make normal UID/GID allocation ranges configurable
Sites that assign static IDs out of band (e.g. for NFS) need a range that userborn will never allocate from dynamically. login.defs already models this as UID_MIN/UID_MAX and GID_MIN/GID_MAX, which nixpkgs exposes via security.loginDefs.settings, so mirror that with separate normalUidRange/normalGidRange fields that the NixOS module can pass through directly.
1 parent a3e91ba commit 4164c31

8 files changed

Lines changed: 244 additions & 32 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
## Unreleased
44

5+
- Dynamic allocation ranges for normal UIDs/GIDs are now configurable via
6+
`normalUidRange`/`normalGidRange` (default: 1000 to 29999), mirroring
7+
`UID_MIN`/`UID_MAX`/`GID_MIN`/`GID_MAX` from login.defs.
58
- Added a JSON schema that specifies the configuration format.
69

710
## 1.0.1

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,21 @@ The config file is specified in a [provided JSON
120120
schema](./userborn.schema.json) which you can use to see available options and
121121
to validate your config.
122122

123+
#### Normal ID Ranges
124+
125+
Normal UIDs/GIDs are dynamically allocated from 1000 to 29999 (inclusive) by
126+
default. `normalUidRange`/`normalGidRange` change this, e.g. to keep statically
127+
assigned IDs (such as for NFS) outside of dynamic allocation:
128+
129+
```json
130+
{
131+
"normalUidRange": { "min": 30000, "max": 39999 },
132+
"normalGidRange": { "min": 30000, "max": 39999 }
133+
}
134+
```
135+
136+
System IDs are always allocated from 1 to 999; `min` must be at least 1000.
137+
123138
### Environment Variables
124139

125140
- `USERBORN_MUTABLE_USERS`: Set this to the string `true` if you want to enable

rust/userborn/src/config.rs

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use std::{
44
path::Path,
55
};
66

7-
use anyhow::{Context, Result};
7+
use anyhow::{Context, Result, bail};
88
use serde::Deserialize;
99

1010
/// # User
@@ -89,6 +89,12 @@ pub struct Group {
8989
#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
9090
#[serde(rename_all = "camelCase")]
9191
pub struct Config {
92+
/// Range for dynamically allocated normal UIDs (login.defs `UID_MIN`/`UID_MAX`).
93+
#[serde(default)]
94+
pub normal_uid_range: IdRange,
95+
/// Range for dynamically allocated normal GIDs (login.defs `GID_MIN`/`GID_MAX`).
96+
#[serde(default)]
97+
pub normal_gid_range: IdRange,
9298
/// Users to manage.
9399
#[serde(default)]
94100
pub users: Vec<User>,
@@ -97,6 +103,41 @@ pub struct Config {
97103
pub groups: Vec<Group>,
98104
}
99105

106+
/// The lowest ID considered "normal" (i.e. not a system ID).
107+
pub const NORMAL_ID_MIN: u32 = 1000;
108+
109+
/// Inclusive range from which normal IDs are dynamically allocated.
110+
#[derive(Deserialize, Debug, Clone, Copy)]
111+
#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
112+
pub struct IdRange {
113+
pub min: u32,
114+
pub max: u32,
115+
}
116+
117+
impl Default for IdRange {
118+
fn default() -> Self {
119+
Self {
120+
min: NORMAL_ID_MIN,
121+
max: 29999,
122+
}
123+
}
124+
}
125+
126+
impl IdRange {
127+
pub fn validate(self) -> Result<()> {
128+
if self.min > self.max {
129+
bail!("Invalid ID range: min ({}) > max ({})", self.min, self.max);
130+
}
131+
if self.min < NORMAL_ID_MIN {
132+
bail!(
133+
"Invalid ID range: min ({}) must be at least {NORMAL_ID_MIN}",
134+
self.min
135+
);
136+
}
137+
Ok(())
138+
}
139+
}
140+
100141
/// Range of subordiate IDs to create.
101142
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
102143
#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
@@ -111,7 +152,16 @@ impl Config {
111152
pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
112153
let contents = fs::read(&path)
113154
.with_context(|| format!("Failed to read {}", path.as_ref().display()))?;
114-
serde_json::from_slice(&contents).context("Failed to parse config")
155+
let config: Self = serde_json::from_slice(&contents).context("Failed to parse config")?;
156+
config
157+
.normal_uid_range
158+
.validate()
159+
.context("normalUidRange")?;
160+
config
161+
.normal_gid_range
162+
.validate()
163+
.context("normalGidRange")?;
164+
Ok(config)
115165
}
116166

117167
#[must_use]
@@ -132,6 +182,8 @@ mod tests {
132182
#[test]
133183
fn config() -> Result<()> {
134184
let value = serde_json::json!({
185+
"normalUidRange": { "min": 30000, "max": 39999 },
186+
"normalGidRange": { "min": 40000, "max": 49999 },
135187
"users": [
136188
{
137189
"isNormal": true,
@@ -170,4 +222,25 @@ mod tests {
170222
serde_json::from_value::<Config>(value)?;
171223
Ok(())
172224
}
225+
226+
#[test]
227+
fn validate_range() {
228+
assert!(IdRange::default().validate().is_ok());
229+
assert!(
230+
IdRange {
231+
min: 999,
232+
max: 2000
233+
}
234+
.validate()
235+
.is_err()
236+
);
237+
assert!(
238+
IdRange {
239+
min: 2000,
240+
max: 1999
241+
}
242+
.validate()
243+
.is_err()
244+
);
245+
}
173246
}

rust/userborn/src/group.rs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use std::collections::{BTreeMap, BTreeSet};
22

33
use anyhow::{Result, bail};
44

5-
use crate::{fs::FromBuffer, id};
5+
use crate::{config, fs::FromBuffer, id};
66

77
#[derive(Clone)]
88
pub struct Entry {
@@ -138,10 +138,15 @@ impl Group {
138138
/// Allocate a new (i.e. unused) GID.
139139
///
140140
/// Returns `Err` if it cannot allocate a new GID because all in the range are already used.
141-
pub fn allocate_gid(&self, is_normal: bool, reserved_gids: &BTreeSet<u32>) -> Result<u32> {
141+
pub fn allocate_gid(
142+
&self,
143+
is_normal: bool,
144+
reserved_gids: &BTreeSet<u32>,
145+
normal_range: config::IdRange,
146+
) -> Result<u32> {
142147
let mut allocated_gids = self.entries.keys().copied().collect::<BTreeSet<u32>>();
143148
allocated_gids.extend(reserved_gids);
144-
id::allocate(&allocated_gids, is_normal)
149+
id::allocate(&allocated_gids, is_normal, normal_range)
145150
}
146151

147152
pub fn contains_gid(&self, gid: u32) -> bool {

rust/userborn/src/id.rs

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,28 @@ use std::collections::BTreeSet;
22

33
use anyhow::{Result, bail};
44

5+
use crate::config::{IdRange, NORMAL_ID_MIN};
6+
57
/// Allocate a new UID/GID.
68
///
7-
/// Normal users/groups get an ID in the range from 1000 to 29999 (inclusive).
9+
/// Normal users/groups get an ID from `normal_range` (by default 1000 to 29999 inclusive).
810
///
911
/// System users/groups get an ID in the range from 1 to 999 (inclusive).
1012
///
1113
/// Fails if there are no unused IDs in the respective ranges.
12-
pub fn allocate(already_allocated_ids: &BTreeSet<u32>, is_normal: bool) -> Result<u32> {
14+
pub fn allocate(
15+
already_allocated_ids: &BTreeSet<u32>,
16+
is_normal: bool,
17+
normal_range: IdRange,
18+
) -> Result<u32> {
1319
if is_normal {
14-
for candidate in 1000u32..30000 {
20+
for candidate in normal_range.min..=normal_range.max {
1521
if !already_allocated_ids.contains(&candidate) {
1622
return Ok(candidate);
1723
}
1824
}
1925
} else {
20-
for candidate in (1u32..1000).rev() {
26+
for candidate in (1u32..NORMAL_ID_MIN).rev() {
2127
if !already_allocated_ids.contains(&candidate) {
2228
return Ok(candidate);
2329
}
@@ -34,9 +40,23 @@ mod tests {
3440
already_allocated_ids: impl IntoIterator<Item = u32>,
3541
is_normal: bool,
3642
expected: u32,
43+
) -> Result<()> {
44+
check_allocate_id_in_range(
45+
already_allocated_ids,
46+
is_normal,
47+
IdRange::default(),
48+
expected,
49+
)
50+
}
51+
52+
fn check_allocate_id_in_range(
53+
already_allocated_ids: impl IntoIterator<Item = u32>,
54+
is_normal: bool,
55+
normal_range: IdRange,
56+
expected: u32,
3757
) -> Result<()> {
3858
let uids = already_allocated_ids.into_iter().collect::<BTreeSet<u32>>();
39-
let allocated = allocate(&uids, is_normal)?;
59+
let allocated = allocate(&uids, is_normal, normal_range)?;
4060
assert_eq!(allocated, expected);
4161
Ok(())
4262
}
@@ -56,4 +76,18 @@ mod tests {
5676
assert!(check_allocate_id(999..30000, true, 1).is_err());
5777
Ok(())
5878
}
79+
80+
#[test]
81+
fn allocate_uid_custom_range() -> Result<()> {
82+
let range = IdRange {
83+
min: 30000,
84+
max: 30001,
85+
};
86+
check_allocate_id_in_range([1000], true, range, 30000)?;
87+
check_allocate_id_in_range([30000], true, range, 30001)?;
88+
assert!(check_allocate_id_in_range([30000, 30001], true, range, 0).is_err());
89+
// The custom range only applies to normal IDs.
90+
check_allocate_id_in_range([], false, range, 999)?;
91+
Ok(())
92+
}
5993
}

0 commit comments

Comments
 (0)