Skip to main content

isb_server/auth/
ops.rs

1//! Account operations judged against a [`Principal`]: who is in an org,
2//! invitations, API tokens, SSH keys, sessions and users. The identity
3//! endpoints ([`super::http`]) and the daemon's account tools
4//! (`member_list`, `token_create`, ...) both call these, so a rule holds
5//! the same on every surface. Answers are the JSON the endpoints return.
6//!
7//! The rules on top of each role ([`Role::permissions`]):
8//! - Nobody outside an org learns about it: its `members`, `invitations`
9//!   and `tokens` answer `404` to non-members (platform admins excepted).
10//! - Only an owner (or platform admin) touches an owner or makes one.
11//! - A workspace (its `isb_ws_` token) is nobody's account: it reaches
12//!   none of this.
13//! - **A token cannot mint tokens** ([`may_mint_tokens`]): API tokens,
14//!   workspace tokens and superadmin tokens are refused, so revoking a
15//!   leaked token always ends what it could do. New tokens come from a
16//!   browser session, an Access or tailnet identity, or the host CLI.
17
18use std::time::Duration;
19
20use serde::Deserialize;
21use serde_json::{Value, json};
22
23#[cfg(test)]
24use super::Superadmin;
25use super::agent_identities::{AgentKind, AgentWays};
26use super::{AuthError, AuthStore, Principal, PrincipalKind, Role, SuperadminSource};
27use crate::org::OrgId;
28
29type R<T> = Result<T, AuthError>;
30
31/// Refuse a workspace: account operations are for people and their tokens.
32pub fn account_holder(p: &Principal) -> R<()> {
33    if p.is_workspace() {
34        return Err(AuthError::Forbidden(
35            "a workspace token has no reach into accounts, members, invitations, tokens or keys"
36                .into(),
37        ));
38    }
39    Ok(())
40}
41
42/// Refuse a token scoped short of `admin` (and a workspace) a change to
43/// accounts, tokens, keys, invitations or members.
44pub fn may_change_accounts(p: &Principal) -> R<()> {
45    account_holder(p)?;
46    if p.restricted() {
47        return Err(AuthError::Forbidden(
48            "this token's scopes do not cover changing accounts, tokens or members (it needs admin)"
49                .into(),
50        ));
51    }
52    Ok(())
53}
54
55/// May `p` mint an API token? Not with a token of any kind: a token that
56/// could mint another would survive its own revocation through the copy,
57/// and a scope or expiry bound on the copy would not change that.
58pub fn may_mint_tokens(p: &Principal) -> R<()> {
59    if p.is_agent() {
60        return Err(AuthError::Forbidden(
61            "a tailnet or Access agent identity has no tokens of its own: create one from a \
62             signed-in browser session, or on the host with `isb token create`"
63                .into(),
64        ));
65    }
66    let by_token = match &p.kind {
67        PrincipalKind::ApiToken { .. } | PrincipalKind::Workspace { .. } => true,
68        PrincipalKind::Superadmin { source } => matches!(source, SuperadminSource::Token { .. }),
69        PrincipalKind::Session { .. } | PrincipalKind::Access | PrincipalKind::Agent { .. } => {
70            false
71        }
72    };
73    if by_token {
74        return Err(AuthError::Forbidden(
75            "a token cannot mint tokens: create one from a signed-in browser session (Account, \
76             API tokens), or on the host with `isb token create`"
77                .into(),
78        ));
79    }
80    Ok(())
81}
82
83/// The orgs that exist, as the runtime knows them (the daemon asks incus
84/// and its servers). The store's org rows anchor memberships and tokens but
85/// are not the truth: an org made or removed past the daemon (`isb org`
86/// against incus with another state directory, a test) leaves them behind.
87pub type OrgsFn = std::sync::Arc<dyn Fn() -> Result<Vec<OrgId>, String> + Send + Sync>;
88
89/// Who is calling: the user, their orgs, and how they signed in.
90///
91/// `orgs` and `memberships` name only orgs that exist (`existing`, when
92/// given): every one for a platform admin (unless the credential is an org
93/// token), else the caller's memberships among them. A membership row for
94/// an org that is gone never shows. Without `existing` (or when asking it
95/// failed) the store's org rows stand in.
96pub fn me(store: &AuthStore, p: &Principal, existing: Option<&OrgsFn>) -> R<Value> {
97    let real: Option<Vec<OrgId>> = existing.and_then(|f| match f() {
98        Ok(v) => Some(v),
99        Err(e) => {
100            eprintln!("isb serve: whoami: listing orgs: {e}; using the identity store's");
101            None
102        }
103    });
104    let exists = |o: &OrgId| real.as_ref().is_none_or(|r| r.contains(o));
105    let memberships: Vec<Value> = p
106        .orgs
107        .iter()
108        .filter(|(o, _)| exists(o))
109        .map(|(o, r)| json!({"org": o, "role": r}))
110        .collect();
111    let mut orgs: Vec<OrgId> = if p.platform_admin {
112        match &real {
113            Some(r) => r.clone(),
114            None => store.list_orgs()?,
115        }
116    } else {
117        p.orgs
118            .iter()
119            .map(|(o, _)| o.clone())
120            .filter(|o| exists(o))
121            .collect()
122    };
123    orgs.sort();
124    orgs.dedup();
125    // A superadmin: where its power comes from, and whether it has an isb
126    // account (sessions, passkeys and tokens of its own).
127    let superadmin = match &p.kind {
128        PrincipalKind::Superadmin { source } => json!({
129            "source": source.label(),
130            "via": source,
131            "account": p.user.id > 0,
132        }),
133        _ => Value::Null,
134    };
135    Ok(json!({
136        "user": p.user,
137        "platform_admin": p.platform_admin,
138        "memberships": memberships,
139        "orgs": orgs,
140        "auth": p.kind,
141        "superadmin": superadmin,
142    }))
143}
144
145// ---- sessions ----
146
147pub fn sessions(store: &AuthStore, p: &Principal) -> R<Value> {
148    account_holder(p)?;
149    let current = p.session_id();
150    let list: Vec<Value> = store
151        .list_sessions(p.user.id)?
152        .into_iter()
153        .map(|s| {
154            let mut v = serde_json::to_value(&s).unwrap_or_default();
155            v["current"] = json!(Some(s.id) == current);
156            v
157        })
158        .collect();
159    Ok(json!({"sessions": list}))
160}
161
162pub fn revoke_session(store: &AuthStore, p: &Principal, id: i64) -> R<()> {
163    may_change_accounts(p)?;
164    if !store.revoke_session(p.user.id, id)? {
165        return Err(AuthError::NotFound(format!("session {id}")));
166    }
167    Ok(())
168}
169
170// ---- invitations ----
171
172/// Invite `email` to `org` as `role` (default member). The answer carries
173/// the invitation token once, and a link when `public_url` is known.
174pub fn invite(
175    store: &AuthStore,
176    p: &Principal,
177    org: &OrgId,
178    email: &str,
179    role: Option<Role>,
180    public_url: Option<&str>,
181) -> R<Value> {
182    may_change_accounts(p)?;
183    let role = role.unwrap_or(Role::Member);
184    match p.max_grant(org) {
185        Some(max) if role <= max => {}
186        Some(_) => {
187            return Err(AuthError::Forbidden(format!(
188                "you cannot invite someone as {role} in org {org}"
189            )));
190        }
191        None => {
192            return Err(AuthError::Forbidden(format!(
193                "inviting to org {org} needs owner or admin"
194            )));
195        }
196    }
197    let n = store.create_invitation((p.user.id > 0).then_some(p.user.id), org, email, role)?;
198    Ok(json!({
199        "invitation": n.invitation,
200        "token": n.token,
201        "link": link(public_url, "invite", &n.token),
202    }))
203}
204
205/// `<public_url>/<page>#<token>`: the token goes in the fragment, which
206/// browsers never send to a server or put in a Referer.
207pub fn link(public_url: Option<&str>, page: &str, token: &str) -> Option<String> {
208    public_url.map(|u| format!("{}/{page}#{token}", u.trim_end_matches('/')))
209}
210
211// ---- API tokens ----
212
213/// What `POST tokens` and `token_create` take.
214#[derive(Debug, Deserialize)]
215pub struct NewToken {
216    pub name: String,
217    #[serde(default)]
218    pub org: Option<OrgId>,
219    /// `90d`, `12h`; absent or null never expires.
220    #[serde(default)]
221    pub expires: Option<String>,
222    /// `read`, `deploy`, `admin`, `tool:GLOB`; empty: the role's reach.
223    #[serde(default)]
224    pub scopes: Vec<String>,
225    /// Never honoured: refused, so nobody mistakes the token they get for
226    /// one.
227    #[serde(default)]
228    pub superadmin: bool,
229}
230
231/// The caller's own tokens (an org token sees only its org's), or only
232/// `org`'s when given.
233pub fn tokens(store: &AuthStore, p: &Principal, org: Option<&OrgId>) -> R<Value> {
234    account_holder(p)?;
235    let list = store.list_api_tokens(p.user.id)?;
236    let pinned = match &p.kind {
237        PrincipalKind::ApiToken { org: Some(o), .. } => Some(o),
238        _ => org,
239    };
240    let list: Vec<_> = match pinned {
241        Some(o) => list
242            .into_iter()
243            .filter(|t| t.org.as_ref() == Some(o))
244            .collect(),
245        None => list,
246    };
247    Ok(json!({"tokens": list}))
248}
249
250/// Mint an API token for the caller: `{token, info}`, the token shown once.
251pub fn create_token(store: &AuthStore, p: &Principal, b: NewToken) -> R<Value> {
252    // Minted on the host only, so a stolen HTTP credential (a superadmin's
253    // included) cannot mint a durable one.
254    if b.superadmin {
255        return Err(AuthError::Forbidden(
256            "superadmin tokens are minted on the host only: isb token create NAME --superadmin"
257                .into(),
258        ));
259    }
260    may_change_accounts(p)?;
261    may_mint_tokens(p)?;
262    if p.user.id <= 0 {
263        return Err(AuthError::Forbidden(
264            "a superadmin without an isb account has no tokens of its own".into(),
265        ));
266    }
267    // Judged by what the caller can reach, not what the user can.
268    match &b.org {
269        Some(o) if !(p.platform_admin || p.role_in(o).is_some()) => {
270            return Err(AuthError::Forbidden(format!(
271                "you are not a member of org {o}"
272            )));
273        }
274        None if !p.platform_admin => {
275            return Err(AuthError::Forbidden(
276                "a token without an org needs a platform admin; pass an org".into(),
277            ));
278        }
279        _ => {}
280    }
281    let expires: Option<Duration> = b
282        .expires
283        .filter(|s| !s.trim().is_empty())
284        .map(|s| crate::parse_duration(&s).map_err(AuthError::Invalid))
285        .transpose()?;
286    let t =
287        store.create_api_token_scoped(p.user.id, b.org.as_ref(), &b.name, expires, &b.scopes)?;
288    Ok(json!({"token": t.token, "info": t.info}))
289}
290
291/// Revoke token `id`: its holder's own, or any in an org the caller
292/// manages. Anything else is indistinguishable from a token that does not
293/// exist.
294pub fn revoke_token(store: &AuthStore, p: &Principal, id: i64) -> R<()> {
295    may_change_accounts(p)?;
296    let hidden = || AuthError::NotFound(format!("token {id}"));
297    let t = store.api_token(id).map_err(|e| match e {
298        AuthError::NotFound(_) => hidden(),
299        e => e,
300    })?;
301    let mine = t.user_id == p.user.id
302        && match &p.kind {
303            PrincipalKind::ApiToken { org: Some(o), .. } => t.org.as_ref() == Some(o),
304            _ => true,
305        };
306    let org_admin = t.org.as_ref().is_some_and(|o| p.can_manage_members(o));
307    if !(mine || org_admin || p.platform_admin) {
308        return Err(hidden());
309    }
310    store.revoke_api_token(id)?;
311    Ok(())
312}
313
314// ---- SSH keys ----
315
316pub fn ssh_keys(store: &AuthStore, p: &Principal) -> R<Value> {
317    account_holder(p)?;
318    let list = if p.user.id > 0 {
319        store.list_ssh_keys(p.user.id)?
320    } else {
321        Vec::new()
322    };
323    Ok(json!({"ssh_keys": list}))
324}
325
326pub fn add_ssh_key(store: &AuthStore, p: &Principal, key: &str, name: Option<&str>) -> R<Value> {
327    may_change_accounts(p)?;
328    if p.user.id <= 0 {
329        return Err(AuthError::Forbidden(
330            "a superadmin without an isb account has no SSH keys of its own".into(),
331        ));
332    }
333    let k = store.add_ssh_key(p.user.id, key, name.filter(|n| !n.trim().is_empty()))?;
334    Ok(json!({"ssh_key": k}))
335}
336
337pub fn delete_ssh_key(store: &AuthStore, p: &Principal, id: i64) -> R<()> {
338    may_change_accounts(p)?;
339    if !store.delete_ssh_key(p.user.id, id)? {
340        return Err(AuthError::NotFound(format!("SSH key {id}")));
341    }
342    Ok(())
343}
344
345// ---- org administration ----
346
347/// Members see who else is in their org; nobody else learns it exists.
348pub fn visible_org(p: &Principal, org: &OrgId) -> R<()> {
349    account_holder(p)?;
350    if p.role_in(org).is_none() && !p.platform_admin {
351        return Err(AuthError::NotFound(format!("org {org}")));
352    }
353    Ok(())
354}
355
356fn manage(p: &Principal, org: &OrgId) -> R<()> {
357    visible_org(p, org)?;
358    if p.can_manage_members(org) {
359        Ok(())
360    } else {
361        Err(AuthError::Forbidden(format!(
362            "managing org {org} needs owner or admin"
363        )))
364    }
365}
366
367pub fn members(store: &AuthStore, p: &Principal, org: &OrgId) -> R<Value> {
368    visible_org(p, org)?;
369    let list: Vec<Value> = store
370        .list_members(org)?
371        .into_iter()
372        .map(|(u, r)| {
373            let last = store.last_active(u.id)?;
374            Ok(json!({"user": u, "role": r, "last_active": last}))
375        })
376        .collect::<R<_>>()?;
377    Ok(json!({"members": list}))
378}
379
380pub fn set_role(store: &AuthStore, p: &Principal, org: &OrgId, uid: i64, role: Role) -> R<Value> {
381    manage(p, org)?;
382    may_change_accounts(p)?;
383    check_role_change(store, p, org, uid, role)?;
384    store.set_member(org, uid, role)?;
385    Ok(json!({"user_id": uid, "role": role}))
386}
387
388/// Remove `uid` from `org`: anyone may leave; removing others needs the
389/// right to manage.
390pub fn remove_member(store: &AuthStore, p: &Principal, org: &OrgId, uid: i64) -> R<()> {
391    visible_org(p, org)?;
392    may_change_accounts(p)?;
393    if uid != p.user.id {
394        manage(p, org)?;
395        check_role_change(store, p, org, uid, Role::Member)?;
396    }
397    if !store.remove_member(org, uid)? {
398        return Err(AuthError::NotFound(format!("member {uid}")));
399    }
400    Ok(())
401}
402
403pub fn invitations(store: &AuthStore, p: &Principal, org: &OrgId) -> R<Value> {
404    manage(p, org)?;
405    Ok(json!({"invitations": store.list_invitations(org)?}))
406}
407
408pub fn revoke_invitation(store: &AuthStore, p: &Principal, org: &OrgId, id: i64) -> R<()> {
409    manage(p, org)?;
410    may_change_accounts(p)?;
411    if !store.revoke_invitation(org, id)? {
412        return Err(AuthError::NotFound(format!("invitation {id}")));
413    }
414    Ok(())
415}
416
417// ---- agent identities ----
418
419/// What `PUT orgs/{org}/agent-identities` and `agent_identity_set` take.
420#[derive(Debug, Deserialize)]
421#[serde(deny_unknown_fields)]
422pub struct NewAgentIdentity {
423    pub kind: AgentKind,
424    pub subject: String,
425    pub role: Role,
426    #[serde(default)]
427    pub note: Option<String>,
428    /// Ignored: the org is the endpoint's or the tool's `org`.
429    #[serde(default)]
430    pub org: Option<String>,
431}
432
433/// The org's tailnet and Access mappings, and which front doors this
434/// server has. Any member may read.
435pub fn agent_identities(
436    store: &AuthStore,
437    p: &Principal,
438    org: &OrgId,
439    ways: &AgentWays,
440) -> R<Value> {
441    visible_org(p, org)?;
442    // Who gets in without a mapping: the other half of "can an agent sign
443    // in here". Counts for every member; the names only for those who
444    // manage the org, so a viewer learns nobody's email.
445    let names = p.can_manage_members(org);
446    let me = p.user.email.as_str();
447    let has = |list: &[String]| list.iter().any(|x| x.eq_ignore_ascii_case(me));
448    let admins: Vec<String> = store
449        .list_users()?
450        .into_iter()
451        .filter(|u| u.platform_admin && !u.disabled)
452        .map(|u| u.email)
453        .collect();
454    let who = |l: &[String]| if names { l.to_vec() } else { Vec::new() };
455    // The flags' superadmins and isb.db's, where this server can match them.
456    let (mut sa_access, mut sa_tailnet) = (
457        ways.superadmin_access.clone(),
458        ways.superadmin_tailnet.clone(),
459    );
460    for i in store.list_superadmin_identities()? {
461        let list = match i.kind {
462            AgentKind::Access if ways.access && ways.public_url.is_some() => &mut sa_access,
463            AgentKind::Tailnet if !ways.tailnet_listen.is_empty() => &mut sa_tailnet,
464            _ => continue,
465        };
466        if !list.contains(&i.value) {
467            list.push(i.value);
468        }
469    }
470    Ok(json!({
471        "identities": store.list_agent_identities(org)?,
472        "available": {
473            "tailnet_listen": ways.tailnet_listen,
474            "access": ways.access,
475            "public_url": ways.public_url,
476            "reach": {
477                "platform_admins": {"count": admins.len(), "who": who(&admins)},
478                "access_superadmins": {"count": sa_access.len(), "who": who(&sa_access), "you": has(&sa_access)},
479                "tailnet_superadmins": {"count": sa_tailnet.len(), "who": who(&sa_tailnet), "you": has(&sa_tailnet)},
480            },
481        },
482    }))
483}
484
485/// Map a tailnet login or tag, an Access email or a service token to a
486/// role (never owner, and at most the caller's own) in `org`.
487pub fn set_agent_identity(
488    store: &AuthStore,
489    p: &Principal,
490    org: &OrgId,
491    b: &NewAgentIdentity,
492) -> R<Value> {
493    manage(p, org)?;
494    may_change_accounts(p)?;
495    match p.max_grant(org) {
496        Some(max) if b.role <= max => {}
497        _ => {
498            return Err(AuthError::Forbidden(format!(
499                "you cannot map an identity to {} in org {org}",
500                b.role
501            )));
502        }
503    }
504    let by: String = p.user.email.chars().take(100).collect();
505    let i = store.set_agent_identity(
506        org,
507        b.kind,
508        &b.subject,
509        b.role,
510        b.note.as_deref().unwrap_or(""),
511        &by,
512    )?;
513    Ok(json!({"identity": i}))
514}
515
516/// Remove a mapping (owners and admins; a mapping is never an owner's).
517pub fn remove_agent_identity(store: &AuthStore, p: &Principal, org: &OrgId, id: i64) -> R<()> {
518    manage(p, org)?;
519    may_change_accounts(p)?;
520    if !store.remove_agent_identity(org, id)? {
521        return Err(AuthError::NotFound(format!("agent identity {id}")));
522    }
523    Ok(())
524}
525
526/// Every token in `org`, with who holds each: a platform admin's token in
527/// an org they are not a member of has no member row to name it.
528pub fn org_tokens(store: &AuthStore, p: &Principal, org: &OrgId) -> R<Value> {
529    manage(p, org)?;
530    let list: Vec<Value> = store
531        .list_org_api_tokens(org)?
532        .into_iter()
533        .map(|t| {
534            let u = store.user(t.user_id)?;
535            let mut v = serde_json::to_value(&t).unwrap_or_default();
536            v["user"] = json!({"id": u.id, "email": u.email, "name": u.name});
537            Ok(v)
538        })
539        .collect::<R<_>>()?;
540    Ok(json!({"tokens": list}))
541}
542
543/// Only an owner (or platform admin) touches an owner or makes one; an
544/// admin manages members and admins.
545fn check_role_change(
546    store: &AuthStore,
547    p: &Principal,
548    org: &OrgId,
549    target: i64,
550    new: Role,
551) -> R<()> {
552    let max = p.max_grant(org).unwrap_or(Role::Member);
553    let current = store
554        .memberships(target)?
555        .into_iter()
556        .find(|m| &m.org == org)
557        .map(|m| m.role);
558    if new > max || current.is_some_and(|c| c > max) {
559        return Err(AuthError::Forbidden(format!(
560            "only an owner can change an owner, or make one, in org {org}"
561        )));
562    }
563    Ok(())
564}
565
566// ---- platform administration ----
567
568fn platform_admin(p: &Principal) -> R<()> {
569    account_holder(p)?;
570    if p.platform_admin {
571        Ok(())
572    } else {
573        Err(AuthError::Forbidden("this is for platform admins".into()))
574    }
575}
576
577/// Every user, with their orgs and when they were last active.
578pub fn users(store: &AuthStore, p: &Principal) -> R<Value> {
579    platform_admin(p)?;
580    let list: Vec<Value> = store
581        .list_users()?
582        .into_iter()
583        .map(|u| {
584            let memberships = store.memberships(u.id)?;
585            let last = store.last_active(u.id)?;
586            let mut v = serde_json::to_value(&u).unwrap_or_default();
587            v["memberships"] = json!(memberships);
588            v["last_active"] = json!(last);
589            Ok(v)
590        })
591        .collect::<R<_>>()?;
592    Ok(json!({"users": list}))
593}
594
595/// What `PATCH admin/users/ID` and `user_update` change.
596#[derive(Debug, Default, Deserialize)]
597#[serde(deny_unknown_fields)]
598pub struct UserChange {
599    #[serde(default)]
600    pub disabled: Option<bool>,
601    #[serde(default)]
602    pub platform_admin: Option<bool>,
603}
604
605/// Disable or enable a user, or make or unmake a platform admin. Nobody
606/// does either to themselves, and the platform keeps an enabled admin.
607pub fn update_user(store: &AuthStore, p: &Principal, id: i64, b: &UserChange) -> R<Value> {
608    platform_admin(p)?;
609    may_change_accounts(p)?;
610    let u = store.user(id)?;
611    let demoting = b.disabled == Some(true) || b.platform_admin == Some(false);
612    if demoting && id == p.user.id {
613        return Err(AuthError::Forbidden(
614            "you cannot disable yourself or drop your own platform admin role; ask another platform admin".into(),
615        ));
616    }
617    if demoting && u.platform_admin && store.other_platform_admins(id)? == 0 {
618        return Err(AuthError::Conflict(format!(
619            "{} is the last enabled platform admin; make someone else one first",
620            u.email
621        )));
622    }
623    if let Some(a) = b.platform_admin {
624        store.set_platform_admin(id, a)?;
625    }
626    if let Some(d) = b.disabled {
627        store.set_disabled(id, d)?;
628    }
629    Ok(json!({"user": store.user(id)?}))
630}
631
632// ---- someone else's account (platform admins) ----
633//
634// What `isb token ls` and `isb key --user` do on the host, for a platform
635// admin's agent. Nothing here hands out a way into an account: making
636// users, setting passwords, minting tokens and adding keys for someone stay
637// on the host, so revoking a leaked token always ends what it did.
638
639fn admin_change(p: &Principal) -> R<()> {
640    platform_admin(p)?;
641    may_change_accounts(p)
642}
643
644/// Every API token on the platform, with who holds each.
645pub fn all_tokens(store: &AuthStore, p: &Principal) -> R<Value> {
646    platform_admin(p)?;
647    let list: Vec<Value> = store
648        .list_all_api_tokens()?
649        .into_iter()
650        .map(|t| {
651            let u = store.user(t.user_id)?;
652            let mut v = serde_json::to_value(&t).unwrap_or_default();
653            v["user"] = json!({"id": u.id, "email": u.email, "name": u.name});
654            Ok(v)
655        })
656        .collect::<R<_>>()?;
657    Ok(json!({"tokens": list}))
658}
659
660pub fn user_ssh_keys(store: &AuthStore, p: &Principal, id: i64) -> R<Value> {
661    platform_admin(p)?;
662    store.user(id)?;
663    Ok(json!({"ssh_keys": store.list_ssh_keys(id)?}))
664}
665
666pub fn delete_user_ssh_key(store: &AuthStore, p: &Principal, id: i64, key: i64) -> R<()> {
667    admin_change(p)?;
668    if !store.delete_ssh_key(id, key)? {
669        return Err(AuthError::NotFound(format!("SSH key {key}")));
670    }
671    Ok(())
672}
673
674#[cfg(test)]
675#[path = "ops_tests.rs"]
676mod tests;