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<String>>,
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()
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
372pub 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, payload_file, note, expires_at, key, } => {
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 let subject_device_did = CanonicalDid::new_unchecked(subject_did.clone());
506
507 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, 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)] let subject_device_did = CanonicalDid::new_unchecked(subject_did.clone());
603
604 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 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 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, ®istry, 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
1199fn 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
1214fn 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
1222fn 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 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 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)] 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}