greentic_deployer/credentials/mod.rs
1//! C1: credentials contract for deployer env-packs.
2//!
3//! Every deployer env-pack ships a [`DeployerCredentials`] implementation
4//! that declares what capabilities its credentials must satisfy and how to
5//! probe them. Phase A's CLI surface (`gtc op credentials …`) drives this
6//! contract through the env-pack registry — `requirements` validates against
7//! the bound deployer, `bootstrap` runs the deployer's bootstrap path.
8//!
9//! Admin credentials are never intentionally persisted. The
10//! [`ZeroizedAdmin`] wrapper zeroizes its in-process buffer on drop where
11//! the language/runtime allows it. The contract is honest about what it
12//! cannot guarantee: process-wide memory erasure is impossible (the OS may
13//! have paged the buffer, the cloud SDK may hold its own copy, ambient
14//! profile chains live outside our control). Callers should run on
15//! short-lived processes when this matters.
16//!
17//! ## Phase A constraint
18//!
19//! Env-pack handlers are metadata-only in Phase A (see
20//! [`env_packs::slot`](crate::env_packs::slot)) — there is no wired secrets
21//! backend yet. Probes that need credential material (reading a key from
22//! AWS-SM, calling AWS STS) cannot run today; impls report
23//! [`CapabilityStatus::Skipped`] for those entries instead of panicking.
24//! Local-process credentials work today because they probe only the local
25//! environment (filesystem writability, port availability) and need no
26//! credential material at all (C2).
27
28pub mod bootstrap;
29pub mod rotate;
30pub mod rules_export;
31pub mod validate;
32
33pub use bootstrap::{
34 BootstrapError, BootstrapInput, BootstrapOutcome, BoundSecretSink, RunBootstrapError,
35 ZeroizedAdmin, run_bootstrap,
36};
37pub use rotate::{RotateOutcome, RunRotateError, run_rotate};
38pub use rules_export::{RulesExportError, RulesPack, RulesPackEntry, write_rules_pack};
39pub use validate::{
40 Capability, CapabilityCheck, CapabilityStatus, RequirementsReport, ValidateError,
41 ValidationContext, validate_requirements,
42};
43
44use chrono::{DateTime, Utc};
45
46/// Contract a deployer env-pack handler implements to surface its
47/// credentials story to the `gtc op credentials` CLI.
48///
49/// Object-safe so the env-pack registry can return `&dyn`. Implementations
50/// must be `Send + Sync` because the registry is shared across the
51/// operator's request handlers.
52pub trait DeployerCredentials: std::fmt::Debug + Send + Sync {
53 /// Whether this deployer requires real credential material at all.
54 ///
55 /// Deployers that run purely locally (e.g. local-process — no IAM
56 /// roles, no cluster RBAC, no cloud credentials) return `false`.
57 /// When `false`:
58 /// - `validate_requirements` skips the `NoCredentialsRef` rejection
59 /// for envs that have no `credentials_ref`.
60 /// - `bootstrap` should return [`BootstrapError::NotApplicable`].
61 ///
62 /// Default is `true`, preserving Phase D AWS/K8s/GCP/Azure behavior.
63 fn requires_credentials_material(&self) -> bool {
64 true
65 }
66
67 /// The set of capabilities the deployer's credentials must satisfy.
68 /// Order is stable — the CLI renders this as the column order in
69 /// `gtc op credentials requirements` output. Used both as the
70 /// declaration of what *would* be checked (`--schema`-like surface) and
71 /// as the iteration order for [`validate`](Self::validate).
72 fn required_capabilities(&self) -> Vec<Capability>;
73
74 /// Probe the env's local state against [`required_capabilities`]. The
75 /// validator MUST NOT mutate `ctx` and MUST NOT panic on probe failure;
76 /// it returns a structured [`Failed`](CapabilityStatus::Fail) or
77 /// [`Skipped`](CapabilityStatus::Skipped) entry instead.
78 fn validate(&self, ctx: &ValidationContext<'_>) -> RequirementsReport;
79
80 /// Run the deployer's bootstrap path against ephemeral admin
81 /// credentials.
82 ///
83 /// Implementations with no admin escalation (e.g. the local-process
84 /// deployer — there are no IAM roles or cluster RBAC to provision
85 /// locally) MUST return [`BootstrapError::NotApplicable`] with a
86 /// message telling the user to run `requirements` instead. Returning
87 /// `Ok` with an empty outcome would be dishonest (no admin was
88 /// actually consumed) and would leave a sentinel `credentials_ref`
89 /// pointing at nothing.
90 fn bootstrap(&self, input: &BootstrapInput<'_>) -> Result<BootstrapOutcome, BootstrapError>;
91
92 /// Best-effort compensating cleanup for a bootstrap that wrote durable
93 /// credential material to a REMOTE backend (e.g. the K8s `--bind` path
94 /// writes the minted bearer into an in-cluster Secret) but then failed a
95 /// later persistence step. Without this a failed bootstrap could leave a
96 /// live bearer in the backend while the env stays unbound. The CLI calls it
97 /// on the bootstrap error path; the delete is idempotent (a never-written
98 /// Secret 404s harmlessly), so an unconditional call is safe.
99 ///
100 /// Default is a no-op — deployers that bind no remote material (the
101 /// local-process / render-only paths) have nothing to undo. Implementations
102 /// MUST NOT panic; cleanup failures are swallowed (the caller already has a
103 /// bootstrap error to report). It does NOT cover a hard process crash
104 /// between the remote write and the local commit — short-lived bound tokens
105 /// + rotation are the systemic mitigation for that residual window.
106 fn rollback_bound_material(&self, _env_id: &greentic_deploy_spec::EnvId) {}
107
108 /// The absolute time the given bound credential *material* should be
109 /// rotated, derived from the material's own self-reported lifetime (e.g.
110 /// the K8s projected-ServiceAccount-token JWT's `iat`/`exp` claims).
111 /// Returns `None` when the lifetime can't be determined.
112 ///
113 /// Default is `None`: deployers that mint no time-bounded material (the
114 /// render-only / local paths, and AWS until its STS producer lands) have
115 /// no rotation point to compute. Backends that mint bounded credentials
116 /// override this to decode their own material format — the rotation
117 /// *policy* (rotate at 80% of lifetime) stays shared in
118 /// [`rotate::rotate_at_from_window`], only the decode varies.
119 fn rotate_at(&self, _material: &str) -> Option<DateTime<Utc>> {
120 None
121 }
122
123 /// Whether the given bound material is at/past its rotation point.
124 ///
125 /// Fails OPEN: material whose lifetime can't be decoded
126 /// ([`rotate_at`](Self::rotate_at) returns `None`) is treated as due, so
127 /// `op credentials rotate --if-needed` errs toward rotating rather than
128 /// letting an opaque token silently lapse. The policy lives here; only the
129 /// per-backend `rotate_at` decode is overridden, so impls should not need
130 /// to override this.
131 fn rotation_due(&self, material: &str, now: DateTime<Utc>) -> bool {
132 match self.rotate_at(material) {
133 Some(rotate_at) => now >= rotate_at,
134 None => true,
135 }
136 }
137}