Skip to main content

auths_sdk/workflows/
org.rs

1//! Organization membership workflows: Role definitions and member sorting.
2
3use std::ops::ControlFlow;
4
5use auths_core::ports::clock::ClockProvider;
6use auths_core::ports::id::UuidProvider;
7use auths_id::ports::registry::RegistryBackend;
8use auths_verifier::Capability;
9use auths_verifier::core::ResourceId;
10pub use auths_verifier::core::Role;
11use auths_verifier::core::{Attestation, Ed25519PublicKey, Ed25519Signature};
12use auths_verifier::types::{DeviceDID, IdentityDID};
13
14use crate::error::OrgError;
15
16/// Ordering key for org member display: admin < member < readonly < unknown.
17///
18/// Args:
19/// * `role`: Optional role as stored in an attestation.
20///
21/// Usage:
22/// ```ignore
23/// members.sort_by(|a, b| member_role_order(&a.role).cmp(&member_role_order(&b.role)));
24/// ```
25pub fn member_role_order(role: &Option<Role>) -> u8 {
26    match role {
27        Some(Role::Admin) => 0,
28        Some(Role::Member) => 1,
29        Some(Role::Readonly) => 2,
30        None => 3,
31    }
32}
33
34/// Find the first org-member attestation whose device public key matches `public_key_hex`
35/// and which holds the `manage_members` capability.
36///
37/// O(n) scan — acceptable because `RegistryBackend` is frozen and the visitor
38/// short-circuits on the first match via `ControlFlow::Break`.
39///
40/// Args:
41/// * `backend`: Registry backend to query.
42/// * `org_prefix`: The KERI method-specific ID of the organization (e.g. `EOrg1234567890`).
43/// * `public_key_hex`: Hex-encoded device public key of the candidate admin.
44///
45/// Usage:
46/// ```ignore
47/// let admin = find_admin(backend, "EOrg1234567890", &pubkey_hex)?;
48/// ```
49pub(crate) fn find_admin(
50    backend: &dyn RegistryBackend,
51    org_prefix: &str,
52    public_key_hex: &str,
53) -> Result<Attestation, OrgError> {
54    let signer_bytes = hex::decode(public_key_hex)
55        .map_err(|e| OrgError::InvalidPublicKey(format!("hex decode failed: {e}")))?;
56
57    let mut found: Option<Attestation> = None;
58
59    backend
60        .visit_org_member_attestations(org_prefix, &mut |entry| {
61            if let Ok(att) = &entry.attestation
62                && att.device_public_key.as_bytes().as_slice() == signer_bytes.as_slice()
63                && !att.is_revoked()
64                && att.capabilities.contains(&Capability::manage_members())
65            {
66                found = Some(att.clone());
67                return ControlFlow::Break(());
68            }
69            ControlFlow::Continue(())
70        })
71        .map_err(|e| OrgError::Storage(e.to_string()))?;
72
73    found.ok_or_else(|| OrgError::AdminNotFound {
74        org: org_prefix.to_owned(),
75    })
76}
77
78/// Find a member's current attestation by their DID within an org.
79///
80/// O(n) scan — short-circuits on first match via `ControlFlow::Break`.
81///
82/// Args:
83/// * `backend`: Registry backend to query.
84/// * `org_prefix`: The KERI method-specific ID of the organization (e.g. `EOrg1234567890`).
85/// * `member_did`: Full DID of the member to look up (e.g. `did:key:z6Mk...`).
86///
87/// Usage:
88/// ```ignore
89/// let att = find_member(backend, "EOrg1234567890", "did:key:z6Mk...")?;
90/// ```
91pub(crate) fn find_member(
92    backend: &dyn RegistryBackend,
93    org_prefix: &str,
94    member_did: &str,
95) -> Result<Option<Attestation>, OrgError> {
96    let mut found: Option<Attestation> = None;
97
98    backend
99        .visit_org_member_attestations(org_prefix, &mut |entry| {
100            if entry.did.to_string() == member_did
101                && let Ok(att) = &entry.attestation
102            {
103                found = Some(att.clone());
104                return ControlFlow::Break(());
105            }
106            ControlFlow::Continue(())
107        })
108        .map_err(|e| OrgError::Storage(e.to_string()))?;
109
110    Ok(found)
111}
112
113// ── Parse helpers ─────────────────────────────────────────────────────────────
114
115fn parse_capabilities(raw: &[String]) -> Result<Vec<Capability>, OrgError> {
116    raw.iter()
117        .map(|s| {
118            Capability::try_from(s.clone()).map_err(|e| OrgError::InvalidCapability {
119                cap: s.clone(),
120                reason: e.to_string(),
121            })
122        })
123        .collect()
124}
125
126// ── Command structs ───────────────────────────────────────────────────────────
127
128/// Command to add a new member to an organization.
129pub struct AddMemberCommand {
130    /// KERI method-specific ID of the org (e.g. `EOrg1234567890`).
131    pub org_prefix: String,
132    /// Full DID of the member being added (e.g. `did:key:z6Mk...`).
133    pub member_did: String,
134    /// Role to assign.
135    pub role: Role,
136    /// Capability strings to grant (e.g. `["sign_commit"]`).
137    pub capabilities: Vec<String>,
138    /// Hex-encoded device public key of the signing admin.
139    pub public_key_hex: String,
140}
141
142/// Command to revoke an existing org member.
143pub struct RevokeMemberCommand {
144    /// KERI method-specific ID of the org (e.g. `EOrg1234567890`).
145    pub org_prefix: String,
146    /// Full DID of the member to revoke.
147    pub member_did: String,
148    /// Hex-encoded device public key of the signing admin.
149    pub public_key_hex: String,
150}
151
152/// Command to update the capability set of an org member.
153pub struct UpdateCapabilitiesCommand {
154    /// KERI method-specific ID of the org (e.g. `EOrg1234567890`).
155    pub org_prefix: String,
156    /// Full DID of the member whose capabilities will be updated.
157    pub member_did: String,
158    /// New capability strings (replaces existing set).
159    pub capabilities: Vec<String>,
160    /// Hex-encoded device public key of the signing admin.
161    pub public_key_hex: String,
162}
163
164// ── Workflow functions ────────────────────────────────────────────────────────
165
166/// Add a new member to an organization.
167///
168/// Verifies that the signer holds the `manage_members` capability, then
169/// stores a new org-member attestation. The attestation is intentionally
170/// unsigned (empty `identity_signature`, `device_signature`, `device_public_key`).
171///
172/// Args:
173/// * `backend`: Registry backend for storage.
174/// * `clock`: Clock provider for the attestation timestamp.
175/// * `id_provider`: UUID provider for the `rid` field.
176/// * `cmd`: Add-member command containing org prefix, member DID, role, and capabilities.
177///
178/// Usage:
179/// ```ignore
180/// let att = add_organization_member(backend, clock, uuid_provider, cmd)?;
181/// ```
182pub fn add_organization_member(
183    backend: &dyn RegistryBackend,
184    clock: &dyn ClockProvider,
185    id_provider: &dyn UuidProvider,
186    cmd: AddMemberCommand,
187) -> Result<Attestation, OrgError> {
188    let admin_att = find_admin(backend, &cmd.org_prefix, &cmd.public_key_hex)?;
189    let parsed_caps = parse_capabilities(&cmd.capabilities)?;
190
191    let member = Attestation {
192        version: 1,
193        rid: ResourceId::new(id_provider.new_id().to_string()),
194        issuer: admin_att.issuer.clone(),
195        subject: DeviceDID::new(&cmd.member_did),
196        device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]),
197        identity_signature: Ed25519Signature::empty(),
198        device_signature: Ed25519Signature::empty(),
199        revoked_at: None,
200        expires_at: None,
201        timestamp: Some(clock.now()),
202        note: None,
203        payload: None,
204        role: Some(cmd.role),
205        capabilities: parsed_caps,
206        delegated_by: Some(IdentityDID::new(admin_att.subject.to_string())),
207        signer_type: None,
208    };
209
210    backend
211        .store_org_member(&cmd.org_prefix, &member)
212        .map_err(|e| OrgError::Storage(e.to_string()))?;
213
214    Ok(member)
215}
216
217/// Revoke an existing org member.
218///
219/// Verifies that the signer holds `manage_members`, checks the member exists
220/// and is not already revoked, then sets `revoked_at` and re-stores.
221///
222/// Args:
223/// * `backend`: Registry backend for storage.
224/// * `clock`: Clock provider for the revocation timestamp.
225/// * `cmd`: Revoke-member command containing org prefix and member DID.
226///
227/// Usage:
228/// ```ignore
229/// let revoked = revoke_organization_member(backend, clock, cmd)?;
230/// ```
231pub fn revoke_organization_member(
232    backend: &dyn RegistryBackend,
233    clock: &dyn ClockProvider,
234    cmd: RevokeMemberCommand,
235) -> Result<Attestation, OrgError> {
236    find_admin(backend, &cmd.org_prefix, &cmd.public_key_hex)?;
237
238    let existing = find_member(backend, &cmd.org_prefix, &cmd.member_did)?.ok_or_else(|| {
239        OrgError::MemberNotFound {
240            org: cmd.org_prefix.clone(),
241            did: cmd.member_did.clone(),
242        }
243    })?;
244
245    if existing.is_revoked() {
246        return Err(OrgError::AlreadyRevoked {
247            did: cmd.member_did.clone(),
248        });
249    }
250
251    let now = clock.now();
252    let mut revoked = existing;
253    revoked.revoked_at = Some(now);
254    revoked.timestamp = Some(now);
255    revoked.note = Some("Revoked by admin".to_owned());
256
257    backend
258        .store_org_member(&cmd.org_prefix, &revoked)
259        .map_err(|e| OrgError::Storage(e.to_string()))?;
260
261    Ok(revoked)
262}
263
264/// Update the capability set of an org member.
265///
266/// Verifies that the signer holds `manage_members`, checks the member exists
267/// and is not revoked, replaces their capability set, and re-stores.
268///
269/// Args:
270/// * `backend`: Registry backend for storage.
271/// * `clock`: Clock provider for the update timestamp.
272/// * `cmd`: Update-capabilities command containing org prefix, member DID, and new capabilities.
273///
274/// Usage:
275/// ```ignore
276/// let updated = update_member_capabilities(backend, clock, cmd)?;
277/// ```
278pub fn update_member_capabilities(
279    backend: &dyn RegistryBackend,
280    clock: &dyn ClockProvider,
281    cmd: UpdateCapabilitiesCommand,
282) -> Result<Attestation, OrgError> {
283    find_admin(backend, &cmd.org_prefix, &cmd.public_key_hex)?;
284
285    let existing = find_member(backend, &cmd.org_prefix, &cmd.member_did)?.ok_or_else(|| {
286        OrgError::MemberNotFound {
287            org: cmd.org_prefix.clone(),
288            did: cmd.member_did.clone(),
289        }
290    })?;
291
292    if existing.is_revoked() {
293        return Err(OrgError::AlreadyRevoked {
294            did: cmd.member_did.clone(),
295        });
296    }
297
298    let parsed_caps = parse_capabilities(&cmd.capabilities)?;
299    let mut updated = existing;
300    updated.capabilities = parsed_caps;
301    updated.timestamp = Some(clock.now());
302
303    backend
304        .store_org_member(&cmd.org_prefix, &updated)
305        .map_err(|e| OrgError::Storage(e.to_string()))?;
306
307    Ok(updated)
308}