Skip to content
Merged
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions influxdb3/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ influxdb3_write = { path = "../influxdb3_write" }
anyhow.workspace = true
backtrace.workspace = true
base64.workspace = true
chrono.workspace = true
clap.workspace = true
owo-colors.workspace = true
dotenvy.workspace = true
Expand Down
218 changes: 193 additions & 25 deletions influxdb3/src/commands/create/token.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,28 @@
use std::{error::Error, io, path::PathBuf};
use std::{error::Error, io, path::PathBuf, sync::Arc};

use clap::{
Arg, Args, Command as ClapCommand, CommandFactory, Error as ClapError, FromArgMatches, Parser,
ValueEnum, error::ErrorKind,
};
use influxdb3_authz::TokenInfo;
use influxdb3_catalog::catalog::{compute_token_hash, create_token_and_hash};
use influxdb3_client::Client;
use influxdb3_types::http::CreateTokenWithPermissionsResponse;
use owo_colors::OwoColorize;
use secrecy::Secret;
use serde::{Deserialize, Serialize};
use url::Url;

#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct AdminTokenFile {
/// The raw token string
pub token: String,
/// The token name
pub name: String,
/// Optional expiry timestamp in milliseconds
#[serde(skip_serializing_if = "Option::is_none")]
pub expiry_millis: Option<i64>,
}
pub(crate) async fn handle_token_creation_with_config(
client: Client,
config: CreateTokenConfig,
Expand All @@ -32,39 +45,129 @@ pub(crate) async fn handle_admin_token_creation(
client: Client,
config: CreateAdminTokenConfig,
) -> Result<CreateTokenWithPermissionsResponse, Box<dyn Error>> {
let json_body = if config.regenerate {
println!("Are you sure you want to regenerate admin token? Enter 'yes' to confirm",);
let mut confirmation = String::new();
let _ = io::stdin().read_line(&mut confirmation);
if confirmation.trim() == "yes" {
if config.offline {
// Generate token without server
let token = generate_offline_token();

let output_file = config
.output_file
.ok_or("--output-file is required with --offline")?;

// Create admin token file with metadata
let token_file = AdminTokenFile {
token: token.clone(),
name: "_admin".to_string(),
expiry_millis: None,
};

let json = serde_json::to_string_pretty(&token_file)?;

// Write token atomically with correct permissions
write_file_atomically(&output_file, &json)?;

println!("Token saved to: {}", output_file.display());

// For offline mode, we return a mock success response
let hash = compute_token_hash(&token);
let token_info = Arc::new(TokenInfo {
id: 0.into(),
name: Arc::from("_admin"),
hash,
description: None,
created_by: None,
created_at: chrono::Utc::now().timestamp_millis(),
updated_at: None,
updated_by: None,
expiry_millis: i64::MAX,
permissions: vec![], // Admin tokens don't need explicit permissions
});

CreateTokenWithPermissionsResponse::from_token_info(token_info, token)
.ok_or_else(|| "Failed to create token response".into())
} else {
let json_body = if config.regenerate {
println!("Are you sure you want to regenerate admin token? Enter 'yes' to confirm",);
let mut confirmation = String::new();
io::stdin().read_line(&mut confirmation)?;
if confirmation.trim() == "yes" {
client
.api_v3_configure_regenerate_admin_token()
.await?
.expect("token creation to return full token info")
} else {
return Err("Cannot regenerate token without confirmation".into());
}
} else {
client
.api_v3_configure_regenerate_admin_token()
.api_v3_configure_create_admin_token()
.await?
.expect("token creation to return full token info")
} else {
return Err("Cannot regenerate token without confirmation".into());
}
} else {
client
.api_v3_configure_create_admin_token()
.await?
.expect("token creation to return full token info")
};
Ok(json_body)
};
Ok(json_body)
}
}

fn generate_offline_token() -> String {
create_token_and_hash().0
}

pub(crate) async fn handle_named_admin_token_creation(
client: Client,
config: CreateAdminTokenConfig,
) -> Result<CreateTokenWithPermissionsResponse, Box<dyn Error>> {
let json_body = client
.api_v3_configure_create_named_admin_token(
config.name.expect("token name to be present"),
config.expiry.map(|expiry| expiry.as_secs()),
)
.await?
.expect("token creation to return full token info");
Ok(json_body)
if config.offline {
// Generate token without server
let token = generate_offline_token();

let output_file = config
.output_file
.ok_or("--output-file is required with --offline")?;

let token_name = config.name.expect("token name to be present");

let expiry_millis = config
.expiry
.map(|expiry| chrono::Utc::now().timestamp_millis() + (expiry.as_secs() as i64 * 1000));
let token_file = AdminTokenFile {
token: token.clone(),
name: token_name.clone(),
expiry_millis,
};

let json = serde_json::to_string_pretty(&token_file)?;

// Write token atomically with correct permissions
write_file_atomically(&output_file, &json)?;

println!("Token saved to: {}", output_file.display());

// For offline mode, we return a mock success response
let hash = compute_token_hash(&token);
let token_info = Arc::new(TokenInfo {
id: 0.into(),
name: Arc::from(token_name.as_str()),
hash,
description: None,
created_by: None,
created_at: chrono::Utc::now().timestamp_millis(),
updated_at: None,
updated_by: None,
expiry_millis: expiry_millis.unwrap_or(i64::MAX),
permissions: vec![], // Admin tokens don't need explicit permissions
});

CreateTokenWithPermissionsResponse::from_token_info(token_info, token)
.ok_or_else(|| "Failed to create token response".into())
} else {
let json_body = client
.api_v3_configure_create_named_admin_token(
config.name.expect("token name to be present"),
config.expiry.map(|expiry| expiry.as_secs()),
)
.await?
.expect("token creation to return full token info");
Ok(json_body)
}
}

#[derive(Debug, ValueEnum, Clone, Copy)]
Expand Down Expand Up @@ -131,6 +234,14 @@ pub struct CreateAdminTokenConfig {
/// Output format for token, supports just json or text
#[clap(long)]
pub format: Option<TokenOutputFormat>,

/// Generate token without connecting to server (enterprise feature)
#[clap(long, requires = "output_file")]
pub offline: bool,

/// File path to save the token (required with --offline)
#[clap(long, value_name = "FILE")]
pub output_file: Option<PathBuf>,
}

impl CreateAdminTokenConfig {
Expand Down Expand Up @@ -245,3 +356,60 @@ impl CommandFactory for CreateTokenConfig {
Self::command()
}
}

/// Write a file atomically by writing to a temporary file and moving it into place
/// This ensures the file either exists with correct permissions or doesn't exist at all
pub(crate) fn write_file_atomically(path: &PathBuf, content: &str) -> Result<(), Box<dyn Error>> {
Comment on lines +360 to +362
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It occurred to me that the object_store crate was implemented to ensure atomicity of writes. An alternative to this method would be to spin up a LocalFileSystem and do a PUT. Under the hood, I believe the PUT implementation on LocalFileSystem is doing what is done here (write to temp file then use a move as a single atomic operation).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this was already approved by security I'm a little hesitant to spin up the LocalFileSystem to do this. I don't doubt it, but getting these changes in to both core and enterprise I think we will want to hold off on it for now and consider doing this in a followup ticket.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree - don't want to make this change now but I thought this was worth noting.

use std::io::Write;

// Ensure content ends with a newline
let content_with_newline = if content.ends_with('\n') {
content.to_string()
} else {
format!("{content}\n")
};

// Create a temporary file in the same directory as the target
let parent = path.parent().ok_or("Invalid file path")?;
let file_name = path.file_name().ok_or("Invalid file name")?;
let temp_path = parent.join(format!(".{}.tmp", file_name.to_string_lossy()));

// Write to temporary file with proper error handling
if let Err(e) = || -> Result<(), Box<dyn Error>> {
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600) // Set permissions atomically during creation
.open(&temp_path)?;

file.write_all(content_with_newline.as_bytes())?;
file.sync_all()?; // Ensure data is written to disk
}

#[cfg(not(unix))]
{
use std::fs::File;
let mut file = File::create(&temp_path)?;
file.write_all(content_with_newline.as_bytes())?;
file.sync_all()?;
}

Ok(())
}() {
// Clean up temp file on error
let _ = std::fs::remove_file(&temp_path);
return Err(e);
}

std::fs::rename(&temp_path, path).map_err(|e| -> Box<dyn Error> {
// Clean up temp file on rename error
let _ = std::fs::remove_file(&temp_path);
format!("Failed to atomically rename temporary file: {e}").into()
})?;

Ok(())
}
Loading