1use anyhow::{Context, Result, anyhow};
2use auths_core::crypto::signer::decrypt_keypair;
3use auths_id::attestation::create::create_signed_attestation;
4use auths_id::attestation::revoke::create_signed_revocation;
5use auths_id::identity::initialize::initialize_registry_identity;
6use auths_id::identity::resolve::DidResolver;
7use chrono::{DateTime, Utc};
8use clap::{ArgAction, Parser, Subcommand};
9use serde_json;
10use std::fs;
11use std::path::PathBuf;
12use std::sync::Arc;
13
14use auths_core::signing::{PassphraseProvider, StorageSigner};
15use auths_core::storage::keychain::{KeyAlias, get_platform_keychain};
16use auths_id::{
17 attestation::{export::AttestationSink, group::AttestationGroup, verify::verify_with_resolver},
18 identity::resolve::DefaultDidResolver,
19 storage::git_refs::AttestationMetadata,
20 storage::{
21 attestation::AttestationSource,
22 identity::IdentityStorage,
23 layout::{self, StorageLayoutConfig},
24 },
25};
26
27use auths_sdk::workflows::org::{Role, member_role_order};
28use auths_storage::git::{
29 GitRegistryBackend, RegistryAttestationStorage, RegistryConfig, RegistryIdentityStorage,
30};
31use auths_verifier::types::DeviceDID;
32use auths_verifier::{Capability, Ed25519PublicKey, Prefix};
33
34use clap::ValueEnum;
35
36#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
40pub enum CliRole {
41 Admin,
42 Member,
43 Readonly,
44}
45
46impl From<CliRole> for Role {
47 fn from(r: CliRole) -> Self {
48 match r {
49 CliRole::Admin => Role::Admin,
50 CliRole::Member => Role::Member,
51 CliRole::Readonly => Role::Readonly,
52 }
53 }
54}
55
56#[derive(Parser, Debug, Clone)]
58pub struct OrgCommand {
59 #[clap(subcommand)]
60 pub subcommand: OrgSubcommand,
61
62 #[command(flatten)]
63 pub overrides: crate::commands::registry_overrides::RegistryOverrides,
64}
65
66#[derive(Subcommand, Debug, Clone)]
68pub enum OrgSubcommand {
69 #[command(visible_alias = "init")]
71 Create {
72 #[arg(long)]
74 name: String,
75
76 #[arg(long)]
78 local_key_alias: Option<String>,
79
80 #[arg(long)]
82 metadata_file: Option<PathBuf>,
83 },
84 Attest {
85 #[arg(long = "subject-did", visible_alias = "subject")]
86 subject_did: String,
87 #[arg(long)]
88 payload_file: PathBuf,
89 #[arg(long)]
90 note: Option<String>,
91 #[arg(long)]
92 expires_at: Option<String>,
93 #[arg(long)]
94 signer_alias: Option<String>,
95 },
96 Revoke {
97 #[arg(long = "subject-did", visible_alias = "subject")]
98 subject_did: String,
99 #[arg(long)]
100 note: Option<String>,
101 #[arg(long)]
102 signer_alias: Option<String>,
103 },
104 Show {
105 #[arg(long = "subject-did", visible_alias = "subject")]
106 subject_did: String,
107 #[arg(long, action = ArgAction::SetTrue)]
108 include_revoked: bool,
109 },
110 List {
111 #[arg(long, action = ArgAction::SetTrue)]
112 include_revoked: bool,
113 },
114 AddMember {
116 #[arg(long)]
118 org: String,
119
120 #[arg(long = "member-did", visible_alias = "member")]
122 member_did: String,
123
124 #[arg(long, value_enum)]
126 role: CliRole,
127
128 #[arg(long, value_delimiter = ',')]
130 capabilities: Option<Vec<String>>,
131
132 #[arg(long)]
134 signer_alias: Option<String>,
135
136 #[arg(long)]
138 note: Option<String>,
139 },
140
141 RevokeMember {
143 #[arg(long)]
145 org: String,
146
147 #[arg(long = "member-did", visible_alias = "member")]
149 member_did: String,
150
151 #[arg(long)]
153 note: Option<String>,
154
155 #[arg(long)]
157 signer_alias: Option<String>,
158
159 #[arg(long)]
161 dry_run: bool,
162 },
163
164 ListMembers {
166 #[arg(long)]
168 org: String,
169
170 #[arg(long, action = ArgAction::SetTrue)]
172 include_revoked: bool,
173 },
174}
175
176pub fn handle_org(
178 cmd: OrgCommand,
179 repo_opt: Option<PathBuf>,
180 identity_ref_override: Option<String>,
181 identity_blob_name_override: Option<String>,
182 attestation_prefix_override: Option<String>,
183 attestation_blob_name_override: Option<String>,
184 passphrase_provider: Arc<dyn PassphraseProvider + Send + Sync>,
185) -> Result<()> {
186 let repo_path = layout::resolve_repo_path(repo_opt)?;
187
188 let mut config = StorageLayoutConfig::default();
189 if let Some(r) = identity_ref_override {
190 config.identity_ref = r.into();
191 }
192 if let Some(b) = identity_blob_name_override {
193 config.identity_blob_name = b.into();
194 }
195 if let Some(p) = attestation_prefix_override {
196 config.device_attestation_prefix = p.into();
197 }
198 if let Some(b) = attestation_blob_name_override {
199 config.attestation_blob_name = b.into();
200 }
201
202 let _attestation_storage = RegistryAttestationStorage::new(repo_path.clone());
203 let resolver: DefaultDidResolver = DefaultDidResolver::with_repo(&repo_path);
204
205 match cmd.subcommand {
206 OrgSubcommand::Create {
207 name,
208 local_key_alias,
209 metadata_file,
210 } => {
211 let key_alias = local_key_alias.unwrap_or_else(|| {
213 format!(
214 "org-{}",
215 name.chars()
216 .filter(|c| c.is_alphanumeric())
217 .take(20)
218 .collect::<String>()
219 .to_lowercase()
220 )
221 });
222
223 println!("šļø Initializing new organization identity...");
224 println!(" Organization Name: {}", name);
225 println!(" Repository path: {:?}", repo_path);
226 println!(" Local Key Alias: {}", key_alias);
227 println!(" Using Identity Ref: '{}'", config.identity_ref);
228
229 use crate::factories::storage::{ensure_git_repo, open_git_repo};
231
232 let identity_storage_check = RegistryIdentityStorage::new(repo_path.clone());
233 if repo_path.exists() {
234 match open_git_repo(&repo_path) {
235 Ok(_) => {
236 println!(" Git repository found.");
237 if identity_storage_check.load_identity().is_ok() {
238 return Err(anyhow!(
239 "An identity already exists at {:?}. Aborting.",
240 repo_path
241 ));
242 }
243 }
244 Err(_) => {
245 println!(" Path exists but is not a Git repo. Initializing...");
246 ensure_git_repo(&repo_path)
247 .context("Failed to initialize Git repository")?;
248 }
249 }
250 } else {
251 println!(" Creating Git repo directory...");
252 ensure_git_repo(&repo_path)
253 .context("Failed to create and initialize Git repository")?;
254 }
255
256 let mut metadata_json = serde_json::json!({
258 "type": "org",
259 "name": name,
260 "created_at": Utc::now().to_rfc3339()
261 });
262
263 if let Some(ref mf) = metadata_file
265 && mf.exists()
266 {
267 let metadata_content = fs::read_to_string(mf)
268 .with_context(|| format!("Failed to read metadata file: {:?}", mf))?;
269 let additional: serde_json::Value = serde_json::from_str(&metadata_content)
270 .with_context(|| format!("Invalid JSON in metadata file: {:?}", mf))?;
271
272 if let (Some(base), Some(add)) =
274 (metadata_json.as_object_mut(), additional.as_object())
275 {
276 for (k, v) in add {
277 if k != "type" && k != "name" {
278 base.insert(k.clone(), v.clone());
279 }
280 }
281 }
282 println!(" Merged additional metadata from {:?}", mf);
283 }
284
285 println!(
286 " Org metadata: {}",
287 serde_json::to_string(&metadata_json)?
288 );
289
290 println!(" Creating KERI-based organization identity (did:keri)...");
292
293 let backend = std::sync::Arc::new(GitRegistryBackend::from_config_unchecked(
294 RegistryConfig::single_tenant(&repo_path),
295 ));
296 let key_alias = KeyAlias::new_unchecked(key_alias);
297 let (controller_did, alias) = initialize_registry_identity(
298 backend,
299 &key_alias,
300 passphrase_provider.as_ref(),
301 &get_platform_keychain()?,
302 None,
303 )
304 .context("Failed to initialize org identity")?;
305
306 println!(" Creating admin attestation for organization creator...");
308
309 let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
310 let managed_identity = identity_storage
311 .load_identity()
312 .context("Failed to load newly created org identity")?;
313 let rid = managed_identity.storage_id;
314
315 let org_resolved = resolver.resolve(controller_did.as_str()).with_context(|| {
317 format!(
318 "Failed to resolve public key for org identity: {}",
319 controller_did
320 )
321 })?;
322 let org_pk_bytes = *org_resolved.public_key();
323
324 let now = Utc::now();
325 let admin_capabilities = vec![
326 Capability::sign_commit(),
327 Capability::sign_release(),
328 Capability::manage_members(),
329 Capability::rotate_keys(),
330 ];
331
332 let meta = AttestationMetadata {
333 note: Some(format!("Organization '{}' root admin", name)),
334 timestamp: Some(now),
335 expires_at: None, };
337
338 let signer = StorageSigner::new(get_platform_keychain()?);
339 let org_did = DeviceDID::new(controller_did.to_string());
340
341 let attestation = create_signed_attestation(
342 now,
343 &rid,
344 &controller_did,
345 &org_did,
346 org_pk_bytes.as_bytes(),
347 Some(serde_json::json!({
348 "org_role": "admin",
349 "org_name": name
350 })),
351 &meta,
352 &signer,
353 passphrase_provider.as_ref(),
354 Some(&alias),
355 None, admin_capabilities,
357 Some(Role::Admin),
358 None, )
360 .context("Failed to create admin attestation")?;
361
362 let attestation_storage = RegistryAttestationStorage::new(repo_path.clone());
364 attestation_storage
365 .export(&auths_verifier::VerifiedAttestation::dangerous_from_unchecked(attestation))
366 .context("Failed to export admin attestation to Git")?;
367
368 println!("\nā
Organization identity initialized successfully!");
369 println!(" Org Identity ID: {}", controller_did);
370 println!(" Org Name: {}", name);
371 println!(" Repo Path: {:?}", repo_path);
372 println!(" Key Alias: {}", alias);
373 println!(" Admin Role: Granted with all capabilities");
374
375 if let Some(did_prefix) = controller_did.as_str().strip_prefix("did:keri:") {
376 println!(
377 " KEL Ref: '{}'",
378 layout::keri_kel_ref(&Prefix::new_unchecked(did_prefix.to_string()))
379 );
380 }
381
382 println!(" Identity Ref: '{}'", config.identity_ref);
383 println!(
384 " Member Ref: '{}'",
385 config.org_member_ref(controller_did.as_str(), &org_did)
386 );
387 println!("\nš Store your key passphrase securely.");
388 println!(
389 " You can now add members with: auths org add-member --org {} --member <identity-id> --role <role>",
390 controller_did
391 );
392
393 Ok(())
394 }
395
396 OrgSubcommand::Attest {
397 subject_did, payload_file, note, expires_at, signer_alias, } => {
403 let signer_alias = signer_alias
404 .ok_or_else(|| anyhow!("Signer key alias must be provided with --signer-alias"))?;
405 let signer_alias = KeyAlias::new_unchecked(signer_alias);
406
407 let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
408 let managed_identity = identity_storage
409 .load_identity()
410 .context("Failed to load org identity from Git repository")?;
411 let controller_did = managed_identity.controller_did;
412 let rid = managed_identity.storage_id;
413
414 let payload_str = fs::read_to_string(&payload_file)
415 .with_context(|| format!("Failed to read payload file {:?}", payload_file))?;
416 let payload: serde_json::Value =
417 serde_json::from_str(&payload_str).context("Invalid JSON in payload file")?;
418
419 let key_storage = get_platform_keychain()?;
420 let (stored_did, _role, encrypted_key) = key_storage
421 .load_key(&signer_alias)
422 .with_context(|| format!("Failed to load signer key '{}'", signer_alias))?;
423
424 if stored_did != controller_did {
425 return Err(anyhow!(
426 "Signer key alias '{}' belongs to DID '{}', but loaded org identity is '{}'",
427 signer_alias,
428 stored_did,
429 controller_did
430 ));
431 }
432
433 let passphrase = passphrase_provider.get_passphrase(&format!(
434 "Enter passphrase for org identity key '{}':",
435 signer_alias
436 ))?;
437 let _pkcs8_bytes = decrypt_keypair(&encrypted_key, &passphrase)
438 .context("Failed to decrypt signer key (invalid passphrase?)")?;
439
440 let subject_device_did = DeviceDID::new(subject_did.clone());
441
442 let device_resolved = resolver.resolve(&subject_did).with_context(|| {
444 format!("Failed to resolve public key for subject: {}", subject_did)
445 })?;
446 let device_pk_bytes = *device_resolved.public_key();
447
448 let now = Utc::now();
449 let meta = AttestationMetadata {
450 note,
451 timestamp: Some(now),
452 expires_at: expires_at
453 .as_deref()
454 .map(DateTime::parse_from_rfc3339)
455 .transpose()
456 .map_err(|e| anyhow!("Invalid RFC3339 datetime string: {}", e))?
457 .map(|dt| dt.with_timezone(&Utc)),
458 };
459
460 let signer = StorageSigner::new(key_storage);
461 let attestation = create_signed_attestation(
462 now,
463 &rid,
464 &controller_did,
465 &subject_device_did,
466 device_pk_bytes.as_bytes(),
467 Some(payload),
468 &meta,
469 &signer,
470 passphrase_provider.as_ref(),
471 Some(&signer_alias),
472 None, vec![],
474 None,
475 None,
476 )
477 .context("Failed to create signed attestation object")?;
478
479 let attestation_storage = RegistryAttestationStorage::new(repo_path.clone());
480 attestation_storage
481 .export(&auths_verifier::VerifiedAttestation::dangerous_from_unchecked(attestation))
482 .context("Failed to export attestation to Git")?;
483
484 println!(
485 "\nā
Org attestation created successfully from '{}' ā '{}'",
486 controller_did, subject_device_did
487 );
488
489 Ok(())
490 }
491
492 OrgSubcommand::Revoke {
493 subject_did,
494 note,
495 signer_alias,
496 } => {
497 println!("š Revoking org authorization for subject: {subject_did}");
498 println!(" Using Repository: {:?}", repo_path);
499 println!(" Using Identity Ref: '{}'", config.identity_ref);
500 println!(
501 " Using Attestation Prefix: '{}'",
502 config.device_attestation_prefix
503 );
504
505 let signer_alias = signer_alias
506 .ok_or_else(|| anyhow!("Signer key alias must be provided for revocation"))?;
507 let signer_alias = KeyAlias::new_unchecked(signer_alias);
508
509 let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
510 let managed_identity = identity_storage
511 .load_identity()
512 .context("Failed to load identity from Git repository")?;
513 let controller_did = managed_identity.controller_did;
514 let rid = managed_identity.storage_id;
515
516 let encrypted_key = get_platform_keychain()?
517 .load_key(&signer_alias)
518 .context("Failed to load signer key")?
519 .2;
520 let pass = passphrase_provider.get_passphrase(&format!(
521 "Enter passphrase for identity key '{}':",
522 signer_alias
523 ))?;
524 let _pkcs8_bytes =
525 decrypt_keypair(&encrypted_key, &pass).context("Failed to decrypt identity key")?;
526
527 let subject_device_did = DeviceDID::new(subject_did.clone());
529 let now = Utc::now();
530
531 let attestation_storage = RegistryAttestationStorage::new(repo_path.clone());
533 let existing = attestation_storage
534 .load_attestations_for_device(&subject_device_did)
535 .context("Failed to load attestations for subject")?;
536 let device_public_key = existing
537 .iter()
538 .find(|a| !a.device_public_key.is_zero())
539 .map(|a| a.device_public_key)
540 .unwrap_or_else(|| Ed25519PublicKey::from_bytes([0u8; 32]));
541
542 println!("š Creating signed revocation...");
543 let signer = StorageSigner::new(get_platform_keychain()?);
544 let attestation = create_signed_revocation(
545 &rid,
546 &controller_did,
547 &subject_device_did,
548 device_public_key.as_bytes(),
549 note,
550 None,
551 now,
552 &signer,
553 passphrase_provider.as_ref(),
554 &signer_alias,
555 )
556 .context("Failed to create revocation")?;
557
558 println!("š¾ Writing revocation to Git...");
559 attestation_storage
560 .export(&auths_verifier::VerifiedAttestation::dangerous_from_unchecked(attestation))
561 .context("Failed to write revocation")?;
562
563 println!("\nā
Revoked authorization for subject {subject_did}");
564
565 Ok(())
566 }
567
568 OrgSubcommand::Show {
569 subject_did,
570 include_revoked,
571 } => {
572 let attestation_storage = RegistryAttestationStorage::new(repo_path.clone());
573 let resolver = DefaultDidResolver::with_repo(&repo_path);
574 let group = AttestationGroup::from_list(attestation_storage.load_all_attestations()?);
575
576 let subject_device_did = DeviceDID(subject_did.clone());
577 if let Some(list) = group.by_device.get(subject_device_did.as_str()) {
578 for (i, att) in list.iter().enumerate() {
579 if !include_revoked
580 && (att.is_revoked() || att.expires_at.is_some_and(|e| Utc::now() > e))
581 {
582 continue;
583 }
584
585 let status = match verify_with_resolver(Utc::now(), &resolver, att, None) {
586 Ok(_) => "ā
valid",
587 Err(e) if e.to_string().contains("revoked") => "š revoked",
588 Err(e) if e.to_string().contains("expired") => "ā expired",
589 Err(_) => "ā invalid",
590 };
591
592 println!(
593 "{i}. [{}] @ {}",
594 status,
595 att.timestamp.unwrap_or(Utc::now())
596 );
597 if let Some(note) = &att.note {
598 println!(" š {}", note);
599 }
600 if let Some(payload) = &att.payload {
601 println!(" š¦ {}", serde_json::to_string_pretty(payload)?);
602 }
603 }
604 } else {
605 println!("No authorizations found for subject: {}", subject_did);
606 }
607
608 Ok(())
609 }
610
611 OrgSubcommand::List { include_revoked } => {
612 let attestation_storage = RegistryAttestationStorage::new(repo_path.clone());
613 let resolver = DefaultDidResolver::with_repo(&repo_path);
614 let group = AttestationGroup::from_list(attestation_storage.load_all_attestations()?);
615
616 for (subject, list) in group.by_device.iter() {
617 let latest = list.last().unwrap();
618 if !include_revoked
619 && (latest.is_revoked() || latest.expires_at.is_some_and(|e| Utc::now() > e))
620 {
621 continue;
622 }
623
624 let status = match verify_with_resolver(Utc::now(), &resolver, latest, None) {
625 Ok(_) => "ā
valid",
626 Err(e) if e.to_string().contains("revoked") => "š revoked",
627 Err(e) if e.to_string().contains("expired") => "ā expired",
628 Err(_) => "ā invalid",
629 };
630
631 println!("- {} [{}]", subject, status);
632 }
633
634 Ok(())
635 }
636
637 OrgSubcommand::AddMember {
638 org,
639 member_did: member,
640 role: cli_role,
641 capabilities,
642 signer_alias,
643 note,
644 } => {
645 let role = Role::from(cli_role);
646 println!("š„ Adding member to organization...");
647 println!(" Org: {}", org);
648 println!(" Member: {}", member);
649 println!(" Role: {}", role);
650
651 let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
653 let managed_identity = identity_storage
654 .load_identity()
655 .context("Failed to load identity. Are you running this from an org repository?")?;
656 let invoker_did = managed_identity.controller_did.clone();
657 let rid = managed_identity.storage_id;
658
659 let signer_alias = KeyAlias::new_unchecked(signer_alias.unwrap_or_else(|| {
661 format!(
663 "org-{}",
664 org.chars()
665 .filter(|c| c.is_alphanumeric())
666 .take(20)
667 .collect::<String>()
668 .to_lowercase()
669 )
670 }));
671
672 let attestation_storage = RegistryAttestationStorage::new(repo_path.clone());
675 let invoker_did_device = DeviceDID::new(invoker_did.to_string());
676 let invoker_attestations = attestation_storage.load_all_attestations()?;
677
678 let invoker_has_manage_members = invoker_attestations.iter().any(|att| {
680 att.subject.as_str() == invoker_did_device.as_str()
681 && !att.is_revoked()
682 && att.capabilities.contains(&Capability::manage_members())
683 });
684
685 if !invoker_has_manage_members {
686 return Err(anyhow!(
687 "You don't have ManageMembers capability for org '{}'. Only org admins can add members.",
688 org
689 ));
690 }
691
692 let key_storage = get_platform_keychain()?;
694 let (stored_did, _role, encrypted_key) = key_storage
695 .load_key(&signer_alias)
696 .with_context(|| format!("Failed to load signer key '{}'", signer_alias))?;
697
698 if stored_did != invoker_did {
699 return Err(anyhow!(
700 "Signer key alias '{}' belongs to DID '{}', but loaded identity is '{}'",
701 signer_alias,
702 stored_did,
703 invoker_did
704 ));
705 }
706
707 let passphrase = passphrase_provider
708 .get_passphrase(&format!("Enter passphrase for org key '{}':", signer_alias))?;
709 let _pkcs8_bytes = decrypt_keypair(&encrypted_key, &passphrase)
710 .context("Failed to decrypt signer key (invalid passphrase?)")?;
711
712 let member_did = DeviceDID::new(member.clone());
714 let member_resolved = resolver
715 .resolve(&member)
716 .with_context(|| format!("Failed to resolve public key for member: {}", member))?;
717 let member_pk_bytes = *member_resolved.public_key();
718
719 let member_capabilities = if let Some(cap_strs) = capabilities {
721 cap_strs
722 .iter()
723 .map(|s| {
724 s.parse::<Capability>().unwrap_or_else(|e| {
725 eprintln!("error: {e}");
726 std::process::exit(2);
727 })
728 })
729 .collect()
730 } else {
731 role.default_capabilities()
732 };
733
734 println!(
735 " Capabilities: {:?}",
736 member_capabilities
737 .iter()
738 .map(|c| format!("{:?}", c))
739 .collect::<Vec<_>>()
740 .join(", ")
741 );
742
743 let now = Utc::now();
745 let meta = AttestationMetadata {
746 note: note.or_else(|| Some(format!("Added as {} by {}", role, invoker_did))),
747 timestamp: Some(now),
748 expires_at: None, };
750
751 let signer = StorageSigner::new(key_storage);
752 let attestation = create_signed_attestation(
753 now,
754 &rid,
755 &invoker_did,
756 &member_did,
757 member_pk_bytes.as_bytes(),
758 Some(serde_json::json!({
759 "org_role": role.to_string(),
760 "org_did": org
761 })),
762 &meta,
763 &signer,
764 passphrase_provider.as_ref(),
765 Some(&signer_alias),
766 None, member_capabilities.clone(),
768 Some(role),
769 Some(invoker_did.clone()),
770 )
771 .context("Failed to create member attestation")?;
772
773 let attestation_storage = RegistryAttestationStorage::new(repo_path.clone());
775 attestation_storage
776 .export(&auths_verifier::VerifiedAttestation::dangerous_from_unchecked(attestation))
777 .context("Failed to export member attestation to Git")?;
778
779 println!("\nā
Member added successfully!");
780 println!(" Member ID: {}", member);
781 println!(" Role: {}", role);
782 println!(
783 " Capabilities: {}",
784 member_capabilities
785 .iter()
786 .map(|c| format!("{:?}", c))
787 .collect::<Vec<_>>()
788 .join(", ")
789 );
790 println!(" Delegated by: {}", invoker_did);
791 println!(
792 " Stored at: {}",
793 config.org_member_ref(&org, &member_did)
794 );
795
796 Ok(())
797 }
798
799 OrgSubcommand::RevokeMember {
800 org,
801 member_did: member,
802 note,
803 signer_alias,
804 dry_run,
805 } => {
806 println!("š Revoking member from organization...");
807 println!(" Org: {}", org);
808 println!(" Member: {}", member);
809
810 let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
812 let managed_identity = identity_storage
813 .load_identity()
814 .context("Failed to load identity. Are you running this from an org repository?")?;
815 let invoker_did = managed_identity.controller_did.clone();
816 let rid = managed_identity.storage_id;
817
818 let signer_alias = KeyAlias::new_unchecked(signer_alias.unwrap_or_else(|| {
820 format!(
821 "org-{}",
822 org.chars()
823 .filter(|c| c.is_alphanumeric())
824 .take(20)
825 .collect::<String>()
826 .to_lowercase()
827 )
828 }));
829
830 let attestation_storage = RegistryAttestationStorage::new(repo_path.clone());
832 let invoker_did_device = DeviceDID::new(invoker_did.to_string());
833 let all_attestations = attestation_storage.load_all_attestations()?;
834
835 let invoker_has_manage_members = all_attestations.iter().any(|att| {
837 att.subject.as_str() == invoker_did_device.as_str()
838 && !att.is_revoked()
839 && att.capabilities.contains(&Capability::manage_members())
840 });
841
842 if !invoker_has_manage_members {
843 return Err(anyhow!(
844 "You don't have ManageMembers capability for org '{}'. Only org admins can revoke members.",
845 org
846 ));
847 }
848
849 let member_did = DeviceDID::new(member.clone());
851 let member_attestation = all_attestations
852 .iter()
853 .find(|att| att.subject.as_str() == member_did.as_str());
854
855 match member_attestation {
856 None => {
857 return Err(anyhow!(
858 "Member '{}' is not a member of org '{}'. Cannot revoke.",
859 member,
860 org
861 ));
862 }
863 Some(att) if att.is_revoked() => {
864 return Err(anyhow!(
865 "Member '{}' is already revoked from org '{}'.",
866 member,
867 org
868 ));
869 }
870 Some(_) => {} }
872
873 if dry_run {
874 return display_dry_run_revoke_member(&org, &member, invoker_did.as_ref());
875 }
876
877 let key_storage = get_platform_keychain()?;
879 let (stored_did, _role, encrypted_key) = key_storage
880 .load_key(&signer_alias)
881 .with_context(|| format!("Failed to load signer key '{}'", signer_alias))?;
882
883 if stored_did != invoker_did {
884 return Err(anyhow!(
885 "Signer key alias '{}' belongs to DID '{}', but loaded identity is '{}'",
886 signer_alias,
887 stored_did,
888 invoker_did
889 ));
890 }
891
892 let passphrase = passphrase_provider
893 .get_passphrase(&format!("Enter passphrase for org key '{}':", signer_alias))?;
894 let _pkcs8_bytes = decrypt_keypair(&encrypted_key, &passphrase)
895 .context("Failed to decrypt signer key (invalid passphrase?)")?;
896
897 let attestation_storage = RegistryAttestationStorage::new(repo_path.clone());
899 let existing = attestation_storage
900 .load_attestations_for_device(&member_did)
901 .context("Failed to load attestations for member")?;
902 let member_public_key = existing
903 .iter()
904 .find(|a| !a.device_public_key.is_zero())
905 .map(|a| a.device_public_key)
906 .unwrap_or_else(|| Ed25519PublicKey::from_bytes([0u8; 32]));
907
908 let now = Utc::now();
910 let signer = StorageSigner::new(key_storage);
911
912 println!("š Creating signed revocation...");
913 let revocation = create_signed_revocation(
914 &rid,
915 &invoker_did,
916 &member_did,
917 member_public_key.as_bytes(),
918 note.clone(),
919 None, now,
921 &signer,
922 passphrase_provider.as_ref(),
923 &signer_alias,
924 )
925 .context("Failed to create revocation")?;
926
927 println!("š¾ Writing revocation to Git...");
929 attestation_storage
930 .export(&auths_verifier::VerifiedAttestation::dangerous_from_unchecked(revocation))
931 .context("Failed to export revocation to Git")?;
932
933 println!("\nā
Member revoked successfully!");
934 println!(" Member ID: {}", member);
935 println!(" Revoked by: {}", invoker_did);
936 if let Some(n) = note {
937 println!(" Note: {}", n);
938 }
939 println!(
940 " Stored at: {}",
941 config.org_member_ref(&org, &member_did)
942 );
943
944 Ok(())
945 }
946
947 OrgSubcommand::ListMembers {
948 org,
949 include_revoked,
950 } => {
951 println!("š Listing members of organization: {}", org);
952
953 let attestation_storage = RegistryAttestationStorage::new(repo_path.clone());
955 let all_attestations = attestation_storage.load_all_attestations()?;
956
957 #[allow(clippy::type_complexity)]
959 let mut members: Vec<(
960 String,
961 Option<Role>,
962 Option<String>,
963 bool,
964 Vec<Capability>,
965 )> = Vec::new();
966
967 for att in &all_attestations {
968 if att.is_revoked() && !include_revoked {
970 continue;
971 }
972
973 if att.expires_at.is_some_and(|e| Utc::now() > e) && !include_revoked {
975 continue;
976 }
977
978 members.push((
979 att.subject.to_string(),
980 att.role,
981 att.delegated_by.as_ref().map(|d| d.to_string()),
982 att.is_revoked(),
983 att.capabilities.clone(),
984 ));
985 }
986
987 if members.is_empty() {
988 println!("\nNo members found for organization.");
989 return Ok(());
990 }
991
992 members.sort_by(|a, b| {
993 member_role_order(&a.1)
994 .cmp(&member_role_order(&b.1))
995 .then_with(|| a.0.cmp(&b.0))
996 });
997
998 println!("\nOrg: {}", org);
999 println!("\nMembers ({} total):", members.len());
1000 println!("āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā");
1001
1002 for (member_did, role, delegated_by, revoked, capabilities) in &members {
1003 let role_str = role.as_ref().map(|r| r.as_str()).unwrap_or("unknown");
1004 let status = if *revoked { " (revoked)" } else { "" };
1005
1006 let prefix = if delegated_by.is_none() {
1008 "āā "
1009 } else {
1010 "ā āā "
1011 };
1012
1013 let caps: Vec<String> = capabilities.iter().map(|c| format!("{:?}", c)).collect();
1015 let caps_str = if caps.is_empty() {
1016 String::new()
1017 } else {
1018 format!(" [{}]", caps.join(", "))
1019 };
1020
1021 println!(
1022 "{}{} [{}]{}{}",
1023 prefix, member_did, role_str, status, caps_str
1024 );
1025
1026 if let Some(delegator) = delegated_by {
1027 println!("ā delegated by: {}", delegator);
1028 }
1029 }
1030
1031 println!("āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā");
1032
1033 if !include_revoked {
1034 let revoked_count = all_attestations.iter().filter(|a| a.is_revoked()).count();
1035 if revoked_count > 0 {
1036 println!(
1037 "\n({} revoked member(s) hidden. Use --include-revoked to show.)",
1038 revoked_count
1039 );
1040 }
1041 }
1042
1043 Ok(())
1044 }
1045 }
1046}
1047
1048fn display_dry_run_revoke_member(org: &str, member: &str, invoker_did: &str) -> Result<()> {
1049 use crate::ux::format::{JsonResponse, is_json_mode};
1050
1051 if is_json_mode() {
1052 JsonResponse::success(
1053 "org revoke-member",
1054 &serde_json::json!({
1055 "dry_run": true,
1056 "org": org,
1057 "member_did": member,
1058 "invoker_did": invoker_did,
1059 "actions": [
1060 "Create signed revocation for member",
1061 "Store revocation in Git repository",
1062 "Member will lose all org capabilities"
1063 ]
1064 }),
1065 )
1066 .print()
1067 .map_err(|e| anyhow!("{e}"))
1068 } else {
1069 let out = crate::ux::format::Output::new();
1070 out.print_info("Dry run mode ā no changes will be made");
1071 out.newline();
1072 out.println(&format!(" Org: {}", org));
1073 out.println(&format!(" Member: {}", member));
1074 out.newline();
1075 out.println("Would perform the following actions:");
1076 out.println(&format!(
1077 " 1. Create signed revocation for member {}",
1078 member
1079 ));
1080 out.println(" 2. Store revocation in Git repository");
1081 out.println(" 3. Member will lose all org capabilities");
1082 Ok(())
1083 }
1084}
1085
1086use crate::commands::executable::ExecutableCommand;
1087use crate::config::CliConfig;
1088
1089impl ExecutableCommand for OrgCommand {
1090 fn execute(&self, ctx: &CliConfig) -> Result<()> {
1091 handle_org(
1092 self.clone(),
1093 ctx.repo_path.clone(),
1094 self.overrides.identity_ref.clone(),
1095 self.overrides.identity_blob.clone(),
1096 self.overrides.attestation_prefix.clone(),
1097 self.overrides.attestation_blob.clone(),
1098 ctx.passphrase_provider.clone(),
1099 )
1100 }
1101}