Skip to main content

auths_cli/commands/
org.rs

1use anyhow::{Context, Result, anyhow};
2use auths_sdk::attestation::create_signed_attestation;
3use auths_sdk::attestation::create_signed_revocation;
4use auths_sdk::identity::DidResolver;
5use auths_sdk::registration::DEFAULT_REGISTRY_URL;
6use chrono::{DateTime, Utc};
7use clap::{ArgAction, Parser, Subcommand};
8use serde_json;
9use std::fs;
10use std::path::PathBuf;
11
12use auths_sdk::attestation::AttestationGroup;
13use auths_sdk::identity::DefaultDidResolver;
14use auths_sdk::keychain::{KeyAlias, get_platform_keychain};
15use auths_sdk::ports::{AttestationMetadata, AttestationSource, IdentityStorage, RegistryBackend};
16use auths_sdk::signing::StorageSigner;
17use auths_sdk::storage_layout::{StorageLayoutConfig, layout};
18
19use auths_sdk::storage::{
20    GitRegistryBackend, RegistryAttestationStorage, RegistryConfig, RegistryIdentityStorage,
21};
22use auths_sdk::workflows::commit_trust::commit_signer_trailers;
23use auths_sdk::workflows::org::{
24    AuthorityAtSigning, Role, add_member, build_org_bundle, classify_authority_at_signing,
25    create_org, fleet_metrics, list_members, list_offboarding_records, load_offboarding_record,
26    load_org_policy, member_role_order, revoke_member, set_org_policy, walk_delegation_chain,
27};
28
29use crate::factories::storage::build_auths_context;
30use auths_verifier::Prefix;
31use auths_verifier::types::CanonicalDid;
32
33use clap::ValueEnum;
34
35/// CLI-level role wrapper that derives `ValueEnum` for argument parsing.
36///
37/// Converts to `auths_sdk::workflows::org::Role` at the CLI boundary.
38#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
39pub enum CliRole {
40    Admin,
41    Member,
42    Readonly,
43}
44
45impl From<CliRole> for Role {
46    fn from(r: CliRole) -> Self {
47        match r {
48            CliRole::Admin => Role::Admin,
49            CliRole::Member => Role::Member,
50            CliRole::Readonly => Role::Readonly,
51        }
52    }
53}
54
55/// Default keychain alias for an org's signing key (`org-{slug}`), derived from
56/// the org identifier when `--key` is not supplied.
57fn org_slug_alias(org: &str) -> String {
58    format!(
59        "org-{}",
60        org.chars()
61            .filter(|c| c.is_alphanumeric())
62            .take(20)
63            .collect::<String>()
64            .to_lowercase()
65    )
66}
67
68/// The `org` subcommand, handling member authorizations.
69#[derive(Parser, Debug, Clone)]
70#[command(
71    about = "Manage organization identities and memberships",
72    after_help = "Examples:
73  auths org create --name 'My Organization'
74                        # Create a new org identity
75  auths org add-member --org did:keri:EOrg --member did:keri:EMember --role admin --key orgkey
76                        # Add a member with admin role
77  auths org revoke-member --org did:keri:EOrg --member did:keri:EMember --key orgkey
78                        # Revoke a member's access
79
80Related:
81  auths id        — Manage individual identities
82  auths namespace — Claim and manage package namespaces
83  auths policy    — Define capability policies"
84)]
85pub struct OrgCommand {
86    #[clap(subcommand)]
87    pub subcommand: OrgSubcommand,
88
89    #[command(flatten)]
90    pub overrides: crate::commands::registry_overrides::RegistryOverrides,
91}
92
93/// Subcommands for managing authorizations issued by this identity.
94#[derive(Subcommand, Debug, Clone)]
95pub enum OrgSubcommand {
96    /// Create a new organization identity
97    #[command(visible_alias = "init")]
98    Create {
99        /// Organization name
100        #[arg(long)]
101        name: String,
102
103        /// Alias for the local signing key (auto-generated if not provided)
104        #[arg(long)]
105        key: Option<String>,
106
107        /// Optional metadata file (if provided, merged with org metadata)
108        #[arg(long)]
109        metadata_file: Option<PathBuf>,
110    },
111    Attest {
112        #[arg(long = "subject", visible_alias = "subject-did")]
113        subject_did: String,
114        #[arg(long)]
115        payload_file: PathBuf,
116        #[arg(long)]
117        note: Option<String>,
118        #[arg(long)]
119        expires_at: Option<String>,
120        #[arg(long)]
121        key: Option<String>,
122    },
123    Revoke {
124        #[arg(long = "subject", visible_alias = "subject-did")]
125        subject_did: String,
126        #[arg(long)]
127        note: Option<String>,
128        #[arg(long)]
129        key: Option<String>,
130    },
131    Show {
132        #[arg(long = "subject", visible_alias = "subject-did")]
133        subject_did: String,
134        #[arg(long, action = ArgAction::SetTrue)]
135        include_revoked: bool,
136    },
137    List {
138        #[arg(long, action = ArgAction::SetTrue)]
139        include_revoked: bool,
140    },
141    /// Add a member to an organization
142    AddMember {
143        /// Organization identity ID
144        #[arg(long)]
145        org: String,
146
147        /// Member identity ID to add
148        #[arg(long = "member", visible_alias = "member-did")]
149        member_did: String,
150
151        /// Role to assign (admin, member, readonly)
152        #[arg(long, value_enum)]
153        role: CliRole,
154
155        /// Override default capabilities (comma-separated)
156        #[arg(long, value_delimiter = ',')]
157        capabilities: Option<Vec<auths_keri::Capability>>,
158
159        /// Alias of the signing key in keychain
160        #[arg(long)]
161        key: Option<String>,
162
163        /// Optional note for the authorization
164        #[arg(long)]
165        note: Option<String>,
166    },
167
168    /// Revoke a member from an organization
169    RevokeMember {
170        /// Organization identity ID
171        #[arg(long)]
172        org: String,
173
174        /// Member identity ID to revoke
175        #[arg(long = "member", visible_alias = "member-did")]
176        member_did: String,
177
178        /// Reason for revocation
179        #[arg(long)]
180        note: Option<String>,
181
182        /// Alias of the signing key in keychain
183        #[arg(long)]
184        key: Option<String>,
185
186        /// Preview actions without making changes.
187        #[arg(long)]
188        dry_run: bool,
189    },
190
191    /// List members of an organization
192    ListMembers {
193        /// Organization identity ID
194        #[arg(long)]
195        org: String,
196
197        /// Include revoked members
198        #[arg(long, action = ArgAction::SetTrue)]
199        include_revoked: bool,
200    },
201
202    /// Classify a member's authority at an artifact's signing position (by KEL position)
203    Audit {
204        /// Organization identity ID
205        #[arg(long)]
206        org: String,
207
208        /// Member identity ID to classify
209        #[arg(long = "member", visible_alias = "member-did")]
210        member_did: String,
211
212        /// Artifact path (shown in the report for context)
213        #[arg(long)]
214        artifact: Option<PathBuf>,
215
216        /// The artifact's in-band signing KEL position (e.g. a commit's `Auths-Anchor-Seq`)
217        #[arg(long)]
218        signed_at: Option<u128>,
219
220        /// Emit the typed verdict as JSON
221        #[arg(long, action = ArgAction::SetTrue)]
222        json: bool,
223    },
224
225    /// List durable off-boarding records for an organization
226    OffboardingLog {
227        /// Organization identity ID
228        #[arg(long)]
229        org: String,
230
231        /// Restrict to a single member
232        #[arg(long = "member", visible_alias = "member-did")]
233        member_did: Option<String>,
234
235        /// Emit the records as JSON
236        #[arg(long, action = ArgAction::SetTrue)]
237        json: bool,
238    },
239
240    /// Produce a self-contained, air-gapped provenance bundle for an organization
241    Bundle {
242        /// Organization identity ID
243        #[arg(long)]
244        org: String,
245
246        /// Output path for the bundle file
247        #[arg(long)]
248        out: PathBuf,
249    },
250
251    /// Join an organization using an invite code
252    Join {
253        /// Invite code (e.g. from `auths org join --code C23BD59F`)
254        #[arg(long)]
255        code: String,
256
257        /// Registry URL to contact
258        #[arg(long, env = "AUTHS_REGISTRY_URL", default_value = DEFAULT_REGISTRY_URL)]
259        registry: String,
260    },
261
262    /// Manage the org-wide authorization policy (anchored on the org KEL)
263    Policy {
264        /// Policy action (set/show)
265        #[command(subcommand)]
266        action: PolicyAction,
267    },
268
269    /// Show fleet governance metrics for an organization
270    Metrics {
271        /// Organization identity ID
272        #[arg(long)]
273        org: String,
274
275        /// Emit the metrics as JSON
276        #[arg(long, action = ArgAction::SetTrue)]
277        json: bool,
278    },
279
280    /// Trace an agent's delegation chain to the authorizing root + live-at-signing
281    Trace {
282        /// A signed commit SHA — traces its signer (`Auths-Device`) at its anchor-seq
283        #[arg(long)]
284        commit: Option<String>,
285
286        /// A member/agent identity ID to trace directly
287        #[arg(long = "member", visible_alias = "member-did")]
288        member: Option<String>,
289
290        /// The in-band signing KEL position (used with `--member`; `--commit` reads it
291        /// from the commit's `Auths-Anchor-Seq` trailer)
292        #[arg(long)]
293        signed_at: Option<u128>,
294
295        /// Emit the chain as JSON
296        #[arg(long, action = ArgAction::SetTrue)]
297        json: bool,
298    },
299}
300
301/// Actions for the org-wide authorization policy.
302#[derive(Subcommand, Debug, Clone)]
303pub enum PolicyAction {
304    /// Anchor a new org-wide policy from a JSON file (a serialized policy `Expr`)
305    Set {
306        /// Organization identity ID
307        #[arg(long)]
308        org: String,
309
310        /// Path to the policy JSON file (a serialized `Expr`)
311        #[arg(long)]
312        file: PathBuf,
313
314        /// Org signing key alias (defaults to the org slug alias)
315        #[arg(long)]
316        key: Option<String>,
317    },
318
319    /// Show the org's currently-anchored policy
320    Show {
321        /// Organization identity ID
322        #[arg(long)]
323        org: String,
324
325        /// Emit the raw policy JSON
326        #[arg(long, action = ArgAction::SetTrue)]
327        json: bool,
328    },
329}
330
331/// single-verifier helper. Resolves the issuer DID,
332/// constructs a typed `DevicePublicKey`, and calls `auths_verifier::verify_with_keys`.
333/// Returns one of: "✅ valid", "🛑 revoked", "⌛ expired", "❌ invalid".
334fn verify_attestation_via_resolver(
335    att: &auths_verifier::Attestation,
336    resolver: &auths_sdk::identity::DefaultDidResolver,
337    anchor_set: Option<&std::collections::HashSet<auths_keri::Said>>,
338) -> String {
339    use auths_sdk::identity::DidResolver;
340    let resolved = match resolver.resolve(att.issuer.as_str()) {
341        Ok(r) => r,
342        Err(_) => return "❌ invalid".to_string(),
343    };
344    let pk_bytes: Vec<u8> = resolved.public_key_bytes().to_vec();
345    let resolved_curve = resolved.curve();
346    let issuer_pk = match auths_verifier::decode_public_key_bytes(&pk_bytes, resolved_curve) {
347        Ok(pk) => pk,
348        Err(_) => return "❌ invalid".to_string(),
349    };
350    #[allow(clippy::expect_used)]
351    // INVARIANT: current-thread runtime creation only fails on resource
352    // exhaustion, which is unrecoverable at the CLI boundary.
353    let rt = tokio::runtime::Builder::new_current_thread()
354        .enable_all()
355        .build()
356        .expect("tokio runtime");
357    let base = match rt.block_on(auths_verifier::verify_with_keys(att, &issuer_pk)) {
358        Ok(_) => "✅ valid",
359        Err(e) if e.to_string().contains("revoked") => "🛑 revoked",
360        Err(e) if e.to_string().contains("expired") => "⌛ expired",
361        Err(_) => "❌ invalid",
362    };
363    let anchor_suffix = match anchor_set {
364        Some(set) => {
365            let anchored =
366                auths_sdk::attestation::canonical_said(att).is_some_and(|said| set.contains(&said));
367            if anchored { "" } else { " (unanchored)" }
368        }
369        None => "",
370    };
371    format!("{base}{anchor_suffix}")
372}
373
374/// Handles `org` commands for issuing or revoking member authorizations.
375pub fn handle_org(
376    cmd: OrgCommand,
377    ctx: &crate::config::CliConfig,
378    now: chrono::DateTime<chrono::Utc>,
379) -> Result<()> {
380    let repo_path = layout::resolve_repo_path(ctx.repo_path.clone())?;
381    let passphrase_provider = ctx.passphrase_provider.clone();
382
383    let mut config = StorageLayoutConfig::default();
384    if let Some(r) = &cmd.overrides.identity_ref {
385        config.identity_ref = r.clone().into();
386    }
387    if let Some(b) = &cmd.overrides.identity_blob {
388        config.identity_blob_name = b.clone().into();
389    }
390    if let Some(p) = &cmd.overrides.attestation_prefix {
391        config.device_attestation_prefix = p.clone().into();
392    }
393    if let Some(b) = &cmd.overrides.attestation_blob {
394        config.attestation_blob_name = b.clone().into();
395    }
396
397    let _attestation_storage = RegistryAttestationStorage::new(repo_path.clone());
398    let resolver: DefaultDidResolver = DefaultDidResolver::with_repo(&repo_path);
399
400    match cmd.subcommand {
401        OrgSubcommand::Create {
402            name,
403            key,
404            metadata_file,
405        } => {
406            let key_alias = key.unwrap_or_else(|| org_slug_alias(&name));
407
408            println!("🏛️  Initializing new organization identity...");
409            println!("   Organization Name: {name}");
410            println!("   Repository path:   {repo_path:?}");
411            println!("   Local Key Alias:   {key_alias}");
412
413            use crate::factories::storage::ensure_git_repo;
414            ensure_git_repo(&repo_path).context("Failed to initialize Git repository")?;
415
416            let extra_metadata = match &metadata_file {
417                Some(mf) if mf.exists() => {
418                    let raw = fs::read_to_string(mf)
419                        .with_context(|| format!("Failed to read metadata file: {mf:?}"))?;
420                    let value: serde_json::Value = serde_json::from_str(&raw)
421                        .with_context(|| format!("Invalid JSON in metadata file: {mf:?}"))?;
422                    println!("   Merged additional metadata from {mf:?}");
423                    Some(value)
424                }
425                _ => None,
426            };
427
428            let sdk_ctx = build_auths_context(
429                &repo_path,
430                &ctx.env_config,
431                Some(passphrase_provider.clone()),
432            )?;
433            let created = create_org(
434                &sdk_ctx,
435                &name,
436                &KeyAlias::new_unchecked(key_alias),
437                auths_crypto::CurveType::default(),
438                extra_metadata,
439            )
440            .with_context(|| format!("Failed to create organization '{name}'"))?;
441
442            println!("\n✅ Organization identity initialized successfully!");
443            println!("   Org Identity ID:    {}", created.org_did);
444            println!("   Org Name:           {name}");
445            println!("   Repo Path:          {repo_path:?}");
446            println!("   Key Alias:          {}", created.key_alias);
447            println!("   Admin Role:         Granted with all capabilities");
448            println!(
449                "   KEL Ref:            '{}'",
450                layout::keri_kel_ref(&Prefix::new_unchecked(created.org_prefix.clone()))
451            );
452            println!("   Identity Ref:       '{}'", config.identity_ref);
453            if let Ok(org_did) = CanonicalDid::parse(&created.org_did) {
454                println!(
455                    "   Member Ref:         '{}'",
456                    config.org_member_ref(&created.org_did, &org_did)
457                );
458            }
459            println!("\n🔑 Store your key passphrase securely.");
460            println!(
461                "   You can now add members with: auths org add-member --org {} --member <identity-id> --role <role>",
462                created.org_did
463            );
464
465            Ok(())
466        }
467
468        OrgSubcommand::Attest {
469            subject_did,  // The subject DID (String)
470            payload_file, // Path to the JSON payload
471            note,         // Optional note (String)
472            expires_at,   // Optional RFC3339 expiration string
473            key,          // Alias of the org's signing key in keychain
474        } => {
475            let signer_alias =
476                key.ok_or_else(|| anyhow!("Signer key alias must be provided with --key"))?;
477            let signer_alias = KeyAlias::new_unchecked(signer_alias);
478
479            let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
480            let managed_identity = identity_storage
481                .load_identity()
482                .context("Failed to load org identity from Git repository")?;
483            let controller_did = managed_identity.controller_did;
484            let rid = managed_identity.storage_id;
485
486            let payload_str = fs::read_to_string(&payload_file)
487                .with_context(|| format!("Failed to read payload file {:?}", payload_file))?;
488            let payload: serde_json::Value =
489                serde_json::from_str(&payload_str).context("Invalid JSON in payload file")?;
490
491            let key_storage = get_platform_keychain()?;
492            let (stored_did, _role, _encrypted_key) = key_storage
493                .load_key(&signer_alias)
494                .with_context(|| format!("Failed to load signer key '{}'", signer_alias))?;
495
496            if stored_did != controller_did {
497                return Err(anyhow!(
498                    "Signer key alias '{}' belongs to DID '{}', but loaded org identity is '{}'",
499                    signer_alias,
500                    stored_did,
501                    controller_did
502                ));
503            }
504
505            #[allow(clippy::disallowed_methods)]
506            // INVARIANT: subject_did accepts both did:key and did:keri
507            let subject_device_did = CanonicalDid::new_unchecked(subject_did.clone());
508
509            // --- Resolve device public key using the custom resolver IF did:key ---
510            let device_resolved = resolver.resolve(&subject_did).with_context(|| {
511                format!("Failed to resolve public key for subject: {}", subject_did)
512            })?;
513            let device_pk_bytes = device_resolved.public_key_bytes().to_vec();
514            let device_curve = device_resolved.curve();
515
516            let meta = AttestationMetadata {
517                note,
518                timestamp: Some(now),
519                expires_at: expires_at
520                    .as_deref()
521                    .map(DateTime::parse_from_rfc3339)
522                    .transpose()
523                    .map_err(|e| anyhow!("Invalid RFC3339 datetime string: {}", e))?
524                    .map(|dt| dt.with_timezone(&Utc)),
525            };
526
527            let signer = StorageSigner::new(key_storage);
528            let attestation = create_signed_attestation(
529                now,
530                auths_sdk::attestation::AttestationInput {
531                    rid: &rid,
532                    identity_did: &controller_did,
533                    subject: &subject_device_did,
534                    device_public_key: &device_pk_bytes,
535                    device_curve,
536                    payload: Some(payload),
537                    meta: &meta,
538                    identity_alias: Some(&signer_alias),
539                    device_alias: None, // No device signature for org attestations
540                    delegated_by: None,
541                    commit_sha: None,
542                    signer_type: None,
543                },
544                &signer,
545                passphrase_provider.as_ref(),
546            )
547            .context("Failed to create signed attestation object")?;
548
549            {
550                let backend = GitRegistryBackend::from_config_unchecked(
551                    RegistryConfig::single_tenant(&repo_path),
552                );
553                let mut batch = auths_sdk::keri::AtomicWriteBatch::new();
554                batch.stage_attestation(attestation);
555                if let Ok(prefix) = auths_sdk::keri::parse_did_keri(controller_did.as_str()) {
556                    let _ = auths_sdk::keri::try_stage_anchor(
557                        &backend,
558                        &signer,
559                        &signer_alias,
560                        passphrase_provider.as_ref(),
561                        &prefix,
562                        &serde_json::json!({}),
563                        &mut batch,
564                    );
565                }
566                backend
567                    .commit_batch(&batch)
568                    .context("Failed to write attestation")?;
569            }
570
571            println!(
572                "\n✅ Org attestation created successfully from '{}' → '{}'",
573                controller_did, subject_device_did
574            );
575
576            Ok(())
577        }
578
579        OrgSubcommand::Revoke {
580            subject_did,
581            note,
582            key,
583        } => {
584            println!("🛑 Revoking org authorization for subject: {subject_did}");
585            println!("   Using Repository:         {:?}", repo_path);
586            println!("   Using Identity Ref:       '{}'", config.identity_ref);
587            println!(
588                "   Using Attestation Prefix: '{}'",
589                config.device_attestation_prefix
590            );
591
592            let signer_alias =
593                key.ok_or_else(|| anyhow!("Signer key alias must be provided for revocation"))?;
594            let signer_alias = KeyAlias::new_unchecked(signer_alias);
595
596            let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
597            let managed_identity = identity_storage
598                .load_identity()
599                .context("Failed to load identity from Git repository")?;
600            let controller_did = managed_identity.controller_did;
601            let rid = managed_identity.storage_id;
602
603            #[allow(clippy::disallowed_methods)] // INVARIANT: accepts both did:key and did:keri
604            let subject_device_did = CanonicalDid::new_unchecked(subject_did.clone());
605
606            // Look up the subject's public key from existing attestations
607            let attestation_storage = RegistryAttestationStorage::new(repo_path.clone());
608            let existing = attestation_storage
609                .load_attestations_for_device(&subject_device_did)
610                .context("Failed to load attestations for subject")?;
611            let device_public_key = existing
612                .iter()
613                .find(|a| !a.device_public_key.is_zero())
614                .map(|a| a.device_public_key.clone())
615                .unwrap_or_default();
616
617            println!("🔏 Creating signed revocation...");
618            let signer = StorageSigner::new(get_platform_keychain()?);
619            let attestation = create_signed_revocation(
620                auths_sdk::attestation::RevocationInput {
621                    rid: &rid,
622                    identity_did: &controller_did,
623                    subject: &subject_device_did,
624                    device_public_key: device_public_key.as_bytes(),
625                    device_curve: device_public_key.curve(),
626                    note,
627                    payload: None,
628                    timestamp: now,
629                    identity_alias: &signer_alias,
630                },
631                &signer,
632                passphrase_provider.as_ref(),
633            )
634            .context("Failed to create revocation")?;
635
636            println!("💾 Writing revocation to Git...");
637            {
638                let backend = GitRegistryBackend::from_config_unchecked(
639                    RegistryConfig::single_tenant(&repo_path),
640                );
641                let mut batch = auths_sdk::keri::AtomicWriteBatch::new();
642                batch.stage_attestation(attestation);
643                if let Ok(prefix) = auths_sdk::keri::parse_did_keri(controller_did.as_str()) {
644                    let _ = auths_sdk::keri::try_stage_anchor(
645                        &backend,
646                        &signer,
647                        &signer_alias,
648                        passphrase_provider.as_ref(),
649                        &prefix,
650                        &serde_json::json!({}),
651                        &mut batch,
652                    );
653                }
654                backend
655                    .commit_batch(&batch)
656                    .context("Failed to write revocation")?;
657            }
658
659            println!("\n✅ Revoked authorization for subject {subject_did}");
660
661            Ok(())
662        }
663
664        OrgSubcommand::Show {
665            subject_did,
666            include_revoked,
667        } => {
668            let attestation_storage = RegistryAttestationStorage::new(repo_path.clone());
669            let resolver = DefaultDidResolver::with_repo(&repo_path);
670            let group = AttestationGroup::from_list(
671                attestation_storage
672                    .load_all_enriched()
673                    .map(|v| v.into_iter().map(|e| e.attestation).collect::<Vec<_>>())?,
674            );
675
676            #[allow(clippy::disallowed_methods)]
677            // INVARIANT: subject_did from CLI arg, used for lookup only
678            let subject_device_did = CanonicalDid::new_unchecked(subject_did.clone());
679            if let Some(list) = group.by_device.get(subject_device_did.as_str()) {
680                for (i, att) in list.iter().enumerate() {
681                    if !include_revoked
682                        && (att.is_revoked() || att.expires_at.is_some_and(|e| now > e))
683                    {
684                        continue;
685                    }
686
687                    let status = verify_attestation_via_resolver(att, &resolver, None);
688
689                    println!("{i}. [{}] @ {}", status, att.timestamp.unwrap_or(now));
690                    if let Some(note) = &att.note {
691                        println!("   📝 {}", note);
692                    }
693                    if let Some(payload) = &att.payload {
694                        println!("   📦 {}", serde_json::to_string_pretty(payload)?);
695                    }
696                }
697            } else {
698                println!("No authorizations found for subject: {}", subject_did);
699            }
700
701            Ok(())
702        }
703
704        OrgSubcommand::List { include_revoked } => {
705            let attestation_storage = RegistryAttestationStorage::new(repo_path.clone());
706            let resolver = DefaultDidResolver::with_repo(&repo_path);
707            let group = AttestationGroup::from_list(
708                attestation_storage
709                    .load_all_enriched()
710                    .map(|v| v.into_iter().map(|e| e.attestation).collect::<Vec<_>>())?,
711            );
712
713            for (subject, list) in group.by_device.iter() {
714                let Some(latest) = list.last() else {
715                    continue;
716                };
717                if !include_revoked
718                    && (latest.is_revoked() || latest.expires_at.is_some_and(|e| now > e))
719                {
720                    continue;
721                }
722
723                let status = verify_attestation_via_resolver(latest, &resolver, None);
724
725                println!("- {} [{}]", subject, status);
726            }
727
728            Ok(())
729        }
730
731        OrgSubcommand::AddMember {
732            org,
733            member_did: member_label,
734            role: cli_role,
735            capabilities,
736            key,
737            note: _note,
738        } => {
739            let role = Role::from(cli_role);
740            println!("👥 Adding member to organization...");
741            println!("   Org:   {}", org);
742            println!("   Label: {}", member_label);
743            println!("   Role:  {}", role);
744
745            let org_prefix =
746                Prefix::new_unchecked(org.strip_prefix("did:keri:").unwrap_or(&org).to_string());
747            let org_alias = KeyAlias::new_unchecked(key.unwrap_or_else(|| org_slug_alias(&org)));
748            let member_alias = KeyAlias::new_unchecked(member_label.clone());
749
750            let capability_strings = capabilities.unwrap_or_else(|| role.default_capabilities());
751
752            let sdk_ctx = build_auths_context(
753                &repo_path,
754                &ctx.env_config,
755                Some(passphrase_provider.clone()),
756            )?;
757            let result = add_member(
758                &sdk_ctx,
759                &org_prefix,
760                &org_alias,
761                &member_alias,
762                auths_crypto::CurveType::Ed25519,
763                role,
764                &capability_strings,
765                None,
766            )
767            .context("Failed to add member")?;
768
769            println!("\n✅ Member added as a KERI delegated identifier!");
770            println!("   Member DID:   {}", result.member_did);
771            println!("   Role:         {}", role);
772            println!(
773                "   Capabilities: {}",
774                capability_strings
775                    .iter()
776                    .map(|c| c.as_str())
777                    .collect::<Vec<_>>()
778                    .join(", ")
779            );
780            println!("\nThe org anchored this member's delegation in its KEL.");
781
782            Ok(())
783        }
784
785        OrgSubcommand::RevokeMember {
786            org,
787            member_did: member,
788            note,
789            key,
790            dry_run,
791        } => {
792            println!("🛑 Revoking member from organization...");
793            println!("   Org:    {}", org);
794            println!("   Member: {}", member);
795
796            let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
797            let invoker_did = identity_storage
798                .load_identity()
799                .context("Failed to load identity. Are you running this from an org repository?")?
800                .controller_did;
801
802            if dry_run {
803                return display_dry_run_revoke_member(&org, &member, invoker_did.as_ref());
804            }
805
806            let org_prefix =
807                Prefix::new_unchecked(org.strip_prefix("did:keri:").unwrap_or(&org).to_string());
808            let org_alias = KeyAlias::new_unchecked(key.unwrap_or_else(|| org_slug_alias(&org)));
809
810            let sdk_ctx = build_auths_context(
811                &repo_path,
812                &ctx.env_config,
813                Some(passphrase_provider.clone()),
814            )?;
815            let record = revoke_member(&sdk_ctx, &org_prefix, &org_alias, &member, note)
816                .context("Failed to revoke member")?;
817
818            match record {
819                Some(signed) => {
820                    println!("\n✅ Member revoked (revocation anchored in the org KEL):");
821                    println!("   Member:        {}", member);
822                    println!("   Revoked by:    {}", invoker_did);
823                    println!(
824                        "   Revoked at:    KEL seq {} (authority ends after this position, not by wall-clock)",
825                        signed.record.revoked_at_seq
826                    );
827                    println!("   Seal SAID:     {}", signed.record.revocation_seal_said);
828                    let role = signed.record.prior_role.as_deref().unwrap_or("unknown");
829                    println!("   Lost role:     {role}");
830                    if !signed.record.prior_caps.is_empty() {
831                        println!(
832                            "   Lost caps:     {}",
833                            signed
834                                .record
835                                .prior_caps
836                                .iter()
837                                .map(|c| c.as_str())
838                                .collect::<Vec<_>>()
839                                .join(", ")
840                        );
841                    }
842                    println!("   Off-boarding record stored (signed, retrievable by org/member).");
843                }
844                None => {
845                    println!("\nℹ️  Member already revoked — no change (idempotent).");
846                    println!("   Member: {member}");
847                }
848            }
849
850            Ok(())
851        }
852
853        OrgSubcommand::ListMembers {
854            org,
855            include_revoked,
856        } => {
857            println!("📋 Listing members of organization: {}", org);
858
859            // KEL-authoritative: members are `dip`s the org anchored. Revocation and
860            // role/capabilities are read from the org KEL, never from an attestation.
861            let org_prefix =
862                Prefix::new_unchecked(org.strip_prefix("did:keri:").unwrap_or(&org).to_string());
863            let sdk_ctx = build_auths_context(
864                &repo_path,
865                &ctx.env_config,
866                Some(passphrase_provider.clone()),
867            )?;
868            let all_members =
869                list_members(&sdk_ctx, &org_prefix).context("Failed to list members")?;
870            let revoked_count = all_members.iter().filter(|m| m.revoked).count();
871
872            let mut members: Vec<_> = all_members
873                .into_iter()
874                .filter(|m| include_revoked || !m.revoked)
875                .collect();
876
877            if members.is_empty() {
878                println!("\nNo members found for organization.");
879                return Ok(());
880            }
881
882            members.sort_by(|a, b| {
883                member_role_order(&a.role)
884                    .cmp(&member_role_order(&b.role))
885                    .then_with(|| a.member_did.cmp(&b.member_did))
886            });
887
888            println!("\nOrg: {}", org);
889            println!("\nMembers ({} total):", members.len());
890            println!("─────────────────────────────────────────");
891
892            for m in &members {
893                let role_str = m.role.as_ref().map(|r| r.as_str()).unwrap_or("unknown");
894                let status = if m.revoked { " (revoked)" } else { "" };
895                let caps_str = if m.capabilities.is_empty() {
896                    String::new()
897                } else {
898                    format!(
899                        " [{}]",
900                        m.capabilities
901                            .iter()
902                            .map(|c| c.as_str())
903                            .collect::<Vec<_>>()
904                            .join(", ")
905                    )
906                };
907
908                println!("├─ {} [{}]{}{}", m.member_did, role_str, status, caps_str);
909                println!("│     delegated by: {}", m.delegated_by_org);
910            }
911
912            println!("─────────────────────────────────────────");
913
914            if !include_revoked && revoked_count > 0 {
915                println!(
916                    "\n({} revoked member(s) hidden. Use --include-revoked to show.)",
917                    revoked_count
918                );
919            }
920
921            Ok(())
922        }
923
924        OrgSubcommand::Audit {
925            org,
926            member_did,
927            artifact,
928            signed_at,
929            json,
930        } => {
931            let org_prefix =
932                Prefix::new_unchecked(org.strip_prefix("did:keri:").unwrap_or(&org).to_string());
933            let member_prefix = Prefix::new_unchecked(
934                member_did
935                    .strip_prefix("did:keri:")
936                    .unwrap_or(&member_did)
937                    .to_string(),
938            );
939            let sdk_ctx = build_auths_context(
940                &repo_path,
941                &ctx.env_config,
942                Some(passphrase_provider.clone()),
943            )?;
944            let verdict =
945                classify_authority_at_signing(&sdk_ctx, &org_prefix, &member_prefix, signed_at)
946                    .context("Failed to classify authority at signing")?;
947
948            if json {
949                println!("{}", serde_json::to_string_pretty(&verdict)?);
950                return Ok(());
951            }
952
953            if let Some(path) = &artifact {
954                println!("Artifact: {path:?}");
955            }
956            println!("Member:   {member_did}");
957            match &verdict {
958                AuthorityAtSigning::AuthorizedBeforeRevocation => println!(
959                    "Verdict:  ✅ AuthorizedBeforeRevocation — authority was live at the signing position"
960                ),
961                AuthorityAtSigning::RejectedAfterRevocation { revoked_at } => println!(
962                    "Verdict:  🛑 RejectedAfterRevocation {{ revoked_at: {revoked_at} }} — signed at/after the revocation KEL position"
963                ),
964                AuthorityAtSigning::RejectedRevokedPositionUnknown { revoked_at } => println!(
965                    "Verdict:  🛑 RejectedRevokedPositionUnknown {{ revoked_at: {revoked_at} }} — revoked; artifact carries no in-band signing position"
966                ),
967                AuthorityAtSigning::NeverDelegated => {
968                    println!("Verdict:  ❌ NeverDelegated — the org never delegated this member")
969                }
970            }
971            Ok(())
972        }
973
974        OrgSubcommand::OffboardingLog {
975            org,
976            member_did,
977            json,
978        } => {
979            let org_prefix =
980                Prefix::new_unchecked(org.strip_prefix("did:keri:").unwrap_or(&org).to_string());
981            let sdk_ctx = build_auths_context(
982                &repo_path,
983                &ctx.env_config,
984                Some(passphrase_provider.clone()),
985            )?;
986
987            let records = match &member_did {
988                Some(m) => {
989                    let member_prefix =
990                        Prefix::new_unchecked(m.strip_prefix("did:keri:").unwrap_or(m).to_string());
991                    load_offboarding_record(&sdk_ctx, &org_prefix, &member_prefix)
992                        .context("Failed to load off-boarding record")?
993                        .into_iter()
994                        .collect::<Vec<_>>()
995                }
996                None => list_offboarding_records(&sdk_ctx, &org_prefix)
997                    .context("Failed to list off-boarding records")?,
998            };
999
1000            if json {
1001                println!("{}", serde_json::to_string_pretty(&records)?);
1002                return Ok(());
1003            }
1004
1005            if records.is_empty() {
1006                println!("No off-boarding records for organization {org}.");
1007                return Ok(());
1008            }
1009
1010            println!("Off-boarding records for {org} ({} total):", records.len());
1011            for r in &records {
1012                println!("─────────────────────────────────────────");
1013                println!("  Member:     {}", r.record.member_did);
1014                println!(
1015                    "  Revoked at: KEL seq {} (by position, not wall-clock)",
1016                    r.record.revoked_at_seq
1017                );
1018                println!("  Seal SAID:  {}", r.record.revocation_seal_said);
1019                if let Some(reason) = &r.record.reason {
1020                    println!("  Reason:     {reason}");
1021                }
1022                let role = r.record.prior_role.as_deref().unwrap_or("unknown");
1023                println!("  Lost role:  {role}");
1024                if !r.record.prior_caps.is_empty() {
1025                    println!(
1026                        "  Lost caps:  {}",
1027                        r.record
1028                            .prior_caps
1029                            .iter()
1030                            .map(|c| c.as_str())
1031                            .collect::<Vec<_>>()
1032                            .join(", ")
1033                    );
1034                }
1035                println!("  Recorded:   {}", r.record.recorded_at);
1036            }
1037            Ok(())
1038        }
1039
1040        OrgSubcommand::Bundle { org, out } => {
1041            let org_prefix =
1042                Prefix::new_unchecked(org.strip_prefix("did:keri:").unwrap_or(&org).to_string());
1043            let sdk_ctx = build_auths_context(
1044                &repo_path,
1045                &ctx.env_config,
1046                Some(passphrase_provider.clone()),
1047            )?;
1048            let bundle =
1049                build_org_bundle(&sdk_ctx, &org_prefix).context("Failed to build org bundle")?;
1050            let json = bundle
1051                .to_canonical_json()
1052                .context("Failed to canonicalize org bundle")?;
1053            fs::write(&out, json).with_context(|| format!("Failed to write bundle to {out:?}"))?;
1054
1055            println!("✅ Air-gapped org bundle written to {out:?}");
1056            println!("   Org:            {}", bundle.org_did.as_str());
1057            println!("   Built as-of:    KEL seq {}", bundle.built_at_org_seq);
1058            println!("   Member KELs:    {}", bundle.member_kels.len());
1059            println!("   Off-boardings:  {}", bundle.offboarding_records.len());
1060            println!("   Pinned roots:   {}", bundle.pinned_roots.len());
1061            println!(
1062                "   Verifies offline (no network): auths artifact verify {out:?} --offline --roots .auths/roots"
1063            );
1064            Ok(())
1065        }
1066
1067        OrgSubcommand::Join { code, registry } => {
1068            handle_join(&code, &registry, passphrase_provider.as_ref())
1069        }
1070
1071        OrgSubcommand::Policy { action } => match action {
1072            PolicyAction::Set { org, file, key } => {
1073                let org_prefix = Prefix::new_unchecked(
1074                    org.strip_prefix("did:keri:").unwrap_or(&org).to_string(),
1075                );
1076                let org_alias =
1077                    KeyAlias::new_unchecked(key.unwrap_or_else(|| org_slug_alias(&org)));
1078                let policy_json = fs::read(&file)
1079                    .with_context(|| format!("Failed to read policy file {file:?}"))?;
1080
1081                let sdk_ctx = build_auths_context(
1082                    &repo_path,
1083                    &ctx.env_config,
1084                    Some(passphrase_provider.clone()),
1085                )?;
1086                let result = set_org_policy(&sdk_ctx, &org_prefix, &org_alias, &policy_json)
1087                    .context("Failed to set org policy")?;
1088
1089                println!("✅ Org policy anchored on the KEL:");
1090                println!("   Org:         {}", result.org_did);
1091                println!("   Policy hash: {}", result.policy_hash);
1092                println!("   Requires:\n{}", result.description);
1093                Ok(())
1094            }
1095
1096            PolicyAction::Show { org, json } => {
1097                let org_prefix = Prefix::new_unchecked(
1098                    org.strip_prefix("did:keri:").unwrap_or(&org).to_string(),
1099                );
1100                let sdk_ctx = build_auths_context(
1101                    &repo_path,
1102                    &ctx.env_config,
1103                    Some(passphrase_provider.clone()),
1104                )?;
1105
1106                match load_org_policy(&sdk_ctx, &org_prefix).context("Failed to load org policy")? {
1107                    Some(policy) => {
1108                        if json {
1109                            println!("{}", policy.source_json);
1110                        } else {
1111                            println!("Org:         did:keri:{}", org_prefix.as_str());
1112                            println!("Policy hash: {}", policy.policy_hash);
1113                            println!("Requires:\n{}", policy.compiled.describe());
1114                        }
1115                    }
1116                    None => println!("No policy anchored for organization {org}."),
1117                }
1118                Ok(())
1119            }
1120        },
1121
1122        OrgSubcommand::Metrics { org, json } => {
1123            let org_prefix =
1124                Prefix::new_unchecked(org.strip_prefix("did:keri:").unwrap_or(&org).to_string());
1125            let sdk_ctx = build_auths_context(
1126                &repo_path,
1127                &ctx.env_config,
1128                Some(passphrase_provider.clone()),
1129            )?;
1130            let m =
1131                fleet_metrics(&sdk_ctx, &org_prefix).context("Failed to compute fleet metrics")?;
1132            if json {
1133                println!("{}", serde_json::to_string_pretty(&m)?);
1134                return Ok(());
1135            }
1136            println!("Fleet metrics for {}", m.org_did);
1137            println!("  Agents (total):           {}", m.agents_total);
1138            println!("  Agents (live):            {}", m.agents_live);
1139            println!("  Agents (revoked):         {}", m.agents_revoked);
1140            println!(
1141                "  Traceable to a human:     {}/{} ({:.0}%)",
1142                m.agents_traceable_to_human,
1143                m.agents_live,
1144                m.traceability_fraction * 100.0
1145            );
1146            println!(
1147                "  Revocation-to-effect:     {} KEL positions (positional — effective at the anchor)",
1148                m.revocation_effect_latency_positions
1149            );
1150            Ok(())
1151        }
1152
1153        OrgSubcommand::Trace {
1154            commit,
1155            member,
1156            signed_at,
1157            json,
1158        } => {
1159            let sdk_ctx = build_auths_context(
1160                &repo_path,
1161                &ctx.env_config,
1162                Some(passphrase_provider.clone()),
1163            )?;
1164
1165            let (leaf_prefix, position) = if let Some(sha) = commit {
1166                let raw = read_commit_object(&sha)?;
1167                let (_root, device) = commit_signer_trailers(&raw).ok_or_else(|| {
1168                    anyhow!("commit {sha} carries no Auths-Id/Auths-Device trailer")
1169                })?;
1170                let pos = parse_anchor_seq_trailer(&raw);
1171                let prefix = Prefix::new_unchecked(
1172                    device
1173                        .strip_prefix("did:keri:")
1174                        .unwrap_or(&device)
1175                        .to_string(),
1176                );
1177                (prefix, pos)
1178            } else if let Some(m) = member {
1179                let prefix =
1180                    Prefix::new_unchecked(m.strip_prefix("did:keri:").unwrap_or(&m).to_string());
1181                (prefix, signed_at)
1182            } else {
1183                return Err(anyhow!("provide --commit <sha> or --member <did> to trace"));
1184            };
1185
1186            let chain = walk_delegation_chain(&sdk_ctx, &leaf_prefix, position)
1187                .context("Failed to walk the delegation chain")?;
1188
1189            if json {
1190                println!("{}", serde_json::to_string_pretty(&chain)?);
1191                return Ok(());
1192            }
1193
1194            println!("Trace: {}", chain.leaf_did);
1195            match position {
1196                Some(p) => println!("  Signed at:       KEL position {p}"),
1197                None => println!(
1198                    "  Signed at:       (no in-band position — upstream revocations fail closed)"
1199                ),
1200            }
1201            println!("  Root:            {}", chain.root_did);
1202            println!("  Depth:           {} hop(s)", chain.depth);
1203            for hop in &chain.hops {
1204                let role = hop.role.as_deref().unwrap_or("-");
1205                let caps = if hop.capabilities.is_empty() {
1206                    String::new()
1207                } else {
1208                    format!(
1209                        " caps=[{}]",
1210                        hop.capabilities
1211                            .iter()
1212                            .map(|c| c.as_str())
1213                            .collect::<Vec<_>>()
1214                            .join(", ")
1215                    )
1216                };
1217                println!(
1218                    "    {} ← {} [{}]{}  authority={:?}",
1219                    hop.child_did, hop.delegator_did, role, caps, hop.authority_at_signing
1220                );
1221            }
1222            if chain.live_at_signing {
1223                println!("  Live at signing: ✅ yes");
1224            } else {
1225                println!(
1226                    "  Live at signing: 🛑 no (a chain authority was revoked at/by the signing position)"
1227                );
1228            }
1229            Ok(())
1230        }
1231    }
1232}
1233
1234/// Read a raw git commit object (`git cat-file commit <sha>`).
1235fn read_commit_object(sha: &str) -> Result<String> {
1236    let out = std::process::Command::new("git")
1237        .args(["cat-file", "commit", sha])
1238        .output()
1239        .context("failed to run git cat-file")?;
1240    if !out.status.success() {
1241        return Err(anyhow!(
1242            "git cat-file commit {sha} failed: {}",
1243            String::from_utf8_lossy(&out.stderr).trim()
1244        ));
1245    }
1246    String::from_utf8(out.stdout).context("commit object is not valid UTF-8")
1247}
1248
1249/// Parse the `Auths-Anchor-Seq` trailer value from a raw commit, if present.
1250fn parse_anchor_seq_trailer(raw: &str) -> Option<u128> {
1251    raw.lines()
1252        .rev()
1253        .find_map(|l| l.strip_prefix("Auths-Anchor-Seq:"))
1254        .and_then(|v| v.trim().parse().ok())
1255}
1256
1257/// Handles the `org join` subcommand by looking up and accepting an invite
1258/// via the registry HTTP API.
1259///
1260/// Args:
1261/// * `code`: Invite code to redeem.
1262/// * `registry`: Base URL of the registry HTTP API.
1263/// * `passphrase_provider`: Injected provider used to unlock the signing key
1264///   when producing the bearer token; respects SE-backed and P-256 keys.
1265///
1266/// Usage:
1267/// ```ignore
1268/// handle_join(&code, &registry, ctx.passphrase_provider.as_ref())?;
1269/// ```
1270fn handle_join(
1271    code: &str,
1272    registry: &str,
1273    passphrase_provider: &dyn auths_sdk::signing::PassphraseProvider,
1274) -> Result<()> {
1275    let rt = tokio::runtime::Runtime::new()?;
1276    let client = reqwest::Client::new();
1277    let base = registry.trim_end_matches('/');
1278
1279    // 1. Look up invite details.
1280    let details_url = format!("{}/v1/invites/{}", base, code);
1281    let details_resp = rt
1282        .block_on(async { client.get(&details_url).send().await })
1283        .context("failed to contact registry")?;
1284
1285    if details_resp.status() == reqwest::StatusCode::NOT_FOUND {
1286        anyhow::bail!(
1287            "Invite code '{}' not found. Check the code and try again.",
1288            code
1289        );
1290    }
1291    if !details_resp.status().is_success() {
1292        let status = details_resp.status();
1293        let body = rt.block_on(details_resp.text()).unwrap_or_default();
1294        anyhow::bail!("Failed to look up invite ({}): {}", status, body);
1295    }
1296
1297    let details: serde_json::Value = rt
1298        .block_on(details_resp.json())
1299        .context("invalid response from registry")?;
1300
1301    let org_name = details["display_name"].as_str().unwrap_or("Unknown");
1302    let role = details["role"].as_str().unwrap_or("member");
1303    let status = details["status"].as_str().unwrap_or("unknown");
1304
1305    if status == "expired" {
1306        anyhow::bail!("This invite has expired. Ask the org admin for a new one.");
1307    }
1308    if status == "accepted" {
1309        anyhow::bail!("This invite has already been accepted.");
1310    }
1311
1312    println!("Organization: {}", org_name);
1313    println!("Role:         {}", role);
1314    println!("Status:       {}", status);
1315    println!();
1316
1317    // 2. Accept the invite. This requires auth — build a signed bearer token.
1318    let repo_path = layout::resolve_repo_path(None)?;
1319    let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
1320    let managed_identity = identity_storage
1321        .load_identity()
1322        .context("no local identity found — run `auths init` first")?;
1323    let did = managed_identity.controller_did.to_string();
1324
1325    let key_storage = get_platform_keychain()?;
1326    let primary_alias = KeyAlias::new_unchecked("main");
1327
1328    #[allow(clippy::disallowed_methods)] // CLI is the presentation boundary
1329    let timestamp = Utc::now().to_rfc3339();
1330    let message = format!("{}\n{}", did, timestamp);
1331
1332    let (sig_bytes, _pubkey, _curve) = auths_sdk::keychain::sign_with_key(
1333        key_storage.as_ref(),
1334        &primary_alias,
1335        passphrase_provider,
1336        message.as_bytes(),
1337    )
1338    .context("failed to sign invite bearer token")?;
1339
1340    use base64::Engine;
1341    let signature = base64::engine::general_purpose::STANDARD.encode(&sig_bytes);
1342
1343    let bearer_payload = serde_json::json!({
1344        "did": did,
1345        "timestamp": timestamp,
1346        "signature": signature,
1347    });
1348    let bearer_token = serde_json::to_string(&bearer_payload)?;
1349
1350    let accept_url = format!("{}/v1/invites/{}/accept", base, code);
1351    let accept_resp = rt
1352        .block_on(async {
1353            client
1354                .post(&accept_url)
1355                .header("Authorization", format!("Bearer {}", bearer_token))
1356                .header("Content-Type", "application/json")
1357                .send()
1358                .await
1359        })
1360        .context("failed to contact registry")?;
1361
1362    if !accept_resp.status().is_success() {
1363        let status = accept_resp.status();
1364        let body = rt.block_on(accept_resp.text()).unwrap_or_default();
1365        anyhow::bail!("Failed to accept invite ({}): {}", status, body);
1366    }
1367
1368    println!("✅ Successfully joined {} as {}", org_name, role);
1369    println!("   Your DID: {}", did);
1370
1371    Ok(())
1372}
1373
1374fn display_dry_run_revoke_member(org: &str, member: &str, invoker_did: &str) -> Result<()> {
1375    use crate::ux::format::{JsonResponse, is_json_mode};
1376
1377    if is_json_mode() {
1378        JsonResponse::success(
1379            "org revoke-member",
1380            &serde_json::json!({
1381                "dry_run": true,
1382                "org": org,
1383                "member_did": member,
1384                "invoker_did": invoker_did,
1385                "actions": [
1386                    "Create signed revocation for member",
1387                    "Store revocation in Git repository",
1388                    "Member will lose all org capabilities"
1389                ]
1390            }),
1391        )
1392        .print()
1393        .map_err(anyhow::Error::from)
1394    } else {
1395        let out = crate::ux::format::Output::new();
1396        out.print_info("Dry run mode — no changes will be made");
1397        out.newline();
1398        out.println(&format!("   Org:    {}", org));
1399        out.println(&format!("   Member: {}", member));
1400        out.newline();
1401        out.println("Would perform the following actions:");
1402        out.println(&format!(
1403            "  1. Create signed revocation for member {}",
1404            member
1405        ));
1406        out.println("  2. Store revocation in Git repository");
1407        out.println("  3. Member will lose all org capabilities");
1408        Ok(())
1409    }
1410}
1411
1412use crate::commands::executable::ExecutableCommand;
1413use crate::config::CliConfig;
1414
1415impl ExecutableCommand for OrgCommand {
1416    #[allow(clippy::disallowed_methods)]
1417    fn execute(&self, ctx: &CliConfig) -> Result<()> {
1418        handle_org(self.clone(), ctx, Utc::now())
1419    }
1420}