use std::fmt::{Debug, Formatter, Write};
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use clap::Args;
use colorful::Colorful;
use miette::Context as _;
use miette::{miette, IntoDiagnostic};
use serde::Serialize;
use crate::credential::CredentialOutput;
use crate::enroll::OidcServiceExt;
use crate::shared_args::{IdentityOpts, RetryOpts, TrustOpts};
use crate::util::parsers::duration_parser;
use crate::value_parsers::parse_enrollment_ticket;
use crate::{docs, Command, CommandGlobalOpts, Error, Result};
use ockam::Context;
use ockam_api::cli_state::{EnrollmentTicket, NamedIdentity};
use ockam_api::colors::color_primary;
use ockam_api::enroll::enrollment::{EnrollStatus, Enrollment};
use ockam_api::enroll::oidc_service::OidcService;
use ockam_api::enroll::okta_oidc_provider::OktaOidcProvider;
use ockam_api::nodes::InMemoryNode;
use ockam_api::orchestrator::project::models::OktaAuth0;
use ockam_api::orchestrator::AuthorityNodeClient;
use ockam_api::output::{human_readable_time, Output};
use ockam_api::terminal::fmt;
use ockam_api::{fmt_log, fmt_ok};
const LONG_ABOUT: &str = include_str!("./static/enroll/long_about.txt");
const AFTER_LONG_HELP: &str = include_str!("./static/enroll/after_long_help.txt");
#[derive(Clone, Args)]
#[command(
long_about = docs::about(LONG_ABOUT),
after_long_help = docs::after_help(AFTER_LONG_HELP)
)]
pub struct EnrollCommand {
#[arg(
display_order = 800,
group = "authentication_method",
value_name = "ENROLLMENT TICKET"
)]
pub enrollment_ticket: Option<String>,
#[command(flatten)]
pub identity_opts: IdentityOpts,
#[command(flatten)]
pub trust_opts: TrustOpts,
#[arg(display_order = 900, long = "okta", group = "authentication_method")]
pub okta: bool,
#[command(flatten)]
pub retry_opts: RetryOpts,
#[arg(long, value_name = "TIMEOUT", default_value = "240s", value_parser = duration_parser)]
pub timeout: Duration,
#[arg(hide = true, long, default_value = "false")]
pub skip_credential_issue: bool,
}
impl Debug for EnrollCommand {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EnrollCommand")
.field("identity_opts", &self.identity_opts)
.field("trust_opts", &self.trust_opts)
.field("okta", &self.okta)
.field("retry_opts", &self.retry_opts)
.field("timeout", &self.timeout)
.finish()
}
}
#[async_trait]
impl Command for EnrollCommand {
const NAME: &'static str = "project enroll";
fn retry_opts(&self) -> Option<RetryOpts> {
Some(self.retry_opts.clone())
}
async fn run(self, ctx: &Context, opts: CommandGlobalOpts) -> crate::Result<()> {
let (project, enrollment_ticket) = if let Some(enrollment_ticket) = &self.enrollment_ticket
{
let enrollment_ticket = parse_enrollment_ticket(&opts, enrollment_ticket).await?;
let project = opts
.state
.projects()
.import_and_store_project(enrollment_ticket.project()?)
.await?;
(project, Some(enrollment_ticket))
} else {
let enrollment_ticket = None;
let project = opts.state
.projects().get_project_by_name_or_default(&self.trust_opts.project_name)
.await
.context("A default project or project parameter is required. Run 'ockam project list' to get a list of available projects. You might also need to pass an enrollment ticket or path to the command.")?;
(project, enrollment_ticket)
};
let identity = opts
.state
.get_named_identity_or_default(&self.identity_opts.identity_name)
.await?;
let node = InMemoryNode::start_with_project_name(
ctx,
&opts.state,
Some(project.name().to_string()),
)
.await?
.with_timeout(self.timeout);
let authority_node_client = node
.create_authority_client_with_project(ctx, &project, Some(identity.name()), false)
.await?;
if self.okta {
self.use_okta(ctx, &opts, &authority_node_client).await?;
} else if let Some(enrollment_ticket) = enrollment_ticket {
self.use_enrollment_ticket(ctx, &opts, &authority_node_client, enrollment_ticket)
.await?;
}
let credential = if opts.state.is_using_in_memory_database()? || self.skip_credential_issue
{
None
} else {
let pb = opts.terminal.spinner();
if let Some(pb) = pb.as_ref() {
pb.set_message("Issuing credential...");
}
let credential = authority_node_client
.issue_credential(ctx)
.await
.map_err(Error::Retry)
.into_diagnostic()
.wrap_err("Failed to decode the credential received from the project authority")?;
Some(CredentialOutput::from_credential(credential)?)
};
let project_name = {
let project = opts
.state
.projects()
.get_project_by_name_or_default(&self.trust_opts.project_name.clone())
.await?;
project.name().to_string()
};
let output = ProjectEnrollOutput::new(identity, project_name, credential);
opts.terminal
.clone()
.to_stdout()
.plain(output.item()?)
.json_obj(output)?
.write_line()?;
Ok(())
}
}
impl EnrollCommand {
async fn use_enrollment_ticket(
&self,
ctx: &Context,
opts: &CommandGlobalOpts,
authority_node_client: &AuthorityNodeClient,
enrollment_ticket: EnrollmentTicket,
) -> Result<()> {
let enroll_status = {
let pb = opts.terminal.spinner();
if let Some(pb) = pb.as_ref() {
pb.set_message("Using enrollment ticket to enroll identity...");
}
authority_node_client
.present_token(ctx, &enrollment_ticket.one_time_code)
.await?
};
match enroll_status {
EnrollStatus::EnrolledSuccessfully => {}
EnrollStatus::AlreadyEnrolled => {
opts.terminal
.write_line(fmt_ok!("Identity is already enrolled with the project"))?;
}
EnrollStatus::FailedNoStatus(msg) => {
return Err(Error::Retry(miette!(
"Failed to enroll identity with project. {msg}"
)))
.into_diagnostic()
}
EnrollStatus::UnexpectedStatus(msg, status) => {
return Err(Error::Retry(miette!(
"Failed to enroll identity with project. {msg} {status}"
)))
.into_diagnostic()
}
}
Ok(())
}
async fn use_okta(
&self,
ctx: &Context,
opts: &CommandGlobalOpts,
authority_node_client: &AuthorityNodeClient,
) -> Result<()> {
let project = opts.state
.projects().get_project_by_name_or_default(&self.trust_opts.project_name)
.await
.context("A default project or project parameter is required. Run 'ockam project list' to get a list of available projects. You might also need to pass an enrollment ticket or path to the command.")?;
let okta_config: OktaAuth0 = project
.model()
.okta_config
.clone()
.ok_or(miette!("Okta addon not configured"))?
.into();
let pb = opts.terminal.spinner();
if let Some(pb) = pb.as_ref() {
pb.set_message("Authenticating with Okta...");
}
let auth0 = OidcService::new_with_provider(Arc::new(OktaOidcProvider::new(okta_config)));
let token = auth0.get_token_interactively(opts).await?;
authority_node_client
.enroll_with_oidc_token_okta(ctx, token)
.await
.map_err(Error::Retry)?;
Ok(())
}
}
#[derive(Serialize)]
struct ProjectEnrollOutput {
identity: NamedIdentity,
project_name: String,
credential: Option<CredentialOutput>,
}
impl ProjectEnrollOutput {
fn new(
identity: NamedIdentity,
project_name: String,
credential: Option<CredentialOutput>,
) -> Self {
Self {
identity,
project_name,
credential,
}
}
}
impl Output for ProjectEnrollOutput {
fn item(&self) -> ockam_api::Result<String> {
let mut f = String::new();
writeln!(
f,
"{}",
fmt_ok!(
"Successfully enrolled identity {} to the {} project.\n",
color_primary(self.identity.name()),
color_primary(&self.project_name)
)
)?;
if let Some(credential) = self.credential.as_ref() {
writeln!(
f,
"{}",
fmt_log!("The identity has a credential in this project")
)?;
writeln!(
f,
"{}",
fmt_log!(
"created at {} that expires at {}\n",
color_primary(human_readable_time(credential.created_at)),
color_primary(human_readable_time(credential.expires_at))
)
)?;
if !credential.attributes.is_empty() {
writeln!(
f,
"{}",
fmt_log!(
"The following attributes are attested by the project's membership authority:"
)
)?;
let mut attributes = credential.attributes.iter().collect::<Vec<_>>();
attributes.sort();
for (k, v) in attributes.iter() {
writeln!(
f,
"{}",
fmt_log!(
"{}{}",
fmt::INDENTATION,
color_primary(format!("\"{k}={v}\""))
)
)?;
}
}
}
Ok(f)
}
}
#[cfg(test)]
mod tests {
use crate::run::parser::resource::utils::parse_cmd_from_args;
use super::*;
#[test]
fn command_can_be_parsed_from_name() {
let cmd = parse_cmd_from_args(EnrollCommand::NAME, &[]);
assert!(cmd.is_ok());
}
}