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<String>>,
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    let rt = tokio::runtime::Builder::new_current_thread()
352        .enable_all()
353        .build()
354        .expect("tokio runtime");
355    let base = match rt.block_on(auths_verifier::verify_with_keys(att, &issuer_pk)) {
356        Ok(_) => "✅ valid",
357        Err(e) if e.to_string().contains("revoked") => "🛑 revoked",
358        Err(e) if e.to_string().contains("expired") => "⌛ expired",
359        Err(_) => "❌ invalid",
360    };
361    let anchor_suffix = match anchor_set {
362        Some(set) => {
363            let anchored =
364                auths_sdk::attestation::canonical_said(att).is_some_and(|said| set.contains(&said));
365            if anchored { "" } else { " (unanchored)" }
366        }
367        None => "",
368    };
369    format!("{base}{anchor_suffix}")
370}
371
372/// Handles `org` commands for issuing or revoking member authorizations.
373pub fn handle_org(
374    cmd: OrgCommand,
375    ctx: &crate::config::CliConfig,
376    now: chrono::DateTime<chrono::Utc>,
377) -> Result<()> {
378    let repo_path = layout::resolve_repo_path(ctx.repo_path.clone())?;
379    let passphrase_provider = ctx.passphrase_provider.clone();
380
381    let mut config = StorageLayoutConfig::default();
382    if let Some(r) = &cmd.overrides.identity_ref {
383        config.identity_ref = r.clone().into();
384    }
385    if let Some(b) = &cmd.overrides.identity_blob {
386        config.identity_blob_name = b.clone().into();
387    }
388    if let Some(p) = &cmd.overrides.attestation_prefix {
389        config.device_attestation_prefix = p.clone().into();
390    }
391    if let Some(b) = &cmd.overrides.attestation_blob {
392        config.attestation_blob_name = b.clone().into();
393    }
394
395    let _attestation_storage = RegistryAttestationStorage::new(repo_path.clone());
396    let resolver: DefaultDidResolver = DefaultDidResolver::with_repo(&repo_path);
397
398    match cmd.subcommand {
399        OrgSubcommand::Create {
400            name,
401            key,
402            metadata_file,
403        } => {
404            let key_alias = key.unwrap_or_else(|| org_slug_alias(&name));
405
406            println!("🏛️  Initializing new organization identity...");
407            println!("   Organization Name: {name}");
408            println!("   Repository path:   {repo_path:?}");
409            println!("   Local Key Alias:   {key_alias}");
410
411            use crate::factories::storage::ensure_git_repo;
412            ensure_git_repo(&repo_path).context("Failed to initialize Git repository")?;
413
414            let extra_metadata = match &metadata_file {
415                Some(mf) if mf.exists() => {
416                    let raw = fs::read_to_string(mf)
417                        .with_context(|| format!("Failed to read metadata file: {mf:?}"))?;
418                    let value: serde_json::Value = serde_json::from_str(&raw)
419                        .with_context(|| format!("Invalid JSON in metadata file: {mf:?}"))?;
420                    println!("   Merged additional metadata from {mf:?}");
421                    Some(value)
422                }
423                _ => None,
424            };
425
426            let sdk_ctx = build_auths_context(
427                &repo_path,
428                &ctx.env_config,
429                Some(passphrase_provider.clone()),
430            )?;
431            let created = create_org(
432                &sdk_ctx,
433                &name,
434                &KeyAlias::new_unchecked(key_alias),
435                auths_crypto::CurveType::default(),
436                extra_metadata,
437            )
438            .with_context(|| format!("Failed to create organization '{name}'"))?;
439
440            println!("\n✅ Organization identity initialized successfully!");
441            println!("   Org Identity ID:    {}", created.org_did);
442            println!("   Org Name:           {name}");
443            println!("   Repo Path:          {repo_path:?}");
444            println!("   Key Alias:          {}", created.key_alias);
445            println!("   Admin Role:         Granted with all capabilities");
446            println!(
447                "   KEL Ref:            '{}'",
448                layout::keri_kel_ref(&Prefix::new_unchecked(created.org_prefix.clone()))
449            );
450            println!("   Identity Ref:       '{}'", config.identity_ref);
451            if let Ok(org_did) = CanonicalDid::parse(&created.org_did) {
452                println!(
453                    "   Member Ref:         '{}'",
454                    config.org_member_ref(&created.org_did, &org_did)
455                );
456            }
457            println!("\n🔑 Store your key passphrase securely.");
458            println!(
459                "   You can now add members with: auths org add-member --org {} --member <identity-id> --role <role>",
460                created.org_did
461            );
462
463            Ok(())
464        }
465
466        OrgSubcommand::Attest {
467            subject_did,  // The subject DID (String)
468            payload_file, // Path to the JSON payload
469            note,         // Optional note (String)
470            expires_at,   // Optional RFC3339 expiration string
471            key,          // Alias of the org's signing key in keychain
472        } => {
473            let signer_alias =
474                key.ok_or_else(|| anyhow!("Signer key alias must be provided with --key"))?;
475            let signer_alias = KeyAlias::new_unchecked(signer_alias);
476
477            let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
478            let managed_identity = identity_storage
479                .load_identity()
480                .context("Failed to load org identity from Git repository")?;
481            let controller_did = managed_identity.controller_did;
482            let rid = managed_identity.storage_id;
483
484            let payload_str = fs::read_to_string(&payload_file)
485                .with_context(|| format!("Failed to read payload file {:?}", payload_file))?;
486            let payload: serde_json::Value =
487                serde_json::from_str(&payload_str).context("Invalid JSON in payload file")?;
488
489            let key_storage = get_platform_keychain()?;
490            let (stored_did, _role, _encrypted_key) = key_storage
491                .load_key(&signer_alias)
492                .with_context(|| format!("Failed to load signer key '{}'", signer_alias))?;
493
494            if stored_did != controller_did {
495                return Err(anyhow!(
496                    "Signer key alias '{}' belongs to DID '{}', but loaded org identity is '{}'",
497                    signer_alias,
498                    stored_did,
499                    controller_did
500                ));
501            }
502
503            #[allow(clippy::disallowed_methods)]
504            // INVARIANT: subject_did accepts both did:key and did:keri
505            let subject_device_did = CanonicalDid::new_unchecked(subject_did.clone());
506
507            // --- Resolve device public key using the custom resolver IF did:key ---
508            let device_resolved = resolver.resolve(&subject_did).with_context(|| {
509                format!("Failed to resolve public key for subject: {}", subject_did)
510            })?;
511            let device_pk_bytes = device_resolved.public_key_bytes().to_vec();
512            let device_curve = device_resolved.curve();
513
514            let meta = AttestationMetadata {
515                note,
516                timestamp: Some(now),
517                expires_at: expires_at
518                    .as_deref()
519                    .map(DateTime::parse_from_rfc3339)
520                    .transpose()
521                    .map_err(|e| anyhow!("Invalid RFC3339 datetime string: {}", e))?
522                    .map(|dt| dt.with_timezone(&Utc)),
523            };
524
525            let signer = StorageSigner::new(key_storage);
526            let attestation = create_signed_attestation(
527                now,
528                auths_sdk::attestation::AttestationInput {
529                    rid: &rid,
530                    identity_did: &controller_did,
531                    subject: &subject_device_did,
532                    device_public_key: &device_pk_bytes,
533                    device_curve,
534                    payload: Some(payload),
535                    meta: &meta,
536                    identity_alias: Some(&signer_alias),
537                    device_alias: None, // No device signature for org attestations
538                    delegated_by: None,
539                    commit_sha: None,
540                    signer_type: None,
541                },
542                &signer,
543                passphrase_provider.as_ref(),
544            )
545            .context("Failed to create signed attestation object")?;
546
547            {
548                let backend = GitRegistryBackend::from_config_unchecked(
549                    RegistryConfig::single_tenant(&repo_path),
550                );
551                let mut batch = auths_sdk::keri::AtomicWriteBatch::new();
552                batch.stage_attestation(attestation);
553                if let Ok(prefix) = auths_sdk::keri::parse_did_keri(controller_did.as_str()) {
554                    let _ = auths_sdk::keri::try_stage_anchor(
555                        &backend,
556                        &signer,
557                        &signer_alias,
558                        passphrase_provider.as_ref(),
559                        &prefix,
560                        &serde_json::json!({}),
561                        &mut batch,
562                    );
563                }
564                backend
565                    .commit_batch(&batch)
566                    .context("Failed to write attestation")?;
567            }
568
569            println!(
570                "\n✅ Org attestation created successfully from '{}' → '{}'",
571                controller_did, subject_device_did
572            );
573
574            Ok(())
575        }
576
577        OrgSubcommand::Revoke {
578            subject_did,
579            note,
580            key,
581        } => {
582            println!("🛑 Revoking org authorization for subject: {subject_did}");
583            println!("   Using Repository:         {:?}", repo_path);
584            println!("   Using Identity Ref:       '{}'", config.identity_ref);
585            println!(
586                "   Using Attestation Prefix: '{}'",
587                config.device_attestation_prefix
588            );
589
590            let signer_alias =
591                key.ok_or_else(|| anyhow!("Signer key alias must be provided for revocation"))?;
592            let signer_alias = KeyAlias::new_unchecked(signer_alias);
593
594            let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
595            let managed_identity = identity_storage
596                .load_identity()
597                .context("Failed to load identity from Git repository")?;
598            let controller_did = managed_identity.controller_did;
599            let rid = managed_identity.storage_id;
600
601            #[allow(clippy::disallowed_methods)] // INVARIANT: accepts both did:key and did:keri
602            let subject_device_did = CanonicalDid::new_unchecked(subject_did.clone());
603
604            // Look up the subject's public key from existing attestations
605            let attestation_storage = RegistryAttestationStorage::new(repo_path.clone());
606            let existing = attestation_storage
607                .load_attestations_for_device(&subject_device_did)
608                .context("Failed to load attestations for subject")?;
609            let device_public_key = existing
610                .iter()
611                .find(|a| !a.device_public_key.is_zero())
612                .map(|a| a.device_public_key.clone())
613                .unwrap_or_default();
614
615            println!("🔏 Creating signed revocation...");
616            let signer = StorageSigner::new(get_platform_keychain()?);
617            let attestation = create_signed_revocation(
618                auths_sdk::attestation::RevocationInput {
619                    rid: &rid,
620                    identity_did: &controller_did,
621                    subject: &subject_device_did,
622                    device_public_key: device_public_key.as_bytes(),
623                    device_curve: device_public_key.curve(),
624                    note,
625                    payload: None,
626                    timestamp: now,
627                    identity_alias: &signer_alias,
628                },
629                &signer,
630                passphrase_provider.as_ref(),
631            )
632            .context("Failed to create revocation")?;
633
634            println!("💾 Writing revocation to Git...");
635            {
636                let backend = GitRegistryBackend::from_config_unchecked(
637                    RegistryConfig::single_tenant(&repo_path),
638                );
639                let mut batch = auths_sdk::keri::AtomicWriteBatch::new();
640                batch.stage_attestation(attestation);
641                if let Ok(prefix) = auths_sdk::keri::parse_did_keri(controller_did.as_str()) {
642                    let _ = auths_sdk::keri::try_stage_anchor(
643                        &backend,
644                        &signer,
645                        &signer_alias,
646                        passphrase_provider.as_ref(),
647                        &prefix,
648                        &serde_json::json!({}),
649                        &mut batch,
650                    );
651                }
652                backend
653                    .commit_batch(&batch)
654                    .context("Failed to write revocation")?;
655            }
656
657            println!("\n✅ Revoked authorization for subject {subject_did}");
658
659            Ok(())
660        }
661
662        OrgSubcommand::Show {
663            subject_did,
664            include_revoked,
665        } => {
666            let attestation_storage = RegistryAttestationStorage::new(repo_path.clone());
667            let resolver = DefaultDidResolver::with_repo(&repo_path);
668            let group = AttestationGroup::from_list(
669                attestation_storage
670                    .load_all_enriched()
671                    .map(|v| v.into_iter().map(|e| e.attestation).collect::<Vec<_>>())?,
672            );
673
674            #[allow(clippy::disallowed_methods)]
675            // INVARIANT: subject_did from CLI arg, used for lookup only
676            let subject_device_did = CanonicalDid::new_unchecked(subject_did.clone());
677            if let Some(list) = group.by_device.get(subject_device_did.as_str()) {
678                for (i, att) in list.iter().enumerate() {
679                    if !include_revoked
680                        && (att.is_revoked() || att.expires_at.is_some_and(|e| now > e))
681                    {
682                        continue;
683                    }
684
685                    let status = verify_attestation_via_resolver(att, &resolver, None);
686
687                    println!("{i}. [{}] @ {}", status, att.timestamp.unwrap_or(now));
688                    if let Some(note) = &att.note {
689                        println!("   📝 {}", note);
690                    }
691                    if let Some(payload) = &att.payload {
692                        println!("   📦 {}", serde_json::to_string_pretty(payload)?);
693                    }
694                }
695            } else {
696                println!("No authorizations found for subject: {}", subject_did);
697            }
698
699            Ok(())
700        }
701
702        OrgSubcommand::List { include_revoked } => {
703            let attestation_storage = RegistryAttestationStorage::new(repo_path.clone());
704            let resolver = DefaultDidResolver::with_repo(&repo_path);
705            let group = AttestationGroup::from_list(
706                attestation_storage
707                    .load_all_enriched()
708                    .map(|v| v.into_iter().map(|e| e.attestation).collect::<Vec<_>>())?,
709            );
710
711            for (subject, list) in group.by_device.iter() {
712                let Some(latest) = list.last() else {
713                    continue;
714                };
715                if !include_revoked
716                    && (latest.is_revoked() || latest.expires_at.is_some_and(|e| now > e))
717                {
718                    continue;
719                }
720
721                let status = verify_attestation_via_resolver(latest, &resolver, None);
722
723                println!("- {} [{}]", subject, status);
724            }
725
726            Ok(())
727        }
728
729        OrgSubcommand::AddMember {
730            org,
731            member_did: member_label,
732            role: cli_role,
733            capabilities,
734            key,
735            note: _note,
736        } => {
737            let role = Role::from(cli_role);
738            println!("👥 Adding member to organization...");
739            println!("   Org:   {}", org);
740            println!("   Label: {}", member_label);
741            println!("   Role:  {}", role);
742
743            let org_prefix =
744                Prefix::new_unchecked(org.strip_prefix("did:keri:").unwrap_or(&org).to_string());
745            let org_alias = KeyAlias::new_unchecked(key.unwrap_or_else(|| org_slug_alias(&org)));
746            let member_alias = KeyAlias::new_unchecked(member_label.clone());
747
748            let capability_strings = capabilities.unwrap_or_else(|| {
749                role.default_capabilities()
750                    .iter()
751                    .map(|c| c.as_str().to_string())
752                    .collect()
753            });
754
755            let sdk_ctx = build_auths_context(
756                &repo_path,
757                &ctx.env_config,
758                Some(passphrase_provider.clone()),
759            )?;
760            let result = add_member(
761                &sdk_ctx,
762                &org_prefix,
763                &org_alias,
764                &member_alias,
765                auths_crypto::CurveType::Ed25519,
766                role,
767                &capability_strings,
768                None,
769            )
770            .context("Failed to add member")?;
771
772            println!("\n✅ Member added as a KERI delegated identifier!");
773            println!("   Member DID:   {}", result.member_did);
774            println!("   Role:         {}", role);
775            println!("   Capabilities: {}", capability_strings.join(", "));
776            println!("\nThe org anchored this member's delegation in its KEL.");
777
778            Ok(())
779        }
780
781        OrgSubcommand::RevokeMember {
782            org,
783            member_did: member,
784            note,
785            key,
786            dry_run,
787        } => {
788            println!("🛑 Revoking member from organization...");
789            println!("   Org:    {}", org);
790            println!("   Member: {}", member);
791
792            let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
793            let invoker_did = identity_storage
794                .load_identity()
795                .context("Failed to load identity. Are you running this from an org repository?")?
796                .controller_did;
797
798            if dry_run {
799                return display_dry_run_revoke_member(&org, &member, invoker_did.as_ref());
800            }
801
802            let org_prefix =
803                Prefix::new_unchecked(org.strip_prefix("did:keri:").unwrap_or(&org).to_string());
804            let org_alias = KeyAlias::new_unchecked(key.unwrap_or_else(|| org_slug_alias(&org)));
805
806            let sdk_ctx = build_auths_context(
807                &repo_path,
808                &ctx.env_config,
809                Some(passphrase_provider.clone()),
810            )?;
811            let record = revoke_member(&sdk_ctx, &org_prefix, &org_alias, &member, note)
812                .context("Failed to revoke member")?;
813
814            match record {
815                Some(signed) => {
816                    println!("\n✅ Member revoked (revocation anchored in the org KEL):");
817                    println!("   Member:        {}", member);
818                    println!("   Revoked by:    {}", invoker_did);
819                    println!(
820                        "   Revoked at:    KEL seq {} (authority ends after this position, not by wall-clock)",
821                        signed.record.revoked_at_seq
822                    );
823                    println!("   Seal SAID:     {}", signed.record.revocation_seal_said);
824                    let role = signed.record.prior_role.as_deref().unwrap_or("unknown");
825                    println!("   Lost role:     {role}");
826                    if !signed.record.prior_caps.is_empty() {
827                        println!("   Lost caps:     {}", signed.record.prior_caps.join(", "));
828                    }
829                    println!("   Off-boarding record stored (signed, retrievable by org/member).");
830                }
831                None => {
832                    println!("\nℹ️  Member already revoked — no change (idempotent).");
833                    println!("   Member: {member}");
834                }
835            }
836
837            Ok(())
838        }
839
840        OrgSubcommand::ListMembers {
841            org,
842            include_revoked,
843        } => {
844            println!("📋 Listing members of organization: {}", org);
845
846            // KEL-authoritative: members are `dip`s the org anchored. Revocation and
847            // role/capabilities are read from the org KEL, never from an attestation.
848            let org_prefix =
849                Prefix::new_unchecked(org.strip_prefix("did:keri:").unwrap_or(&org).to_string());
850            let sdk_ctx = build_auths_context(
851                &repo_path,
852                &ctx.env_config,
853                Some(passphrase_provider.clone()),
854            )?;
855            let all_members =
856                list_members(&sdk_ctx, &org_prefix).context("Failed to list members")?;
857            let revoked_count = all_members.iter().filter(|m| m.revoked).count();
858
859            let mut members: Vec<_> = all_members
860                .into_iter()
861                .filter(|m| include_revoked || !m.revoked)
862                .collect();
863
864            if members.is_empty() {
865                println!("\nNo members found for organization.");
866                return Ok(());
867            }
868
869            members.sort_by(|a, b| {
870                member_role_order(&a.role)
871                    .cmp(&member_role_order(&b.role))
872                    .then_with(|| a.member_did.cmp(&b.member_did))
873            });
874
875            println!("\nOrg: {}", org);
876            println!("\nMembers ({} total):", members.len());
877            println!("─────────────────────────────────────────");
878
879            for m in &members {
880                let role_str = m.role.as_ref().map(|r| r.as_str()).unwrap_or("unknown");
881                let status = if m.revoked { " (revoked)" } else { "" };
882                let caps_str = if m.capabilities.is_empty() {
883                    String::new()
884                } else {
885                    format!(" [{}]", m.capabilities.join(", "))
886                };
887
888                println!("├─ {} [{}]{}{}", m.member_did, role_str, status, caps_str);
889                println!("│     delegated by: {}", m.delegated_by_org);
890            }
891
892            println!("─────────────────────────────────────────");
893
894            if !include_revoked && revoked_count > 0 {
895                println!(
896                    "\n({} revoked member(s) hidden. Use --include-revoked to show.)",
897                    revoked_count
898                );
899            }
900
901            Ok(())
902        }
903
904        OrgSubcommand::Audit {
905            org,
906            member_did,
907            artifact,
908            signed_at,
909            json,
910        } => {
911            let org_prefix =
912                Prefix::new_unchecked(org.strip_prefix("did:keri:").unwrap_or(&org).to_string());
913            let member_prefix = Prefix::new_unchecked(
914                member_did
915                    .strip_prefix("did:keri:")
916                    .unwrap_or(&member_did)
917                    .to_string(),
918            );
919            let sdk_ctx = build_auths_context(
920                &repo_path,
921                &ctx.env_config,
922                Some(passphrase_provider.clone()),
923            )?;
924            let verdict =
925                classify_authority_at_signing(&sdk_ctx, &org_prefix, &member_prefix, signed_at)
926                    .context("Failed to classify authority at signing")?;
927
928            if json {
929                println!("{}", serde_json::to_string_pretty(&verdict)?);
930                return Ok(());
931            }
932
933            if let Some(path) = &artifact {
934                println!("Artifact: {path:?}");
935            }
936            println!("Member:   {member_did}");
937            match &verdict {
938                AuthorityAtSigning::AuthorizedBeforeRevocation => println!(
939                    "Verdict:  ✅ AuthorizedBeforeRevocation — authority was live at the signing position"
940                ),
941                AuthorityAtSigning::RejectedAfterRevocation { revoked_at } => println!(
942                    "Verdict:  🛑 RejectedAfterRevocation {{ revoked_at: {revoked_at} }} — signed at/after the revocation KEL position"
943                ),
944                AuthorityAtSigning::RejectedRevokedPositionUnknown { revoked_at } => println!(
945                    "Verdict:  🛑 RejectedRevokedPositionUnknown {{ revoked_at: {revoked_at} }} — revoked; artifact carries no in-band signing position"
946                ),
947                AuthorityAtSigning::NeverDelegated => {
948                    println!("Verdict:  ❌ NeverDelegated — the org never delegated this member")
949                }
950            }
951            Ok(())
952        }
953
954        OrgSubcommand::OffboardingLog {
955            org,
956            member_did,
957            json,
958        } => {
959            let org_prefix =
960                Prefix::new_unchecked(org.strip_prefix("did:keri:").unwrap_or(&org).to_string());
961            let sdk_ctx = build_auths_context(
962                &repo_path,
963                &ctx.env_config,
964                Some(passphrase_provider.clone()),
965            )?;
966
967            let records = match &member_did {
968                Some(m) => {
969                    let member_prefix =
970                        Prefix::new_unchecked(m.strip_prefix("did:keri:").unwrap_or(m).to_string());
971                    load_offboarding_record(&sdk_ctx, &org_prefix, &member_prefix)
972                        .context("Failed to load off-boarding record")?
973                        .into_iter()
974                        .collect::<Vec<_>>()
975                }
976                None => list_offboarding_records(&sdk_ctx, &org_prefix)
977                    .context("Failed to list off-boarding records")?,
978            };
979
980            if json {
981                println!("{}", serde_json::to_string_pretty(&records)?);
982                return Ok(());
983            }
984
985            if records.is_empty() {
986                println!("No off-boarding records for organization {org}.");
987                return Ok(());
988            }
989
990            println!("Off-boarding records for {org} ({} total):", records.len());
991            for r in &records {
992                println!("─────────────────────────────────────────");
993                println!("  Member:     {}", r.record.member_did);
994                println!(
995                    "  Revoked at: KEL seq {} (by position, not wall-clock)",
996                    r.record.revoked_at_seq
997                );
998                println!("  Seal SAID:  {}", r.record.revocation_seal_said);
999                if let Some(reason) = &r.record.reason {
1000                    println!("  Reason:     {reason}");
1001                }
1002                let role = r.record.prior_role.as_deref().unwrap_or("unknown");
1003                println!("  Lost role:  {role}");
1004                if !r.record.prior_caps.is_empty() {
1005                    println!("  Lost caps:  {}", r.record.prior_caps.join(", "));
1006                }
1007                println!("  Recorded:   {}", r.record.recorded_at);
1008            }
1009            Ok(())
1010        }
1011
1012        OrgSubcommand::Bundle { org, out } => {
1013            let org_prefix =
1014                Prefix::new_unchecked(org.strip_prefix("did:keri:").unwrap_or(&org).to_string());
1015            let sdk_ctx = build_auths_context(
1016                &repo_path,
1017                &ctx.env_config,
1018                Some(passphrase_provider.clone()),
1019            )?;
1020            let bundle =
1021                build_org_bundle(&sdk_ctx, &org_prefix).context("Failed to build org bundle")?;
1022            let json = bundle
1023                .to_canonical_json()
1024                .context("Failed to canonicalize org bundle")?;
1025            fs::write(&out, json).with_context(|| format!("Failed to write bundle to {out:?}"))?;
1026
1027            println!("✅ Air-gapped org bundle written to {out:?}");
1028            println!("   Org:            {}", bundle.org_did.as_str());
1029            println!("   Built as-of:    KEL seq {}", bundle.built_at_org_seq);
1030            println!("   Member KELs:    {}", bundle.member_kels.len());
1031            println!("   Off-boardings:  {}", bundle.offboarding_records.len());
1032            println!("   Pinned roots:   {}", bundle.pinned_roots.len());
1033            println!(
1034                "   Verifies offline (no network): auths artifact verify {out:?} --offline --roots .auths/roots"
1035            );
1036            Ok(())
1037        }
1038
1039        OrgSubcommand::Join { code, registry } => {
1040            handle_join(&code, &registry, passphrase_provider.as_ref())
1041        }
1042
1043        OrgSubcommand::Policy { action } => match action {
1044            PolicyAction::Set { org, file, key } => {
1045                let org_prefix = Prefix::new_unchecked(
1046                    org.strip_prefix("did:keri:").unwrap_or(&org).to_string(),
1047                );
1048                let org_alias =
1049                    KeyAlias::new_unchecked(key.unwrap_or_else(|| org_slug_alias(&org)));
1050                let policy_json = fs::read(&file)
1051                    .with_context(|| format!("Failed to read policy file {file:?}"))?;
1052
1053                let sdk_ctx = build_auths_context(
1054                    &repo_path,
1055                    &ctx.env_config,
1056                    Some(passphrase_provider.clone()),
1057                )?;
1058                let result = set_org_policy(&sdk_ctx, &org_prefix, &org_alias, &policy_json)
1059                    .context("Failed to set org policy")?;
1060
1061                println!("✅ Org policy anchored on the KEL:");
1062                println!("   Org:         {}", result.org_did);
1063                println!("   Policy hash: {}", result.policy_hash);
1064                println!("   Requires:\n{}", result.description);
1065                Ok(())
1066            }
1067
1068            PolicyAction::Show { org, json } => {
1069                let org_prefix = Prefix::new_unchecked(
1070                    org.strip_prefix("did:keri:").unwrap_or(&org).to_string(),
1071                );
1072                let sdk_ctx = build_auths_context(
1073                    &repo_path,
1074                    &ctx.env_config,
1075                    Some(passphrase_provider.clone()),
1076                )?;
1077
1078                match load_org_policy(&sdk_ctx, &org_prefix).context("Failed to load org policy")? {
1079                    Some(policy) => {
1080                        if json {
1081                            println!("{}", policy.source_json);
1082                        } else {
1083                            println!("Org:         did:keri:{}", org_prefix.as_str());
1084                            println!("Policy hash: {}", policy.policy_hash);
1085                            println!("Requires:\n{}", policy.compiled.describe());
1086                        }
1087                    }
1088                    None => println!("No policy anchored for organization {org}."),
1089                }
1090                Ok(())
1091            }
1092        },
1093
1094        OrgSubcommand::Metrics { org, json } => {
1095            let org_prefix =
1096                Prefix::new_unchecked(org.strip_prefix("did:keri:").unwrap_or(&org).to_string());
1097            let sdk_ctx = build_auths_context(
1098                &repo_path,
1099                &ctx.env_config,
1100                Some(passphrase_provider.clone()),
1101            )?;
1102            let m =
1103                fleet_metrics(&sdk_ctx, &org_prefix).context("Failed to compute fleet metrics")?;
1104            if json {
1105                println!("{}", serde_json::to_string_pretty(&m)?);
1106                return Ok(());
1107            }
1108            println!("Fleet metrics for {}", m.org_did);
1109            println!("  Agents (total):           {}", m.agents_total);
1110            println!("  Agents (live):            {}", m.agents_live);
1111            println!("  Agents (revoked):         {}", m.agents_revoked);
1112            println!(
1113                "  Traceable to a human:     {}/{} ({:.0}%)",
1114                m.agents_traceable_to_human,
1115                m.agents_live,
1116                m.traceability_fraction * 100.0
1117            );
1118            println!(
1119                "  Revocation-to-effect:     {} KEL positions (positional — effective at the anchor)",
1120                m.revocation_effect_latency_positions
1121            );
1122            Ok(())
1123        }
1124
1125        OrgSubcommand::Trace {
1126            commit,
1127            member,
1128            signed_at,
1129            json,
1130        } => {
1131            let sdk_ctx = build_auths_context(
1132                &repo_path,
1133                &ctx.env_config,
1134                Some(passphrase_provider.clone()),
1135            )?;
1136
1137            let (leaf_prefix, position) = if let Some(sha) = commit {
1138                let raw = read_commit_object(&sha)?;
1139                let (_root, device) = commit_signer_trailers(&raw).ok_or_else(|| {
1140                    anyhow!("commit {sha} carries no Auths-Id/Auths-Device trailer")
1141                })?;
1142                let pos = parse_anchor_seq_trailer(&raw);
1143                let prefix = Prefix::new_unchecked(
1144                    device
1145                        .strip_prefix("did:keri:")
1146                        .unwrap_or(&device)
1147                        .to_string(),
1148                );
1149                (prefix, pos)
1150            } else if let Some(m) = member {
1151                let prefix =
1152                    Prefix::new_unchecked(m.strip_prefix("did:keri:").unwrap_or(&m).to_string());
1153                (prefix, signed_at)
1154            } else {
1155                return Err(anyhow!("provide --commit <sha> or --member <did> to trace"));
1156            };
1157
1158            let chain = walk_delegation_chain(&sdk_ctx, &leaf_prefix, position)
1159                .context("Failed to walk the delegation chain")?;
1160
1161            if json {
1162                println!("{}", serde_json::to_string_pretty(&chain)?);
1163                return Ok(());
1164            }
1165
1166            println!("Trace: {}", chain.leaf_did);
1167            match position {
1168                Some(p) => println!("  Signed at:       KEL position {p}"),
1169                None => println!(
1170                    "  Signed at:       (no in-band position — upstream revocations fail closed)"
1171                ),
1172            }
1173            println!("  Root:            {}", chain.root_did);
1174            println!("  Depth:           {} hop(s)", chain.depth);
1175            for hop in &chain.hops {
1176                let role = hop.role.as_deref().unwrap_or("-");
1177                let caps = if hop.capabilities.is_empty() {
1178                    String::new()
1179                } else {
1180                    format!(" caps=[{}]", hop.capabilities.join(", "))
1181                };
1182                println!(
1183                    "    {} ← {} [{}]{}  authority={:?}",
1184                    hop.child_did, hop.delegator_did, role, caps, hop.authority_at_signing
1185                );
1186            }
1187            if chain.live_at_signing {
1188                println!("  Live at signing: ✅ yes");
1189            } else {
1190                println!(
1191                    "  Live at signing: 🛑 no (a chain authority was revoked at/by the signing position)"
1192                );
1193            }
1194            Ok(())
1195        }
1196    }
1197}
1198
1199/// Read a raw git commit object (`git cat-file commit <sha>`).
1200fn read_commit_object(sha: &str) -> Result<String> {
1201    let out = std::process::Command::new("git")
1202        .args(["cat-file", "commit", sha])
1203        .output()
1204        .context("failed to run git cat-file")?;
1205    if !out.status.success() {
1206        return Err(anyhow!(
1207            "git cat-file commit {sha} failed: {}",
1208            String::from_utf8_lossy(&out.stderr).trim()
1209        ));
1210    }
1211    String::from_utf8(out.stdout).context("commit object is not valid UTF-8")
1212}
1213
1214/// Parse the `Auths-Anchor-Seq` trailer value from a raw commit, if present.
1215fn parse_anchor_seq_trailer(raw: &str) -> Option<u128> {
1216    raw.lines()
1217        .rev()
1218        .find_map(|l| l.strip_prefix("Auths-Anchor-Seq:"))
1219        .and_then(|v| v.trim().parse().ok())
1220}
1221
1222/// Handles the `org join` subcommand by looking up and accepting an invite
1223/// via the registry HTTP API.
1224///
1225/// Args:
1226/// * `code`: Invite code to redeem.
1227/// * `registry`: Base URL of the registry HTTP API.
1228/// * `passphrase_provider`: Injected provider used to unlock the signing key
1229///   when producing the bearer token; respects SE-backed and P-256 keys.
1230///
1231/// Usage:
1232/// ```ignore
1233/// handle_join(&code, &registry, ctx.passphrase_provider.as_ref())?;
1234/// ```
1235fn handle_join(
1236    code: &str,
1237    registry: &str,
1238    passphrase_provider: &dyn auths_sdk::signing::PassphraseProvider,
1239) -> Result<()> {
1240    let rt = tokio::runtime::Runtime::new()?;
1241    let client = reqwest::Client::new();
1242    let base = registry.trim_end_matches('/');
1243
1244    // 1. Look up invite details.
1245    let details_url = format!("{}/v1/invites/{}", base, code);
1246    let details_resp = rt
1247        .block_on(async { client.get(&details_url).send().await })
1248        .context("failed to contact registry")?;
1249
1250    if details_resp.status() == reqwest::StatusCode::NOT_FOUND {
1251        anyhow::bail!(
1252            "Invite code '{}' not found. Check the code and try again.",
1253            code
1254        );
1255    }
1256    if !details_resp.status().is_success() {
1257        let status = details_resp.status();
1258        let body = rt.block_on(details_resp.text()).unwrap_or_default();
1259        anyhow::bail!("Failed to look up invite ({}): {}", status, body);
1260    }
1261
1262    let details: serde_json::Value = rt
1263        .block_on(details_resp.json())
1264        .context("invalid response from registry")?;
1265
1266    let org_name = details["display_name"].as_str().unwrap_or("Unknown");
1267    let role = details["role"].as_str().unwrap_or("member");
1268    let status = details["status"].as_str().unwrap_or("unknown");
1269
1270    if status == "expired" {
1271        anyhow::bail!("This invite has expired. Ask the org admin for a new one.");
1272    }
1273    if status == "accepted" {
1274        anyhow::bail!("This invite has already been accepted.");
1275    }
1276
1277    println!("Organization: {}", org_name);
1278    println!("Role:         {}", role);
1279    println!("Status:       {}", status);
1280    println!();
1281
1282    // 2. Accept the invite. This requires auth — build a signed bearer token.
1283    let repo_path = layout::resolve_repo_path(None)?;
1284    let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
1285    let managed_identity = identity_storage
1286        .load_identity()
1287        .context("no local identity found — run `auths init` first")?;
1288    let did = managed_identity.controller_did.to_string();
1289
1290    let key_storage = get_platform_keychain()?;
1291    let primary_alias = KeyAlias::new_unchecked("main");
1292
1293    #[allow(clippy::disallowed_methods)] // CLI is the presentation boundary
1294    let timestamp = Utc::now().to_rfc3339();
1295    let message = format!("{}\n{}", did, timestamp);
1296
1297    let (sig_bytes, _pubkey, _curve) = auths_sdk::keychain::sign_with_key(
1298        key_storage.as_ref(),
1299        &primary_alias,
1300        passphrase_provider,
1301        message.as_bytes(),
1302    )
1303    .context("failed to sign invite bearer token")?;
1304
1305    use base64::Engine;
1306    let signature = base64::engine::general_purpose::STANDARD.encode(&sig_bytes);
1307
1308    let bearer_payload = serde_json::json!({
1309        "did": did,
1310        "timestamp": timestamp,
1311        "signature": signature,
1312    });
1313    let bearer_token = serde_json::to_string(&bearer_payload)?;
1314
1315    let accept_url = format!("{}/v1/invites/{}/accept", base, code);
1316    let accept_resp = rt
1317        .block_on(async {
1318            client
1319                .post(&accept_url)
1320                .header("Authorization", format!("Bearer {}", bearer_token))
1321                .header("Content-Type", "application/json")
1322                .send()
1323                .await
1324        })
1325        .context("failed to contact registry")?;
1326
1327    if !accept_resp.status().is_success() {
1328        let status = accept_resp.status();
1329        let body = rt.block_on(accept_resp.text()).unwrap_or_default();
1330        anyhow::bail!("Failed to accept invite ({}): {}", status, body);
1331    }
1332
1333    println!("✅ Successfully joined {} as {}", org_name, role);
1334    println!("   Your DID: {}", did);
1335
1336    Ok(())
1337}
1338
1339fn display_dry_run_revoke_member(org: &str, member: &str, invoker_did: &str) -> Result<()> {
1340    use crate::ux::format::{JsonResponse, is_json_mode};
1341
1342    if is_json_mode() {
1343        JsonResponse::success(
1344            "org revoke-member",
1345            &serde_json::json!({
1346                "dry_run": true,
1347                "org": org,
1348                "member_did": member,
1349                "invoker_did": invoker_did,
1350                "actions": [
1351                    "Create signed revocation for member",
1352                    "Store revocation in Git repository",
1353                    "Member will lose all org capabilities"
1354                ]
1355            }),
1356        )
1357        .print()
1358        .map_err(anyhow::Error::from)
1359    } else {
1360        let out = crate::ux::format::Output::new();
1361        out.print_info("Dry run mode — no changes will be made");
1362        out.newline();
1363        out.println(&format!("   Org:    {}", org));
1364        out.println(&format!("   Member: {}", member));
1365        out.newline();
1366        out.println("Would perform the following actions:");
1367        out.println(&format!(
1368            "  1. Create signed revocation for member {}",
1369            member
1370        ));
1371        out.println("  2. Store revocation in Git repository");
1372        out.println("  3. Member will lose all org capabilities");
1373        Ok(())
1374    }
1375}
1376
1377use crate::commands::executable::ExecutableCommand;
1378use crate::config::CliConfig;
1379
1380impl ExecutableCommand for OrgCommand {
1381    #[allow(clippy::disallowed_methods)]
1382    fn execute(&self, ctx: &CliConfig) -> Result<()> {
1383        handle_org(self.clone(), ctx, Utc::now())
1384    }
1385}