Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(rust): project ticket improvements #8580

Merged
merged 4 commits into from
Oct 29, 2024
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
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ use crate::authenticator::{
AuthorityEnrollmentTokenRepository, AuthorityMembersRepository, EnrollmentToken,
};

pub(super) const MAX_TOKEN_DURATION: Duration = Duration::from_secs(600);
pub const DEFAULT_TOKEN_DURATION: Duration = Duration::from_secs(60 * 10);
pub const MAX_RECOMMENDED_TOKEN_DURATION: Duration = Duration::from_secs(60 * 60 * 24 * 5);
pub const DEFAULT_TOKEN_USAGE_COUNT: u64 = 1;
pub const MAX_RECOMMENDED_TOKEN_USAGE_COUNT: u64 = 10;

pub struct EnrollmentTokenIssuerError(pub String);

Expand Down Expand Up @@ -90,8 +93,8 @@ impl EnrollmentTokenIssuer {
.take(10)
.map(char::from)
.collect();
let max_token_duration = token_duration.unwrap_or(MAX_TOKEN_DURATION);
let ttl_count = ttl_count.unwrap_or(1);
let max_token_duration = token_duration.unwrap_or(DEFAULT_TOKEN_DURATION);
let ttl_count = ttl_count.unwrap_or(DEFAULT_TOKEN_USAGE_COUNT);
let now = now()?;
let expires_at = now + max_token_duration.as_secs();
let tkn = EnrollmentToken {
Expand Down
53 changes: 39 additions & 14 deletions implementations/rust/ockam/ockam_api/src/cli_state/enrollments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -269,12 +269,19 @@ impl LegacyEnrollmentTicket {
.map_err(|_err| ApiError::core("Failed to authenticate with Okta"))?;
Ok(hex::encode(serialized))
}
}

impl FromStr for LegacyEnrollmentTicket {
type Err = ApiError;

pub fn from_hex(hex: &str) -> Result<Self> {
let data = hex::decode(hex)
.map_err(|_err| ApiError::core("Failed to decode EnrollmentTicket hex"))?;
Ok(serde_json::from_slice(&data)
.map_err(|_err| ApiError::core("Failed to decode EnrollmentTicket json"))?)
fn from_str(contents: &str) -> std::result::Result<Self, Self::Err> {
if let Ok(data) = hex::decode(contents) {
Ok(serde_json::from_slice(&data)
.map_err(|_err| ApiError::core("Failed to decode EnrollmentTicket json"))?)
} else {
Ok(serde_json::from_str(contents)
.map_err(|_err| ApiError::core("Failed to decode EnrollmentTicket json"))?)
}
}
}

Expand Down Expand Up @@ -343,13 +350,19 @@ impl FromStr for ExportedEnrollmentTicket {
type Err = ApiError;

fn from_str(contents: &str) -> std::result::Result<Self, Self::Err> {
// Try to hex-decode the contents
// Try to decode from hex-encoded string
let contents = match hex::decode(contents) {
Ok(decoded) => String::from_utf8(decoded)
.map_err(|_| ApiError::core("Failed to hex decode enrollment ticket"))?,
Err(_) => contents.to_string(),
};

// Try to decode from json
let contents = match serde_json::from_str(&contents) {
Ok(decoded) => return Ok(decoded),
Err(_) => contents,
};

// Decode as comma-separated text
let values: Vec<&str> = contents.split(',').collect();
if values.len() < Self::MANDATORY_FIELDS_NUM {
Expand Down Expand Up @@ -636,18 +649,30 @@ mod tests {
fn test_exported_enrollment_ticket() {
let exported = ExportedEnrollmentTicket::new_test();
let encoded = exported.to_string();
let hex_decoded = String::from_utf8(hex::decode(&encoded).unwrap()).unwrap();
assert!(hex_decoded.contains(&String::from(&exported.one_time_code)));
assert!(hex_decoded.contains(&exported.project_route.id));
assert!(hex_decoded.contains(&exported.project_route.route.to_string()));
assert!(hex_decoded.contains(&exported.project_name));
assert!(hex_decoded.contains(&exported.project_change_history));
assert!(hex_decoded.contains(&exported.authority_change_history));
let plain = String::from_utf8(hex::decode(&encoded).unwrap()).unwrap();
assert!(plain.contains(&String::from(&exported.one_time_code)));
assert!(plain.contains(&exported.project_route.id));
assert!(plain.contains(&exported.project_route.route.to_string()));
assert!(plain.contains(&exported.project_name));
assert!(plain.contains(&exported.project_change_history));
assert!(plain.contains(&exported.authority_change_history));

let decoded = ExportedEnrollmentTicket::from_str(&encoded).unwrap();
assert_eq!(decoded, exported);

let decoded = ExportedEnrollmentTicket::from_str(&hex_decoded).unwrap();
let decoded = ExportedEnrollmentTicket::from_str(&plain).unwrap();
assert_eq!(decoded, exported);

let json_encoded = serde_json::to_string(&exported).unwrap();
let decoded = ExportedEnrollmentTicket::from_str(&json_encoded).unwrap();
assert_eq!(decoded, exported);
}

#[test]
fn exported_enrollment_ticket_from_hex() {
let exported = ExportedEnrollmentTicket::new_test();
let encoded = exported.to_string();
let decoded = ExportedEnrollmentTicket::from_str(&encoded).unwrap();
assert_eq!(decoded, exported);
}

Expand Down
1 change: 1 addition & 0 deletions implementations/rust/ockam/ockam_app_lib/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
//!
//! It exposes C APIs that can be used by the frontend to interact with the application.
//!
#![recursion_limit = "256"]

use thiserror::Error;
mod api;
Expand Down
101 changes: 75 additions & 26 deletions implementations/rust/ockam/ockam_command/src/project/ticket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,21 @@ use miette::miette;
use tracing::debug;

use crate::shared_args::{IdentityOpts, RetryOpts, TrustOpts};
use crate::util::parsers::duration_parser;
use crate::util::parsers::{duration_parser, duration_to_human_format};
use crate::{docs, Command, CommandGlobalOpts, Error, Result};
use ockam::Context;
use ockam_api::authenticator::direct::{
OCKAM_ROLE_ATTRIBUTE_ENROLLER_VALUE, OCKAM_ROLE_ATTRIBUTE_KEY, OCKAM_TLS_ATTRIBUTE_KEY,
};
use ockam_api::authenticator::enrollment_tokens::TokenIssuer;
use ockam_api::authenticator::enrollment_tokens::{
TokenIssuer, DEFAULT_TOKEN_DURATION, DEFAULT_TOKEN_USAGE_COUNT, MAX_RECOMMENDED_TOKEN_DURATION,
MAX_RECOMMENDED_TOKEN_USAGE_COUNT,
};
use ockam_api::cli_state::{ExportedEnrollmentTicket, ProjectRoute};
use ockam_api::colors::color_primary;
use ockam_api::nodes::InMemoryNode;
use ockam_api::{fmt_log, fmt_ok};
use ockam_api::terminal::fmt;
use ockam_api::{fmt_info, fmt_log, fmt_ok, fmt_warn};
use ockam_multiaddr::MultiAddr;

const LONG_ABOUT: &str = include_str!("./static/ticket/long_about.txt");
Expand Down Expand Up @@ -47,7 +51,6 @@ pub struct TicketCommand {
#[arg(short, long = "attribute", value_name = "ATTRIBUTE")]
attributes: Vec<String>,

// Note: MAX_TOKEN_DURATION holds the default value.
/// Duration for which the enrollment ticket is valid, if you don't specify this, the default is 10 minutes. Examples: 10000ms, 600s, 600, 10m, 1h, 1d. If you don't specify a length sigil, it is assumed to be seconds
#[arg(long = "expires-in", value_name = "DURATION", value_parser = duration_parser)]
expires_in: Option<Duration>,
Expand Down Expand Up @@ -81,37 +84,30 @@ impl Command for TicketCommand {
}

async fn async_run(self, ctx: &Context, opts: CommandGlobalOpts) -> Result<()> {
if opts.global_args.output_format().is_json() {
return Err(miette::miette!(
"This command only outputs a hex encoded string for 'ockam project enroll' to use. \
Please try running it again without '--output json'."
)
.into());
}

let cmd = self.parse_args(&opts).await?;
let identity = opts
.state
.get_identity_name_or_default(&self.identity_opts.identity_name)
.get_identity_name_or_default(&cmd.identity_opts.identity_name)
.await?;

let node = InMemoryNode::start_with_project_name(
ctx,
&opts.state,
self.trust_opts.project_name.clone(),
cmd.trust_opts.project_name.clone(),
)
.await?;

let project = opts
.state
.projects()
.get_project_by_name_or_default(&self.trust_opts.project_name)
.get_project_by_name_or_default(&cmd.trust_opts.project_name)
.await?;

let authority_node_client = node
.create_authority_client_with_project(ctx, &project, Some(identity))
.await?;

let attributes = self.attributes()?;
let attributes = cmd.attributes()?;
debug!(attributes = ?attributes, "Attributes passed");

// Request an enrollment token that a future member can use to get a
Expand All @@ -122,7 +118,7 @@ impl Command for TicketCommand {
pb.set_message("Creating an enrollment ticket...");
}
authority_node_client
.create_token(ctx, attributes, self.expires_in, self.usage_count)
.create_token(ctx, attributes.clone(), cmd.expires_in, cmd.usage_count)
.await
.map_err(Error::Retry)?
};
Expand All @@ -147,26 +143,79 @@ impl Command for TicketCommand {
)
.import()
.await?
.export_legacy()?
.hex_encoded()?;
.export_legacy()?;

let usage_count = cmd.usage_count.unwrap_or(DEFAULT_TOKEN_USAGE_COUNT);
let attributes_msg = if attributes.is_empty() {
"".to_string()
} else {
let mut attributes_msg =
fmt_log!("The redeemer will be assigned the following attributes:\n");

for (key, value) in &attributes {
attributes_msg += &fmt_log!(
"{}{}",
fmt::INDENTATION,
color_primary(format!("\"{key}={value}\"\n"))
);
}
attributes_msg += "\n";
attributes_msg
};

opts.terminal
.write_line(fmt_ok!("Created enrollment ticket\n"))?;
opts.terminal.write_line(fmt_log!(
"You can use it to enroll another machine using: {}",
color_primary("ockam project enroll")
))?;
let plain = fmt_ok!("Created enrollment ticket\n\n")
+ &attributes_msg
+ &fmt_info!(
"It will expire in {} and it can be used {}\n",
color_primary(duration_to_human_format(
&cmd.expires_in.unwrap_or(DEFAULT_TOKEN_DURATION)
)),
if usage_count == 1 {
color_primary("once").to_string()
} else {
format!("up to {} times", color_primary(usage_count))
}
)
+ &fmt_log!(
"You can use it to enroll another machine using: {}",
color_primary("ockam project enroll")
);

opts.terminal
.stdout()
.machine(ticket.to_string())
.plain(plain)
.machine(ticket.hex_encoded()?.to_string())
.json_obj(ticket)?
.write_line()?;

Ok(())
}
}

impl TicketCommand {
async fn parse_args(self, opts: &CommandGlobalOpts) -> miette::Result<Self> {
// Handle expires_in and usage_count limits
if let Some(usage_count) = self.usage_count {
if usage_count < 1 {
return Err(miette!("The usage count must be at least 1"));
}
}
if let (Some(expires_in), Some(usage_count)) = (self.expires_in, self.usage_count) {
if expires_in >= MAX_RECOMMENDED_TOKEN_DURATION
&& usage_count >= MAX_RECOMMENDED_TOKEN_USAGE_COUNT
{
opts.terminal.write_line(
fmt_warn!(
"You are creating a ticket with a long expiration time and a high usage count\n"
) + &fmt_log!(
"This is a security risk. Please consider reducing the values according to the ticket's intended use\n"
),
)?;
}
}
Ok(self)
}

fn attributes(&self) -> Result<BTreeMap<String, String>> {
let mut attributes = BTreeMap::new();
for attr in &self.attributes {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,16 +80,17 @@ impl Command for CreateCommand {
}

async fn async_run(self, ctx: &Context, opts: CommandGlobalOpts) -> crate::Result<()> {
if self.project_relay {
print_deprecated_flag_warning(&opts, "--project-relay")?;
}

initialize_default_node(ctx, &opts).await?;

let cmd = self.parse_args(&opts).await?;
let at = cmd.at();
let alias = cmd.relay_name();
let return_timing = cmd.return_timing();

if cmd.project_relay {
print_deprecated_flag_warning(&opts, "--project-relay")?;
}

let node = BackgroundNodeClient::create(ctx, &opts.state, &cmd.to).await?;
let relay_info = {
if at.starts_with(Project::CODE) && cmd.authorized.is_some() {
Expand Down
22 changes: 22 additions & 0 deletions implementations/rust/ockam/ockam_command/src/util/parsers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,25 @@ pub(crate) fn project_name_parser(s: &str) -> Result<String> {
pub(crate) fn duration_parser(arg: &str) -> std::result::Result<Duration, clap::Error> {
parse_duration(arg).map_err(|_| Error::raw(ErrorKind::InvalidValue, "Invalid duration."))
}

pub(crate) fn duration_to_human_format(duration: &Duration) -> String {
let mut parts = vec![];
let secs = duration.as_secs();
let days = secs / 86400;
if days > 0 {
parts.push(format!("{}d", days));
}
let hours = (secs % 86400) / 3600;
if hours > 0 {
parts.push(format!("{}h", hours));
}
let minutes = (secs % 3600) / 60;
if minutes > 0 {
parts.push(format!("{}m", minutes));
}
let seconds = secs % 60;
if seconds > 0 {
parts.push(format!("{}s", seconds));
}
parts.join(" ")
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ pub async fn parse_enrollment_ticket(
let contents = parse_string_or_path_or_url(value).await?;

// Try to parse it using the old format
if let Ok(ticket) = LegacyEnrollmentTicket::from_hex(&contents) {
if let Ok(ticket) = LegacyEnrollmentTicket::from_str(&contents) {
// TODO: disabled until release 0.138.0
// opts.terminal.write_line(fmt_warn!(
// "The enrollment ticket was generated from an old Ockam version"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -259,3 +259,19 @@ EOF
# Check that the identity can reach the project
run_success $OCKAM message send hi --to "/project/default/service/echo"
}

@test "nodes - create with config, using a json-encoded enrollment ticket" {
$OCKAM project ticket --output json >"$OCKAM_HOME/enrollment.ticket"
export ENROLLMENT_TICKET="$OCKAM_HOME/enrollment.ticket"

cat <<EOF >"$OCKAM_HOME/config.yaml"
ticket: ${ENROLLMENT_TICKET}
name: n1
EOF

run_success "$OCKAM" node create "$OCKAM_HOME/config.yaml"
run_success "$OCKAM" node show n1

# Check that the identity can reach the project
run_success $OCKAM message send hi --to "/project/default/service/echo"
}
Loading