Skip to content

Commit b29c0cc

Browse files
authored
feat: Add offline generation for admin tokens (#26734)
This commit adds the ability to generate authentication tokens offline that can then be loaded up by the database at runtime if the tokens do not already exist. This is perfect for automated deployments and containerized environments. This is the implementation for Core which only has admin tokens. The Enterprise counterpart to this change also includes permissions based tokens. CLI Usage ```bash # Generate offline admin token influxdb3 create token --admin --offline --output-file ./config/admin_token.txt ``` Server Configuration The server can load tokens from files on startup: - Admin token: `--admin-token-file ./config/admin_token.txt` Closes #26437
1 parent 9ff91dd commit b29c0cc

File tree

8 files changed

+353
-33
lines changed

8 files changed

+353
-33
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

influxdb3/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ influxdb3_write = { path = "../influxdb3_write" }
5454
anyhow.workspace = true
5555
backtrace.workspace = true
5656
base64.workspace = true
57+
chrono.workspace = true
5758
clap.workspace = true
5859
owo-colors.workspace = true
5960
dotenvy.workspace = true

influxdb3/src/commands/create/token.rs

Lines changed: 193 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,28 @@
1-
use std::{error::Error, io, path::PathBuf};
1+
use std::{error::Error, io, path::PathBuf, sync::Arc};
22

33
use clap::{
44
Arg, Args, Command as ClapCommand, CommandFactory, Error as ClapError, FromArgMatches, Parser,
55
ValueEnum, error::ErrorKind,
66
};
7+
use influxdb3_authz::TokenInfo;
8+
use influxdb3_catalog::catalog::{compute_token_hash, create_token_and_hash};
79
use influxdb3_client::Client;
810
use influxdb3_types::http::CreateTokenWithPermissionsResponse;
911
use owo_colors::OwoColorize;
1012
use secrecy::Secret;
13+
use serde::{Deserialize, Serialize};
1114
use url::Url;
1215

16+
#[derive(Debug, Serialize, Deserialize)]
17+
pub(crate) struct AdminTokenFile {
18+
/// The raw token string
19+
pub token: String,
20+
/// The token name
21+
pub name: String,
22+
/// Optional expiry timestamp in milliseconds
23+
#[serde(skip_serializing_if = "Option::is_none")]
24+
pub expiry_millis: Option<i64>,
25+
}
1326
pub(crate) async fn handle_token_creation_with_config(
1427
client: Client,
1528
config: CreateTokenConfig,
@@ -32,39 +45,129 @@ pub(crate) async fn handle_admin_token_creation(
3245
client: Client,
3346
config: CreateAdminTokenConfig,
3447
) -> Result<CreateTokenWithPermissionsResponse, Box<dyn Error>> {
35-
let json_body = if config.regenerate {
36-
println!("Are you sure you want to regenerate admin token? Enter 'yes' to confirm",);
37-
let mut confirmation = String::new();
38-
let _ = io::stdin().read_line(&mut confirmation);
39-
if confirmation.trim() == "yes" {
48+
if config.offline {
49+
// Generate token without server
50+
let token = generate_offline_token();
51+
52+
let output_file = config
53+
.output_file
54+
.ok_or("--output-file is required with --offline")?;
55+
56+
// Create admin token file with metadata
57+
let token_file = AdminTokenFile {
58+
token: token.clone(),
59+
name: "_admin".to_string(),
60+
expiry_millis: None,
61+
};
62+
63+
let json = serde_json::to_string_pretty(&token_file)?;
64+
65+
// Write token atomically with correct permissions
66+
write_file_atomically(&output_file, &json)?;
67+
68+
println!("Token saved to: {}", output_file.display());
69+
70+
// For offline mode, we return a mock success response
71+
let hash = compute_token_hash(&token);
72+
let token_info = Arc::new(TokenInfo {
73+
id: 0.into(),
74+
name: Arc::from("_admin"),
75+
hash,
76+
description: None,
77+
created_by: None,
78+
created_at: chrono::Utc::now().timestamp_millis(),
79+
updated_at: None,
80+
updated_by: None,
81+
expiry_millis: i64::MAX,
82+
permissions: vec![], // Admin tokens don't need explicit permissions
83+
});
84+
85+
CreateTokenWithPermissionsResponse::from_token_info(token_info, token)
86+
.ok_or_else(|| "Failed to create token response".into())
87+
} else {
88+
let json_body = if config.regenerate {
89+
println!("Are you sure you want to regenerate admin token? Enter 'yes' to confirm",);
90+
let mut confirmation = String::new();
91+
io::stdin().read_line(&mut confirmation)?;
92+
if confirmation.trim() == "yes" {
93+
client
94+
.api_v3_configure_regenerate_admin_token()
95+
.await?
96+
.expect("token creation to return full token info")
97+
} else {
98+
return Err("Cannot regenerate token without confirmation".into());
99+
}
100+
} else {
40101
client
41-
.api_v3_configure_regenerate_admin_token()
102+
.api_v3_configure_create_admin_token()
42103
.await?
43104
.expect("token creation to return full token info")
44-
} else {
45-
return Err("Cannot regenerate token without confirmation".into());
46-
}
47-
} else {
48-
client
49-
.api_v3_configure_create_admin_token()
50-
.await?
51-
.expect("token creation to return full token info")
52-
};
53-
Ok(json_body)
105+
};
106+
Ok(json_body)
107+
}
108+
}
109+
110+
fn generate_offline_token() -> String {
111+
create_token_and_hash().0
54112
}
55113

56114
pub(crate) async fn handle_named_admin_token_creation(
57115
client: Client,
58116
config: CreateAdminTokenConfig,
59117
) -> Result<CreateTokenWithPermissionsResponse, Box<dyn Error>> {
60-
let json_body = client
61-
.api_v3_configure_create_named_admin_token(
62-
config.name.expect("token name to be present"),
63-
config.expiry.map(|expiry| expiry.as_secs()),
64-
)
65-
.await?
66-
.expect("token creation to return full token info");
67-
Ok(json_body)
118+
if config.offline {
119+
// Generate token without server
120+
let token = generate_offline_token();
121+
122+
let output_file = config
123+
.output_file
124+
.ok_or("--output-file is required with --offline")?;
125+
126+
let token_name = config.name.expect("token name to be present");
127+
128+
let expiry_millis = config
129+
.expiry
130+
.map(|expiry| chrono::Utc::now().timestamp_millis() + (expiry.as_secs() as i64 * 1000));
131+
let token_file = AdminTokenFile {
132+
token: token.clone(),
133+
name: token_name.clone(),
134+
expiry_millis,
135+
};
136+
137+
let json = serde_json::to_string_pretty(&token_file)?;
138+
139+
// Write token atomically with correct permissions
140+
write_file_atomically(&output_file, &json)?;
141+
142+
println!("Token saved to: {}", output_file.display());
143+
144+
// For offline mode, we return a mock success response
145+
let hash = compute_token_hash(&token);
146+
let token_info = Arc::new(TokenInfo {
147+
id: 0.into(),
148+
name: Arc::from(token_name.as_str()),
149+
hash,
150+
description: None,
151+
created_by: None,
152+
created_at: chrono::Utc::now().timestamp_millis(),
153+
updated_at: None,
154+
updated_by: None,
155+
expiry_millis: expiry_millis.unwrap_or(i64::MAX),
156+
permissions: vec![], // Admin tokens don't need explicit permissions
157+
});
158+
159+
CreateTokenWithPermissionsResponse::from_token_info(token_info, token)
160+
.ok_or_else(|| "Failed to create token response".into())
161+
} else {
162+
let json_body = client
163+
.api_v3_configure_create_named_admin_token(
164+
config.name.expect("token name to be present"),
165+
config.expiry.map(|expiry| expiry.as_secs()),
166+
)
167+
.await?
168+
.expect("token creation to return full token info");
169+
Ok(json_body)
170+
}
68171
}
69172

70173
#[derive(Debug, ValueEnum, Clone, Copy)]
@@ -131,6 +234,14 @@ pub struct CreateAdminTokenConfig {
131234
/// Output format for token, supports just json or text
132235
#[clap(long)]
133236
pub format: Option<TokenOutputFormat>,
237+
238+
/// Generate token without connecting to server (enterprise feature)
239+
#[clap(long, requires = "output_file")]
240+
pub offline: bool,
241+
242+
/// File path to save the token (required with --offline)
243+
#[clap(long, value_name = "FILE")]
244+
pub output_file: Option<PathBuf>,
134245
}
135246

136247
impl CreateAdminTokenConfig {
@@ -245,3 +356,60 @@ impl CommandFactory for CreateTokenConfig {
245356
Self::command()
246357
}
247358
}
359+
360+
/// Write a file atomically by writing to a temporary file and moving it into place
361+
/// This ensures the file either exists with correct permissions or doesn't exist at all
362+
pub(crate) fn write_file_atomically(path: &PathBuf, content: &str) -> Result<(), Box<dyn Error>> {
363+
use std::io::Write;
364+
365+
// Ensure content ends with a newline
366+
let content_with_newline = if content.ends_with('\n') {
367+
content.to_string()
368+
} else {
369+
format!("{content}\n")
370+
};
371+
372+
// Create a temporary file in the same directory as the target
373+
let parent = path.parent().ok_or("Invalid file path")?;
374+
let file_name = path.file_name().ok_or("Invalid file name")?;
375+
let temp_path = parent.join(format!(".{}.tmp", file_name.to_string_lossy()));
376+
377+
// Write to temporary file with proper error handling
378+
if let Err(e) = || -> Result<(), Box<dyn Error>> {
379+
#[cfg(unix)]
380+
{
381+
use std::os::unix::fs::OpenOptionsExt;
382+
let mut file = std::fs::OpenOptions::new()
383+
.write(true)
384+
.create(true)
385+
.truncate(true)
386+
.mode(0o600) // Set permissions atomically during creation
387+
.open(&temp_path)?;
388+
389+
file.write_all(content_with_newline.as_bytes())?;
390+
file.sync_all()?; // Ensure data is written to disk
391+
}
392+
393+
#[cfg(not(unix))]
394+
{
395+
use std::fs::File;
396+
let mut file = File::create(&temp_path)?;
397+
file.write_all(content_with_newline.as_bytes())?;
398+
file.sync_all()?;
399+
}
400+
401+
Ok(())
402+
}() {
403+
// Clean up temp file on error
404+
let _ = std::fs::remove_file(&temp_path);
405+
return Err(e);
406+
}
407+
408+
std::fs::rename(&temp_path, path).map_err(|e| -> Box<dyn Error> {
409+
// Clean up temp file on rename error
410+
let _ = std::fs::remove_file(&temp_path);
411+
format!("Failed to atomically rename temporary file: {e}").into()
412+
})?;
413+
414+
Ok(())
415+
}

0 commit comments

Comments
 (0)