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