Skip to main content

auths_cli/commands/
org.rs

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/// CLI-level role wrapper that derives `ValueEnum` for argument parsing.
37///
38/// Converts to `auths_sdk::workflows::org::Role` at the CLI boundary.
39#[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/// The `org` subcommand, handling member authorizations.
57#[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/// Subcommands for managing authorizations issued by this identity.
67#[derive(Subcommand, Debug, Clone)]
68pub enum OrgSubcommand {
69    /// Create a new organization identity
70    #[command(visible_alias = "init")]
71    Create {
72        /// Organization name
73        #[arg(long)]
74        name: String,
75
76        /// Alias for the local signing key (auto-generated if not provided)
77        #[arg(long)]
78        local_key_alias: Option<String>,
79
80        /// Optional metadata file (if provided, merged with org metadata)
81        #[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    /// Add a member to an organization
115    AddMember {
116        /// Organization identity ID
117        #[arg(long)]
118        org: String,
119
120        /// Member identity ID to add
121        #[arg(long = "member-did", visible_alias = "member")]
122        member_did: String,
123
124        /// Role to assign (admin, member, readonly)
125        #[arg(long, value_enum)]
126        role: CliRole,
127
128        /// Override default capabilities (comma-separated)
129        #[arg(long, value_delimiter = ',')]
130        capabilities: Option<Vec<String>>,
131
132        /// Alias of the signing key in keychain
133        #[arg(long)]
134        signer_alias: Option<String>,
135
136        /// Optional note for the authorization
137        #[arg(long)]
138        note: Option<String>,
139    },
140
141    /// Revoke a member from an organization
142    RevokeMember {
143        /// Organization identity ID
144        #[arg(long)]
145        org: String,
146
147        /// Member identity ID to revoke
148        #[arg(long = "member-did", visible_alias = "member")]
149        member_did: String,
150
151        /// Reason for revocation
152        #[arg(long)]
153        note: Option<String>,
154
155        /// Alias of the signing key in keychain
156        #[arg(long)]
157        signer_alias: Option<String>,
158
159        /// Preview actions without making changes.
160        #[arg(long)]
161        dry_run: bool,
162    },
163
164    /// List members of an organization
165    ListMembers {
166        /// Organization identity ID
167        #[arg(long)]
168        org: String,
169
170        /// Include revoked members
171        #[arg(long, action = ArgAction::SetTrue)]
172        include_revoked: bool,
173    },
174}
175
176/// Handles `org` commands for issuing or revoking member authorizations.
177pub 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            // Generate a key alias if not provided
212            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            // --- Ensure Git repo exists ---
230            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            // --- Build org metadata ---
257            let mut metadata_json = serde_json::json!({
258                "type": "org",
259                "name": name,
260                "created_at": Utc::now().to_rfc3339()
261            });
262
263            // Merge with additional metadata file if provided
264            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                // Merge additional metadata (preserving type and name)
273                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            // --- Generate KERI Identity ---
291            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            // --- Create admin self-attestation ---
307            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            // Resolve the org's own public key for self-attestation
316            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, // Admin attestation doesn't expire
336            };
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, // Self-attestation, no device signature
356                admin_capabilities,
357                Some(Role::Admin),
358                None, // Root admin has no delegator
359            )
360            .context("Failed to create admin attestation")?;
361
362            // Export to Git at the org member ref path
363            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,  // The subject DID (String)
398            payload_file, // Path to the JSON payload
399            note,         // Optional note (String)
400            expires_at,   // Optional RFC3339 expiration string
401            signer_alias, // Alias of the org's signing key in keychain
402        } => {
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            // --- Resolve device public key using the custom resolver IF did:key ---
443            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, // No device signature for org attestations
473                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            // Allow both did:key and did:keri as subject input
528            let subject_device_did = DeviceDID::new(subject_did.clone());
529            let now = Utc::now();
530
531            // Look up the subject's public key from existing attestations
532            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            // Load invoker's identity and key
652            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            // Determine signer alias
660            let signer_alias = KeyAlias::new_unchecked(signer_alias.unwrap_or_else(|| {
661                // Try to derive alias from org name in identity metadata
662                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            // Verify invoker has ManageMembers capability
673            // First, load the invoker's own org attestation to check capabilities
674            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            // Find invoker's attestation for this org
679            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            // Load signer key and verify passphrase
693            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            // Resolve member's public key
713            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            // Determine capabilities: use override if provided, otherwise use role defaults
720            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            // Create the attestation
744            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, // Member attestations don't expire by default
749            };
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, // No device signature for org membership attestations
767                member_capabilities.clone(),
768                Some(role),
769                Some(invoker_did.clone()),
770            )
771            .context("Failed to create member attestation")?;
772
773            // Export to Git at the org member ref path
774            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            // Load invoker's identity and key
811            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            // Determine signer alias
819            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            // Verify invoker has ManageMembers capability
831            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            // Find invoker's attestation for this org
836            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            // Check if member exists and is not already revoked
850            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(_) => {} // Member exists and is active, proceed
871            }
872
873            if dry_run {
874                return display_dry_run_revoke_member(&org, &member, invoker_did.as_ref());
875            }
876
877            // Load signer key and verify passphrase
878            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            // Look up the member's public key from existing attestations
898            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            // Create revocation
909            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, // No expiration for revocations
920                now,
921                &signer,
922                passphrase_provider.as_ref(),
923                &signer_alias,
924            )
925            .context("Failed to create revocation")?;
926
927            // Export to Git
928            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            // Load all attestations
954            let attestation_storage = RegistryAttestationStorage::new(repo_path.clone());
955            let all_attestations = attestation_storage.load_all_attestations()?;
956
957            // Build member list with delegation info
958            #[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                // Skip if revoked and not including revoked
969                if att.is_revoked() && !include_revoked {
970                    continue;
971                }
972
973                // Skip expired attestations
974                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                // Determine tree prefix based on delegator
1007                let prefix = if delegated_by.is_none() {
1008                    "ā”œā”€ "
1009                } else {
1010                    "│  └─ "
1011                };
1012
1013                // Format capabilities
1014                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}