use std::path::PathBuf;
use greentic_deploy_spec::EnvId;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use thiserror::Error;
use zeroize::Zeroizing;
use crate::credentials::{
RunBootstrapError, ValidateError, ZeroizedAdmin, run_bootstrap, validate_requirements,
};
use crate::env_packs::EnvPackRegistry;
use crate::environment::{EnvironmentStore, LocalFsStore};
use super::{AuditCtx, AuditGens, OpError, OpFlags, OpOutcome, audit_and_record};
const NOUN: &str = "credentials";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CredentialsRequirementsPayload {
pub environment_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CredentialsBootstrapPayload {
pub environment_id: String,
pub admin_profile: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub admin_material_path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub admin_material_inline: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CredentialsRotatePayload {
pub environment_id: String,
}
#[derive(Debug, Error)]
enum AdminLoadError {
#[error("no admin material supplied: provide `admin_material_path` or `admin_material_inline`")]
Missing,
#[error("cannot supply both `admin_material_path` and `admin_material_inline`")]
Both,
#[error("read admin material from `{path}`: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("admin material at `{path}` is not valid UTF-8")]
NonUtf8 { path: PathBuf },
#[error("admin material is empty")]
Empty,
}
pub fn requirements(
store: &LocalFsStore,
registry: &EnvPackRegistry,
flags: &OpFlags,
payload: Option<CredentialsRequirementsPayload>,
) -> Result<OpOutcome, OpError> {
if flags.schema_only {
return Ok(OpOutcome::new(NOUN, "requirements", req_schema()));
}
let payload = resolve_payload::<CredentialsRequirementsPayload>(flags, payload)?;
let env_id = parse_env_id(&payload.environment_id)?;
let (doc, report) =
validate_requirements(store, registry, &env_id).map_err(map_validate_err)?;
Ok(OpOutcome::new(
NOUN,
"requirements",
json!({
"environment_id": env_id.as_str(),
"deployer_kind": doc.deployer_kind.as_str(),
"credentials_ref": doc.provided_credentials_ref.as_str(),
"mode": "requirements",
"result": result_label(&doc.validation.result),
"missing_capabilities": doc.validation.missing_capabilities,
"checks": report.checks,
"last_run_at": doc.validation.last_run_at,
}),
))
}
pub fn bootstrap(
store: &LocalFsStore,
registry: &EnvPackRegistry,
flags: &OpFlags,
payload: Option<CredentialsBootstrapPayload>,
) -> Result<OpOutcome, OpError> {
if flags.schema_only {
return Ok(OpOutcome::new(NOUN, "bootstrap", bootstrap_schema()));
}
let mut payload = resolve_payload::<CredentialsBootstrapPayload>(flags, payload)?;
let env_id = parse_env_id(&payload.environment_id)?;
let admin = load_admin_credential(&mut payload).map_err(|e| {
OpError::Conflict(e.to_string())
})?;
let ctx = AuditCtx {
env_id: env_id.clone(),
noun: NOUN,
verb: "bootstrap",
target: json!({"admin_profile": payload.admin_profile}),
idempotency_key: None,
};
audit_and_record(store, ctx, |committed| {
let doc = match run_bootstrap(store, registry, &env_id, &admin) {
Ok(d) => d,
Err(e) => return Err(map_bootstrap_err(e)),
};
committed.mark_committed();
Ok((
OpOutcome::new(
NOUN,
"bootstrap",
json!({
"environment_id": env_id.as_str(),
"deployer_kind": doc.deployer_kind.as_str(),
"mode": "bootstrap",
"credentials_ref": doc.provided_credentials_ref.as_str(),
"rules_pack_ref": doc.bootstrap.as_ref().map(|b| b.rules_pack_ref.display().to_string()),
"admin_credential_consumed_at": doc.bootstrap.as_ref().map(|b| b.admin_credential_consumed_at),
}),
),
AuditGens::NONE,
))
})
}
pub fn rotate(
store: &LocalFsStore,
_registry: &EnvPackRegistry,
flags: &OpFlags,
payload: Option<CredentialsRotatePayload>,
) -> Result<OpOutcome, OpError> {
if flags.schema_only {
return Ok(OpOutcome::new(NOUN, "rotate", rotate_schema()));
}
let payload = resolve_payload::<CredentialsRotatePayload>(flags, payload)?;
let env_id = parse_env_id(&payload.environment_id)?;
let env = store.load(&env_id).map_err(|e| match e {
crate::environment::StoreError::NotFound(_) => {
OpError::NotFound(format!("environment `{env_id}`"))
}
other => OpError::Store(other),
})?;
if env
.pack_for_slot(greentic_deploy_spec::CapabilitySlot::Deployer)
.is_none()
{
return Err(OpError::Conflict(format!(
"env `{env_id}` has no deployer env-pack bound; bind one with `op env-packs add` first"
)));
}
if env.credentials_ref.is_none() {
return Err(OpError::Conflict(format!(
"env `{env_id}` has no credentials_ref; run `op credentials bootstrap` first"
)));
}
let ctx = AuditCtx {
env_id: env_id.clone(),
noun: NOUN,
verb: "rotate",
target: json!({}),
idempotency_key: None,
};
audit_and_record(store, ctx, |_committed| {
Err(OpError::NotYetImplemented(
"session-token rotation hooks are Phase D \
— use `op credentials requirements` for re-validation today"
.to_string(),
))
})
}
fn result_label(r: &greentic_deploy_spec::CredentialsValidationResult) -> &'static str {
match r {
greentic_deploy_spec::CredentialsValidationResult::Pass => "pass",
greentic_deploy_spec::CredentialsValidationResult::Fail => "fail",
}
}
fn load_admin_credential(
payload: &mut CredentialsBootstrapPayload,
) -> Result<ZeroizedAdmin, AdminLoadError> {
match (&payload.admin_material_path, &payload.admin_material_inline) {
(None, None) => Err(AdminLoadError::Missing),
(Some(_), Some(_)) => Err(AdminLoadError::Both),
(Some(path), None) => {
let mut bytes =
Zeroizing::new(std::fs::read(path).map_err(|source| AdminLoadError::Io {
path: path.clone(),
source,
})?);
let raw = std::mem::take(&mut *bytes);
let material = String::from_utf8(raw)
.map_err(|_| AdminLoadError::NonUtf8 { path: path.clone() })?;
if material.trim().is_empty() {
return Err(AdminLoadError::Empty);
}
Ok(ZeroizedAdmin::new(&payload.admin_profile, material))
}
(None, Some(_)) => {
let taken = std::mem::take(&mut payload.admin_material_inline)
.expect("matched Some branch; take cannot be None");
if taken.trim().is_empty() {
return Err(AdminLoadError::Empty);
}
Ok(ZeroizedAdmin::new(&payload.admin_profile, taken))
}
}
}
fn no_deployer_bound_msg(env_id: &EnvId) -> String {
format!("env `{env_id}` has no deployer env-pack bound; bind one with `op env-packs add` first")
}
fn handler_not_registered_msg(kind: &str) -> String {
format!(
"deployer env-pack `{kind}` has no native credentials handler registered (Phase D plug-in)"
)
}
fn map_validate_err(e: ValidateError) -> OpError {
match e {
ValidateError::NoDeployerBound(env_id) => OpError::Conflict(no_deployer_bound_msg(&env_id)),
ValidateError::NoCredentialsRef(env_id) => OpError::Conflict(format!(
"env `{env_id}` has no credentials_ref; run `op credentials bootstrap` first"
)),
ValidateError::HandlerNotRegistered { kind } => {
OpError::Conflict(handler_not_registered_msg(&kind))
}
ValidateError::Store(s) => OpError::Store(s),
ValidateError::Registry(r) => OpError::Conflict(r.to_string()),
}
}
fn map_bootstrap_err(e: RunBootstrapError) -> OpError {
use crate::credentials::BootstrapError;
match e {
RunBootstrapError::NoDeployerBound(env_id) => {
OpError::Conflict(no_deployer_bound_msg(&env_id))
}
RunBootstrapError::AlreadyBootstrapped(env_id) => OpError::Conflict(format!(
"env `{env_id}` already has credentials_ref; use `rotate` instead of `bootstrap`"
)),
RunBootstrapError::HandlerNotRegistered { kind } => {
OpError::Conflict(handler_not_registered_msg(&kind))
}
RunBootstrapError::Store(s) => OpError::Store(s),
RunBootstrapError::Registry(r) => OpError::Conflict(r.to_string()),
RunBootstrapError::Bootstrap(BootstrapError::NotApplicable(msg)) => OpError::Conflict(msg),
RunBootstrapError::Bootstrap(BootstrapError::AdminRejected(msg)) => {
OpError::Conflict(format!("admin credential rejected: {msg}"))
}
RunBootstrapError::Bootstrap(BootstrapError::ProvisioningFailed { step, message }) => {
OpError::Conflict(format!("bootstrap failed during {step}: {message}"))
}
RunBootstrapError::RulesExport(r) => OpError::Conflict(format!("rules export: {r}")),
}
}
fn resolve_payload<T: serde::de::DeserializeOwned>(
flags: &OpFlags,
payload: Option<T>,
) -> Result<T, OpError> {
if let Some(p) = payload {
return Ok(p);
}
if let Some(path) = &flags.answers {
return super::load_answers::<T>(path);
}
Err(OpError::InvalidArgument(
"no payload provided: pass --answers <path> or supply the payload directly".to_string(),
))
}
fn parse_env_id(raw: &str) -> Result<EnvId, OpError> {
EnvId::try_from(raw).map_err(|e| OpError::InvalidArgument(format!("environment_id: {e}")))
}
fn req_schema() -> Value {
json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "CredentialsRequirementsPayload",
"type": "object",
"required": ["environment_id"],
"additionalProperties": false,
"properties": {"environment_id": {"type": "string"}}
})
}
fn bootstrap_schema() -> Value {
json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "CredentialsBootstrapPayload",
"type": "object",
"required": ["environment_id", "admin_profile"],
"additionalProperties": false,
"properties": {
"environment_id": {"type": "string"},
"admin_profile": {"type": "string"},
"admin_material_path": {"type": "string", "description": "path to a file holding the admin credential material (zeroized on drop)"},
"admin_material_inline": {"type": "string", "description": "inline admin credential material (zeroized on drop); mutually exclusive with admin_material_path"}
}
})
}
fn rotate_schema() -> Value {
json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "CredentialsRotatePayload",
"type": "object",
"required": ["environment_id"],
"additionalProperties": false,
"properties": {"environment_id": {"type": "string"}}
})
}
#[cfg(test)]
pub(crate) fn requirements_default(
store: &LocalFsStore,
flags: &OpFlags,
payload: Option<CredentialsRequirementsPayload>,
) -> Result<OpOutcome, OpError> {
requirements(store, &EnvPackRegistry::with_builtins(), flags, payload)
}
#[cfg(test)]
pub(crate) fn bootstrap_default(
store: &LocalFsStore,
flags: &OpFlags,
payload: Option<CredentialsBootstrapPayload>,
) -> Result<OpOutcome, OpError> {
bootstrap(store, &EnvPackRegistry::with_builtins(), flags, payload)
}
#[cfg(test)]
pub(crate) fn rotate_default(
store: &LocalFsStore,
flags: &OpFlags,
payload: Option<CredentialsRotatePayload>,
) -> Result<OpOutcome, OpError> {
rotate(store, &EnvPackRegistry::with_builtins(), flags, payload)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::tests_common::{make_binding, make_env};
use crate::environment::EnvironmentStore;
use greentic_deploy_spec::{CapabilitySlot, SecretRef};
use tempfile::tempdir;
#[test]
fn requirements_rejects_env_without_deployer() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
store.save(&make_env("local")).unwrap();
let err = requirements_default(
&store,
&OpFlags::default(),
Some(CredentialsRequirementsPayload {
environment_id: "local".to_string(),
}),
)
.unwrap_err();
assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
}
#[test]
fn requirements_passes_for_no_material_deployer_without_credentials_ref() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let mut env = make_env("local");
env.packs.push(make_binding(
CapabilitySlot::Deployer,
"greentic.deployer.local-process@0.1.0",
));
store.save(&env).unwrap();
let outcome = requirements_default(
&store,
&OpFlags::default(),
Some(CredentialsRequirementsPayload {
environment_id: "local".to_string(),
}),
)
.unwrap();
assert_eq!(outcome.result["mode"], "requirements");
assert_eq!(outcome.result["result"], "pass");
}
#[test]
fn requirements_with_unregistered_deployer_kind_yields_conflict() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let mut env = make_env("local");
env.packs.push(make_binding(
CapabilitySlot::Deployer,
"acme.deployer.fictional@1.0.0",
));
env.credentials_ref = Some(SecretRef::try_new("secret://local/credentials/aws").unwrap());
store.save(&env).unwrap();
let err = requirements_default(
&store,
&OpFlags::default(),
Some(CredentialsRequirementsPayload {
environment_id: "local".to_string(),
}),
)
.unwrap_err();
assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
}
#[test]
fn bootstrap_rejects_when_creds_already_set() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let mut env = make_env("local");
env.packs.push(make_binding(
CapabilitySlot::Deployer,
"greentic.deployer.aws-ecs@1.0.0",
));
env.credentials_ref = Some(SecretRef::try_new("secret://local/credentials/aws").unwrap());
store.save(&env).unwrap();
let err = bootstrap_default(
&store,
&OpFlags::default(),
Some(CredentialsBootstrapPayload {
environment_id: "local".to_string(),
admin_profile: "admin".to_string(),
admin_material_path: None,
admin_material_inline: Some("ADMIN_TOKEN".to_string()),
}),
)
.unwrap_err();
assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
}
#[test]
fn bootstrap_requires_admin_material() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let mut env = make_env("local");
env.packs.push(make_binding(
CapabilitySlot::Deployer,
"greentic.deployer.aws-ecs@1.0.0",
));
store.save(&env).unwrap();
let err = bootstrap_default(
&store,
&OpFlags::default(),
Some(CredentialsBootstrapPayload {
environment_id: "local".to_string(),
admin_profile: "admin".to_string(),
admin_material_path: None,
admin_material_inline: None,
}),
)
.unwrap_err();
assert!(
matches!(err, OpError::Conflict(ref m) if m.contains("no admin material")),
"got {err:?}"
);
}
#[test]
fn bootstrap_rejects_both_path_and_inline() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let mut env = make_env("local");
env.packs.push(make_binding(
CapabilitySlot::Deployer,
"greentic.deployer.aws-ecs@1.0.0",
));
store.save(&env).unwrap();
let err = bootstrap_default(
&store,
&OpFlags::default(),
Some(CredentialsBootstrapPayload {
environment_id: "local".to_string(),
admin_profile: "admin".to_string(),
admin_material_path: Some("/tmp/x".into()),
admin_material_inline: Some("y".into()),
}),
)
.unwrap_err();
assert!(
matches!(err, OpError::Conflict(ref m) if m.contains("cannot supply both")),
"got {err:?}"
);
}
#[test]
fn requirements_against_c2_local_process_handler_returns_pass() {
use crate::defaults::LOCAL_DEPLOYER_PACK;
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let mut env = make_env("local");
env.packs
.push(make_binding(CapabilitySlot::Deployer, LOCAL_DEPLOYER_PACK));
env.credentials_ref =
Some(SecretRef::try_new("secret://local/credentials/local-process").unwrap());
store.save(&env).unwrap();
let registry = {
let mut r = EnvPackRegistry::new();
for h in crate::env_packs::BUILTIN_HANDLERS {
r.register(Box::new(*h)).unwrap();
}
r.register(Box::new(
crate::env_packs::LocalProcessDeployerHandler::with_port_range(49000..=49100),
))
.unwrap();
r
};
let outcome = requirements(
&store,
®istry,
&OpFlags::default(),
Some(CredentialsRequirementsPayload {
environment_id: "local".to_string(),
}),
)
.expect("requirements should succeed against c2 local-process");
assert_eq!(outcome.op, "requirements");
assert_eq!(outcome.noun, NOUN);
assert_eq!(outcome.result["result"], "pass");
assert!(
outcome.result["missing_capabilities"]
.as_array()
.map(|a| a.is_empty())
.unwrap_or(false),
"no missing caps; got {outcome:?}"
);
let checks = outcome.result["checks"].as_array().unwrap();
assert_eq!(checks.len(), 2);
}
#[test]
fn bootstrap_against_c2_local_process_handler_refuses_as_not_applicable() {
use crate::defaults::LOCAL_DEPLOYER_PACK;
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let mut env = make_env("local");
env.packs
.push(make_binding(CapabilitySlot::Deployer, LOCAL_DEPLOYER_PACK));
store.save(&env).unwrap();
let err = bootstrap_default(
&store,
&OpFlags::default(),
Some(CredentialsBootstrapPayload {
environment_id: "local".to_string(),
admin_profile: "admin".to_string(),
admin_material_path: None,
admin_material_inline: Some("any-admin-token".to_string()),
}),
)
.unwrap_err();
match err {
OpError::Conflict(msg) => {
assert!(
msg.contains("no admin escalation") || msg.contains("requirements"),
"Conflict message should point user at `requirements`, got: {msg}"
);
}
other => panic!("expected Conflict (NotApplicable mapped), got {other:?}"),
}
let reloaded = store.load(&"local".try_into().unwrap()).unwrap();
assert!(reloaded.credentials_ref.is_none());
}
#[test]
fn rotate_rejects_env_without_credentials_ref() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let mut env = make_env("local");
env.packs.push(make_binding(
CapabilitySlot::Deployer,
"greentic.deployer.aws-ecs@1.0.0",
));
store.save(&env).unwrap();
let err = rotate_default(
&store,
&OpFlags::default(),
Some(CredentialsRotatePayload {
environment_id: "local".to_string(),
}),
)
.unwrap_err();
assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
}
#[test]
fn rotate_returns_not_yet_implemented() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let mut env = make_env("local");
env.packs.push(make_binding(
CapabilitySlot::Deployer,
"greentic.deployer.local-process@0.1.0",
));
env.credentials_ref = Some(SecretRef::try_new("secret://local/credentials/test").unwrap());
store.save(&env).unwrap();
let err = rotate_default(
&store,
&OpFlags::default(),
Some(CredentialsRotatePayload {
environment_id: "local".to_string(),
}),
)
.unwrap_err();
assert!(
matches!(err, OpError::NotYetImplemented(_)),
"rotate should return NotYetImplemented, got {err:?}"
);
}
#[test]
fn no_material_deployer_requirements_without_credentials_ref() {
use crate::credentials::{
BootstrapError, BootstrapInput, BootstrapOutcome, Capability, DeployerCredentials,
RequirementsReport, ValidationContext,
};
use crate::env_packs::EnvPackHandler;
#[derive(Debug)]
struct NoMaterialHandler;
impl EnvPackHandler for NoMaterialHandler {
fn slot(&self) -> CapabilitySlot {
CapabilitySlot::Deployer
}
fn descriptor_path(&self) -> &str {
"test.deployer.no-material"
}
fn supported_versions(&self) -> semver::VersionReq {
"^0.1.0".parse().unwrap()
}
fn deployer_credentials(&self) -> Option<&dyn DeployerCredentials> {
Some(&NoMaterialCreds)
}
}
#[derive(Debug)]
struct NoMaterialCreds;
impl DeployerCredentials for NoMaterialCreds {
fn requires_credentials_material(&self) -> bool {
false
}
fn required_capabilities(&self) -> Vec<Capability> {
Vec::new()
}
fn validate(&self, _ctx: &ValidationContext<'_>) -> RequirementsReport {
RequirementsReport::new(Vec::new())
}
fn bootstrap(
&self,
_input: &BootstrapInput<'_>,
) -> Result<BootstrapOutcome, BootstrapError> {
Err(BootstrapError::NotApplicable(
"no admin escalation".to_string(),
))
}
}
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let mut env = make_env("local");
env.packs.push(make_binding(
CapabilitySlot::Deployer,
"test.deployer.no-material@0.1.0",
));
store.save(&env).unwrap();
let mut registry = EnvPackRegistry::new();
registry.register(Box::new(NoMaterialHandler)).unwrap();
let outcome = requirements(
&store,
®istry,
&OpFlags::default(),
Some(CredentialsRequirementsPayload {
environment_id: "local".to_string(),
}),
)
.unwrap();
assert_eq!(outcome.result["mode"], "requirements");
assert_eq!(outcome.result["result"], "pass");
assert!(
outcome.result["credentials_ref"]
.as_str()
.unwrap()
.contains("no-material-required"),
"expected sentinel credentials_ref, got {:?}",
outcome.result["credentials_ref"]
);
}
#[test]
fn requirements_default_passes_for_local_deployer_without_credentials_ref() {
use crate::defaults::LOCAL_DEPLOYER_PACK;
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let mut env = make_env("local");
env.packs
.push(make_binding(CapabilitySlot::Deployer, LOCAL_DEPLOYER_PACK));
store.save(&env).unwrap();
let outcome = requirements_default(
&store,
&OpFlags::default(),
Some(CredentialsRequirementsPayload {
environment_id: "local".to_string(),
}),
)
.expect("default registry requirements should pass for local-process");
assert_eq!(outcome.result["result"], "pass");
let checks = outcome.result["checks"].as_array().unwrap();
assert_eq!(
checks.len(),
2,
"local-process declares 2 capabilities (fs + port)"
);
assert!(
outcome.result["credentials_ref"]
.as_str()
.unwrap()
.contains("no-material-required"),
"expected sentinel ref, got {:?}",
outcome.result["credentials_ref"]
);
}
}