kcode-k1-access 0.6.1

K1 Access Groups-aware authorization facade
Documentation
use std::{path::Path, sync::Arc};

use kcode_k1_access_driver::K1AccessDriver;
use kcode_k1_groups::K1Groups;
use kcode_k1_peering::K1Peering;
use kcode_k1_txn_ordering::K1TxnOrdering;

pub use kcode_k1_access_types::{
    AccessCheck, AccessContext, AccessId, AccessPolicy, AccessRevision, Authority,
    FilteredAuthorities, GroupId, ModelId, ProfileId, SubsystemId, Target, TxId, UserId,
    ViewerSubject,
};
pub use kcode_k1_groups::{ALL_MODELS, ALL_MODELS_MEMBER, ALL_USERS, LOCAL_MODELS, SentinelGroup};

const ACCESS_UNAVAILABLE: &str = "access is unavailable";
const ACCESS_GROUP_UNAVAILABLE: &str = "access group is unavailable";
const FILTERED_AUTHORITY: &str = "access authority is filtered";

pub struct K1Access {
    driver: K1AccessDriver,
    groups: Arc<K1Groups>,
}

impl K1Access {
    pub fn open(
        root: &Path,
        ordering: Arc<K1TxnOrdering>,
        peering: Arc<K1Peering>,
        groups: Arc<K1Groups>,
    ) -> Result<Self, String> {
        Ok(Self {
            driver: K1AccessDriver::open(root, ordering, peering)?,
            groups,
        })
    }

    pub fn create(
        &self,
        context: &AccessContext,
        target: Target,
        profile_id: ProfileId,
        policy: AccessPolicy,
    ) -> Result<AccessRevision, String> {
        require_visible_authority(context, &policy)?;
        self.driver.create(target, profile_id, policy)
    }

    /// Performs a raw trusted lookup without authorization, filtering, or concealment.
    ///
    /// This internal/public-library primitive must not be exposed directly through an
    /// untrusted or user-facing interface.
    pub fn profile_id(&self, access_id: AccessId) -> Result<Option<ProfileId>, String> {
        self.driver.profile_id(access_id)
    }

    pub fn controller_profile_ids(
        &self,
        context: &AccessContext,
        access_ids: &[AccessId],
    ) -> Result<Vec<Option<ProfileId>>, String> {
        if access_ids.is_empty() {
            return Ok(Vec::new());
        }
        let memberships = self.groups.memberships(context.user(), context.model())?;
        self.driver
            .controller_profile_ids(context, access_ids, memberships.user_groups())
    }

    pub fn replace_policy(
        &self,
        context: &AccessContext,
        access_id: AccessId,
        editors: Vec<Authority>,
        viewers: Vec<ViewerSubject>,
    ) -> Result<AccessRevision, String> {
        let memberships = self.groups.memberships(context.user(), context.model())?;
        let witness = self
            .driver
            .edit_witness(context, access_id, memberships.user_groups())?;
        let witness = witness.ok_or_else(|| ACCESS_UNAVAILABLE.to_owned())?;
        let (editors, viewers) = normalize_replacement(context.user(), editors, viewers)?;
        self.driver.replace(
            access_id,
            context.user(),
            memberships.revision(),
            witness,
            editors,
            viewers,
        )
    }

    pub fn check(
        &self,
        context: &AccessContext,
        access_id: AccessId,
        expected_subsystem: SubsystemId,
    ) -> Result<AccessCheck, String> {
        if self
            .driver
            .discovery_missing(context, access_id, expected_subsystem)?
        {
            self.driver.ensure_discovery(access_id)?;
        }
        let memberships = self.groups.memberships(context.user(), context.model())?;
        self.driver.check(
            context,
            access_id,
            expected_subsystem,
            memberships.user_groups(),
            memberships.model_groups(),
            memberships.revision(),
        )
    }

    pub fn check_many(
        &self,
        context: &AccessContext,
        access_ids: &[AccessId],
        expected_subsystem: SubsystemId,
    ) -> Result<Vec<AccessCheck>, String> {
        if access_ids.is_empty() {
            return Ok(Vec::new());
        }
        let memberships = self.groups.memberships(context.user(), context.model())?;
        self.driver.check_many(
            context,
            access_ids,
            expected_subsystem,
            memberships.user_groups(),
            memberships.model_groups(),
            memberships.revision(),
        )
    }

    pub fn resolve_visible_targets(
        &self,
        context: &AccessContext,
        targets: &[Target],
        expected_subsystem: SubsystemId,
    ) -> Result<Vec<Option<AccessId>>, String> {
        if targets.is_empty() {
            return Ok(Vec::new());
        }
        let memberships = self.groups.memberships(context.user(), context.model())?;
        self.driver.resolve_visible_targets(
            context,
            targets,
            expected_subsystem,
            memberships.user_groups(),
            memberships.model_groups(),
        )
    }

    pub fn list_user(
        &self,
        context: &AccessContext,
        expected_subsystem: SubsystemId,
    ) -> Result<Vec<AccessId>, String> {
        self.driver.discovered_for_user(context, expected_subsystem)
    }

    pub fn list_group(
        &self,
        context: &AccessContext,
        group: GroupId,
        expected_subsystem: SubsystemId,
    ) -> Result<Vec<AccessId>, String> {
        let groups = self.groups.groups_for_user(context.user())?;
        require_group(&groups, group)?;
        self.driver
            .discovered_for_group(context, group, expected_subsystem)
    }

    pub fn list_user_group_targets(
        &self,
        context: &AccessContext,
        expected_subsystem: SubsystemId,
    ) -> Result<Vec<(AccessId, Target)>, String> {
        let memberships = self.groups.memberships(context.user(), context.model())?;
        self.driver.discovered_targets_for_user_and_groups(
            context,
            memberships.user_groups(),
            expected_subsystem,
        )
    }
}

fn require_visible_authority(context: &AccessContext, policy: &AccessPolicy) -> Result<(), String> {
    if context.filter().contains(policy.authority()) {
        Err(FILTERED_AUTHORITY.to_owned())
    } else {
        Ok(())
    }
}

fn normalize_replacement(
    actor: UserId,
    mut editors: Vec<Authority>,
    mut viewers: Vec<ViewerSubject>,
) -> Result<(Vec<Authority>, Vec<ViewerSubject>), String> {
    AccessPolicy::new(Authority::User(actor), editors.clone(), viewers.clone())?;
    editors.sort_unstable();
    editors.dedup();
    viewers.sort_unstable();
    viewers.dedup();
    viewers.retain(|viewer| match viewer {
        ViewerSubject::User(user) => editors.binary_search(&Authority::User(*user)).is_err(),
        ViewerSubject::Group(group) => editors.binary_search(&Authority::Group(*group)).is_err(),
        ViewerSubject::Model(_) => true,
    });
    Ok((editors, viewers))
}

fn require_group(groups: &[GroupId], group: GroupId) -> Result<(), String> {
    if groups.contains(&group) {
        Ok(())
    } else {
        Err(ACCESS_GROUP_UNAVAILABLE.to_owned())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn txid(byte: u8) -> TxId {
        TxId::from_bytes([byte; 12])
    }

    fn user(byte: u8) -> UserId {
        UserId::from_tx_id(txid(byte))
    }

    fn group(byte: u8) -> GroupId {
        GroupId::new(txid(byte))
    }

    fn model(byte: u8) -> ModelId {
        ModelId::from_bytes([byte; 32])
    }

    #[test]
    fn filtered_create_authority_is_rejected() {
        let authority = Authority::Group(group(7));
        let context = AccessContext::new(
            user(1),
            model(2),
            FilteredAuthorities::new(vec![authority]).unwrap(),
        )
        .unwrap();
        let policy = AccessPolicy::new(authority, Vec::new(), Vec::new()).unwrap();
        assert_eq!(
            require_visible_authority(&context, &policy),
            Err(FILTERED_AUTHORITY.to_owned())
        );
    }

    #[test]
    fn replacement_lists_are_canonical_and_editor_viewers_are_removed() {
        let editor_user = user(3);
        let editor_group = group(4);
        let visible_model = model(5);
        let (editors, viewers) = normalize_replacement(
            user(1),
            vec![
                Authority::Group(editor_group),
                Authority::User(editor_user),
                Authority::Group(editor_group),
            ],
            vec![
                ViewerSubject::Model(visible_model),
                ViewerSubject::User(editor_user),
                ViewerSubject::Group(editor_group),
                ViewerSubject::Model(visible_model),
            ],
        )
        .unwrap();
        assert_eq!(
            editors,
            vec![Authority::User(editor_user), Authority::Group(editor_group)]
        );
        assert_eq!(viewers, vec![ViewerSubject::Model(visible_model)]);
    }

    #[test]
    fn replacement_rejects_sentinel_editors_and_all_models_member() {
        assert!(
            normalize_replacement(user(1), vec![Authority::Group(ALL_USERS)], Vec::new()).is_err()
        );
        assert!(
            normalize_replacement(
                user(1),
                Vec::new(),
                vec![ViewerSubject::Model(ALL_MODELS_MEMBER)]
            )
            .is_err()
        );
    }

    #[test]
    fn requested_group_requires_current_membership() {
        let requested = group(1);
        assert_eq!(require_group(&[requested], requested), Ok(()));
        assert_eq!(
            require_group(&[group(2), group(3)], requested),
            Err(ACCESS_GROUP_UNAVAILABLE.to_owned())
        );
    }
}