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#[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
55fn 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#[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#[derive(Subcommand, Debug, Clone)]
95pub enum OrgSubcommand {
96 #[command(visible_alias = "init")]
98 Create {
99 #[arg(long)]
101 name: String,
102
103 #[arg(long)]
105 key: Option<String>,
106
107 #[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 AddMember {
143 #[arg(long)]
145 org: String,
146
147 #[arg(long = "member", visible_alias = "member-did")]
149 member_did: String,
150
151 #[arg(long, value_enum)]
153 role: CliRole,
154
155 #[arg(long, value_delimiter = ',')]
157 capabilities: Option<Vec<auths_keri::Capability>>,
158
159 #[arg(long)]
161 key: Option<String>,
162
163 #[arg(long)]
165 note: Option<String>,
166 },
167
168 RevokeMember {
170 #[arg(long)]
172 org: String,
173
174 #[arg(long = "member", visible_alias = "member-did")]
176 member_did: String,
177
178 #[arg(long)]
180 note: Option<String>,
181
182 #[arg(long)]
184 key: Option<String>,
185
186 #[arg(long)]
188 dry_run: bool,
189 },
190
191 ListMembers {
193 #[arg(long)]
195 org: String,
196
197 #[arg(long, action = ArgAction::SetTrue)]
199 include_revoked: bool,
200 },
201
202 Audit {
204 #[arg(long)]
206 org: String,
207
208 #[arg(long = "member", visible_alias = "member-did")]
210 member_did: String,
211
212 #[arg(long)]
214 artifact: Option<PathBuf>,
215
216 #[arg(long)]
218 signed_at: Option<u128>,
219
220 #[arg(long, action = ArgAction::SetTrue)]
222 json: bool,
223 },
224
225 OffboardingLog {
227 #[arg(long)]
229 org: String,
230
231 #[arg(long = "member", visible_alias = "member-did")]
233 member_did: Option<String>,
234
235 #[arg(long, action = ArgAction::SetTrue)]
237 json: bool,
238 },
239
240 Bundle {
242 #[arg(long)]
244 org: String,
245
246 #[arg(long)]
248 out: PathBuf,
249 },
250
251 Join {
253 #[arg(long)]
255 code: String,
256
257 #[arg(long, env = "AUTHS_REGISTRY_URL", default_value = DEFAULT_REGISTRY_URL)]
259 registry: String,
260 },
261
262 Policy {
264 #[command(subcommand)]
266 action: PolicyAction,
267 },
268
269 Metrics {
271 #[arg(long)]
273 org: String,
274
275 #[arg(long, action = ArgAction::SetTrue)]
277 json: bool,
278 },
279
280 Trace {
282 #[arg(long)]
284 commit: Option<String>,
285
286 #[arg(long = "member", visible_alias = "member-did")]
288 member: Option<String>,
289
290 #[arg(long)]
293 signed_at: Option<u128>,
294
295 #[arg(long, action = ArgAction::SetTrue)]
297 json: bool,
298 },
299}
300
301#[derive(Subcommand, Debug, Clone)]
303pub enum PolicyAction {
304 Set {
306 #[arg(long)]
308 org: String,
309
310 #[arg(long)]
312 file: PathBuf,
313
314 #[arg(long)]
316 key: Option<String>,
317 },
318
319 Show {
321 #[arg(long)]
323 org: String,
324
325 #[arg(long, action = ArgAction::SetTrue)]
327 json: bool,
328 },
329}
330
331fn 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()
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
374pub 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, payload_file, note, expires_at, key, } => {
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 let subject_device_did = CanonicalDid::new_unchecked(subject_did.clone());
508
509 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, 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)] let subject_device_did = CanonicalDid::new_unchecked(subject_did.clone());
605
606 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 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 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, ®istry, 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
1234fn 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
1249fn 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
1257fn 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 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 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)] 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}