// System protected objects. Items matching specific requirements
// may only have certain modifications performed.

use hashbrown::HashSet;
use std::sync::Arc;

use crate::event::{CreateEvent, DeleteEvent, ModifyEvent};
use crate::modify::Modify;
use crate::plugins::Plugin;
use crate::prelude::*;

pub struct Protected {}

// Here is the declaration of all the attrs that can be altered by
// a call on a system object. We trust they are allowed because
// schema will have checked this, and we don't allow class changes!

lazy_static! {
    static ref ALLOWED_ATTRS: HashSet<Attribute> = {
        let attrs = vec![
        // Allow modification of some schema class types to allow local extension
        // of schema types.
        Attribute::Must,
        Attribute::May,
        // modification of some domain info types for local configuratiomn.
        Attribute::DomainSsid,
        Attribute::DomainLdapBasedn,
        Attribute::LdapMaxQueryableAttrs,
        Attribute::LdapAllowUnixPwBind,
        Attribute::FernetPrivateKeyStr,
        Attribute::Es256PrivateKeyDer,
        Attribute::KeyActionRevoke,
        Attribute::KeyActionRotate,
        Attribute::IdVerificationEcKey,
        Attribute::BadlistPassword,
        Attribute::DeniedName,
        Attribute::DomainDisplayName,
        Attribute::Image,
        // modification of account policy values for dyngroup.
        Attribute::AuthSessionExpiry,
        Attribute::AuthPasswordMinimumLength,
        Attribute::CredentialTypeMinimum,
        Attribute::PrivilegeExpiry,
        Attribute::WebauthnAttestationCaList,
        Attribute::LimitSearchMaxResults,
        Attribute::LimitSearchMaxFilterTest,
        Attribute::AllowPrimaryCredFallback,
        ];

        let mut m = HashSet::with_capacity(attrs.len());
        m.extend(attrs);

        m
    };

    static ref PROTECTED_ENTRYCLASSES: Vec<EntryClass> =
        vec![
            EntryClass::System,
            EntryClass::DomainInfo,
            EntryClass::SystemInfo,
            EntryClass::SystemConfig,
            EntryClass::DynGroup,
            EntryClass::SyncObject,
            EntryClass::Tombstone,
            EntryClass::Recycled,
        ];
}

impl Plugin for Protected {
    fn id() -> &'static str {
        "plugin_protected"
    }

    #[instrument(level = "debug", name = "protected_pre_create", skip_all)]
    fn pre_create(
        _qs: &mut QueryServerWriteTransaction,
        // List of what we will commit that is valid?
        cand: &[Entry<EntrySealed, EntryNew>],
        ce: &CreateEvent,
    ) -> Result<(), OperationError> {
        if ce.ident.is_internal() {
            trace!("Internal operation, not enforcing system object protection");
            return Ok(());
        }

        cand.iter().try_fold((), |(), cand| {
            if PROTECTED_ENTRYCLASSES
                .iter()
                .any(|c| cand.attribute_equality(Attribute::Class, &c.to_partialvalue()))
            {
                trace!("Rejecting operation during pre_create check");
                Err(OperationError::SystemProtectedObject)
            } else {
                Ok(())
            }
        })
    }

    #[instrument(level = "debug", name = "protected_pre_modify", skip_all)]
    fn pre_modify(
        _qs: &mut QueryServerWriteTransaction,
        _pre_cand: &[Arc<EntrySealedCommitted>],
        cand: &mut Vec<EntryInvalidCommitted>,
        me: &ModifyEvent,
    ) -> Result<(), OperationError> {
        if me.ident.is_internal() {
            trace!("Internal operation, not enforcing system object protection");
            return Ok(());
        }
        // Prevent adding class: system, domain_info, tombstone, or recycled.
        me.modlist.iter().try_fold((), |(), m| match m {
            Modify::Present(a, v) => {
                if a == Attribute::Class.as_ref()
                    && PROTECTED_ENTRYCLASSES.iter().any(|c| v == &c.to_value())
                {
                    trace!("Rejecting operation during pre_modify check");
                    Err(OperationError::SystemProtectedObject)
                } else {
                    Ok(())
                }
            }
            _ => Ok(()),
        })?;

        // HARD block mods on tombstone or recycle. We soft block on the rest as they may
        // have some allowed attrs.
        cand.iter().try_fold((), |(), cand| {
            if cand.attribute_equality(Attribute::Class, &EntryClass::Tombstone.into())
                || cand.attribute_equality(Attribute::Class, &EntryClass::Recycled.into())
            {
                Err(OperationError::SystemProtectedObject)
            } else {
                Ok(())
            }
        })?;

        // if class: system, check the mods are "allowed"
        let system_pres = cand.iter().any(|c| {
            // We don't need to check for domain info here because domain_info has a class
            // system also. We just need to block it from being created.
            c.attribute_equality(Attribute::Class, &EntryClass::System.into())
        });

        trace!("class: system -> {}", system_pres);
        // No system types being altered, return.
        if !system_pres {
            return Ok(());
        }

        // Something altered is system, check if it's allowed.
        me.modlist.into_iter().try_fold((), |(), m| {
            // Already hit an error, move on.
            let a = match m {
                Modify::Present(a, _)
                | Modify::Removed(a, _)
                | Modify::Set(a, _)
                | Modify::Purged(a) => Some(a),
                Modify::Assert(_, _) => None,
            };
            if let Some(attr) = a {
                match ALLOWED_ATTRS.contains(attr) {
                    true => Ok(()),
                    false => {
                        trace!("If you're getting this, you need to modify the ALLOWED_ATTRS list");
                        Err(OperationError::SystemProtectedObject)
                    }
                }
            } else {
                // Was not a mod needing checking
                Ok(())
            }
        })
    }

    #[instrument(level = "debug", name = "protected_pre_batch_modify", skip_all)]
    fn pre_batch_modify(
        _qs: &mut QueryServerWriteTransaction,
        _pre_cand: &[Arc<EntrySealedCommitted>],
        cand: &mut Vec<EntryInvalidCommitted>,
        me: &BatchModifyEvent,
    ) -> Result<(), OperationError> {
        if me.ident.is_internal() {
            trace!("Internal operation, not enforcing system object protection");
            return Ok(());
        }

        me.modset
            .values()
            .flat_map(|ml| ml.iter())
            .try_fold((), |(), m| match m {
                Modify::Present(a, v) => {
                    if a == Attribute::Class.as_ref()
                        && PROTECTED_ENTRYCLASSES.iter().any(|c| v == &c.to_value())
                    {
                        trace!("Rejecting operation during pre_batch_modify check");
                        Err(OperationError::SystemProtectedObject)
                    } else {
                        Ok(())
                    }
                }
                _ => Ok(()),
            })?;

        // HARD block mods on tombstone or recycle. We soft block on the rest as they may
        // have some allowed attrs.
        cand.iter().try_fold((), |(), cand| {
            if cand.attribute_equality(Attribute::Class, &EntryClass::Tombstone.into())
                || cand.attribute_equality(Attribute::Class, &EntryClass::Recycled.into())
            {
                Err(OperationError::SystemProtectedObject)
            } else {
                Ok(())
            }
        })?;

        // if class: system, check the mods are "allowed"
        let system_pres = cand.iter().any(|c| {
            // We don't need to check for domain info here because domain_info has a class
            // system also. We just need to block it from being created.
            c.attribute_equality(Attribute::Class, &EntryClass::System.into())
        });

        trace!("{}: system -> {}", Attribute::Class, system_pres);
        // No system types being altered, return.
        if !system_pres {
            return Ok(());
        }

        // Something altered is system, check if it's allowed.
        me.modset
            .values()
            .flat_map(|ml| ml.iter())
            .try_fold((), |(), m| {
                // Already hit an error, move on.
                let a = match m {
                    Modify::Present(a, _) | Modify::Removed(a, _) | Modify::Set(a, _) | Modify::Purged(a) => Some(a),
                    Modify::Assert(_, _) => None,
                };
                if let Some(attr) = a {
                    match ALLOWED_ATTRS.contains(attr) {
                        true => Ok(()),
                        false => {

                        trace!("Rejecting operation during pre_batch_modify check, if you're getting this check ALLOWED_ATTRS");
                            Err(OperationError::SystemProtectedObject)
                        },
                    }
                } else {
                    // Was not a mod needing checking
                    Ok(())
                }
            })
    }

    #[instrument(level = "debug", name = "protected_pre_delete", skip_all)]
    fn pre_delete(
        _qs: &mut QueryServerWriteTransaction,
        // Should these be EntrySealed
        cand: &mut Vec<Entry<EntryInvalid, EntryCommitted>>,
        de: &DeleteEvent,
    ) -> Result<(), OperationError> {
        if de.ident.is_internal() {
            trace!("Internal operation, not enforcing system object protection");
            return Ok(());
        }

        cand.iter().try_fold((), |(), cand| {
            if PROTECTED_ENTRYCLASSES
                .iter()
                .any(|c| cand.attribute_equality(Attribute::Class, &c.to_partialvalue()))
            {
                trace!("Rejecting operation during pre_delete check");
                Err(OperationError::SystemProtectedObject)
            } else {
                Ok(())
            }
        })
    }
}

#[cfg(test)]
mod tests {
    use crate::prelude::*;
    use std::sync::Arc;

    const UUID_TEST_ACCOUNT: Uuid = uuid::uuid!("cc8e95b4-c24f-4d68-ba54-8bed76f63930");
    const UUID_TEST_GROUP: Uuid = uuid::uuid!("81ec1640-3637-4a2f-8a52-874fa3c3c92f");
    const UUID_TEST_ACP: Uuid = uuid::uuid!("acae81d6-5ea7-4bd8-8f7f-fcec4c0dd647");

    lazy_static! {
        pub static ref TEST_ACCOUNT: EntryInitNew = entry_init!(
            (Attribute::Class, EntryClass::Account.to_value()),
            (Attribute::Class, EntryClass::ServiceAccount.to_value()),
            (Attribute::Class, EntryClass::MemberOf.to_value()),
            (Attribute::Name, Value::new_iname("test_account_1")),
            (Attribute::DisplayName, Value::new_utf8s("test_account_1")),
            (Attribute::Uuid, Value::Uuid(UUID_TEST_ACCOUNT)),
            (Attribute::MemberOf, Value::Refer(UUID_TEST_GROUP))
        );
        pub static ref TEST_GROUP: EntryInitNew = entry_init!(
            (Attribute::Class, EntryClass::Group.to_value()),
            (Attribute::Name, Value::new_iname("test_group_a")),
            (Attribute::Uuid, Value::Uuid(UUID_TEST_GROUP)),
            (Attribute::Member, Value::Refer(UUID_TEST_ACCOUNT))
        );
        pub static ref ALLOW_ALL: EntryInitNew = entry_init!(
            (Attribute::Class, EntryClass::Object.to_value()),
            (
                Attribute::Class,
                EntryClass::AccessControlProfile.to_value()
            ),
            (
                Attribute::Class,
                EntryClass::AccessControlTargetScope.to_value()
            ),
            (
                Attribute::Class,
                EntryClass::AccessControlReceiverGroup.to_value()
            ),
            (Attribute::Class, EntryClass::AccessControlModify.to_value()),
            (Attribute::Class, EntryClass::AccessControlCreate.to_value()),
            (Attribute::Class, EntryClass::AccessControlDelete.to_value()),
            (Attribute::Class, EntryClass::AccessControlSearch.to_value()),
            (
                Attribute::Name,
                Value::new_iname("idm_admins_acp_allow_all_test")
            ),
            (Attribute::Uuid, Value::Uuid(UUID_TEST_ACP)),
            (Attribute::AcpReceiverGroup, Value::Refer(UUID_TEST_GROUP)),
            (
                Attribute::AcpTargetScope,
                Value::new_json_filter_s("{\"pres\":\"class\"}").expect("filter")
            ),
            (Attribute::AcpSearchAttr, Value::from(Attribute::Name)),
            (Attribute::AcpSearchAttr, Value::from(Attribute::Class)),
            (Attribute::AcpSearchAttr, Value::from(Attribute::Uuid)),
            (Attribute::AcpSearchAttr, Value::new_iutf8("classname")),
            (
                Attribute::AcpSearchAttr,
                Value::new_iutf8(Attribute::AttributeName.as_ref())
            ),
            (Attribute::AcpModifyClass, EntryClass::System.to_value()),
            (Attribute::AcpModifyClass, Value::new_iutf8("domain_info")),
            (
                Attribute::AcpModifyRemovedAttr,
                Value::from(Attribute::Class)
            ),
            (
                Attribute::AcpModifyRemovedAttr,
                Value::from(Attribute::DisplayName)
            ),
            (Attribute::AcpModifyRemovedAttr, Value::from(Attribute::May)),
            (
                Attribute::AcpModifyRemovedAttr,
                Value::from(Attribute::Must)
            ),
            (
                Attribute::AcpModifyRemovedAttr,
                Value::from(Attribute::DomainName)
            ),
            (
                Attribute::AcpModifyRemovedAttr,
                Value::from(Attribute::DomainDisplayName)
            ),
            (
                Attribute::AcpModifyRemovedAttr,
                Value::from(Attribute::DomainUuid)
            ),
            (
                Attribute::AcpModifyRemovedAttr,
                Value::from(Attribute::DomainSsid)
            ),
            (
                Attribute::AcpModifyRemovedAttr,
                Value::from(Attribute::FernetPrivateKeyStr)
            ),
            (
                Attribute::AcpModifyRemovedAttr,
                Value::from(Attribute::Es256PrivateKeyDer)
            ),
            (
                Attribute::AcpModifyRemovedAttr,
                Value::from(Attribute::PrivateCookieKey)
            ),
            (
                Attribute::AcpModifyPresentAttr,
                Value::from(Attribute::Class)
            ),
            (
                Attribute::AcpModifyPresentAttr,
                Value::from(Attribute::DisplayName)
            ),
            (Attribute::AcpModifyPresentAttr, Value::from(Attribute::May)),
            (
                Attribute::AcpModifyPresentAttr,
                Value::from(Attribute::Must)
            ),
            (
                Attribute::AcpModifyPresentAttr,
                Value::from(Attribute::DomainName)
            ),
            (
                Attribute::AcpModifyPresentAttr,
                Value::from(Attribute::DomainDisplayName)
            ),
            (
                Attribute::AcpModifyPresentAttr,
                Value::from(Attribute::DomainUuid)
            ),
            (
                Attribute::AcpModifyPresentAttr,
                Value::from(Attribute::DomainSsid)
            ),
            (
                Attribute::AcpModifyPresentAttr,
                Value::from(Attribute::FernetPrivateKeyStr)
            ),
            (
                Attribute::AcpModifyPresentAttr,
                Value::from(Attribute::Es256PrivateKeyDer)
            ),
            (
                Attribute::AcpModifyPresentAttr,
                Value::from(Attribute::PrivateCookieKey)
            ),
            (Attribute::AcpCreateClass, EntryClass::Object.to_value()),
            (Attribute::AcpCreateClass, EntryClass::Account.to_value()),
            (Attribute::AcpCreateClass, EntryClass::Person.to_value()),
            (Attribute::AcpCreateClass, EntryClass::System.to_value()),
            (Attribute::AcpCreateClass, EntryClass::DomainInfo.to_value()),
            (Attribute::AcpCreateAttr, Value::from(Attribute::Name)),
            (Attribute::AcpCreateAttr, EntryClass::Class.to_value(),),
            (
                Attribute::AcpCreateAttr,
                Value::from(Attribute::Description),
            ),
            (
                Attribute::AcpCreateAttr,
                Value::from(Attribute::DisplayName),
            ),
            (Attribute::AcpCreateAttr, Value::from(Attribute::DomainName),),
            (
                Attribute::AcpCreateAttr,
                Value::from(Attribute::DomainDisplayName)
            ),
            (Attribute::AcpCreateAttr, Value::from(Attribute::DomainUuid)),
            (Attribute::AcpCreateAttr, Value::from(Attribute::DomainSsid)),
            (Attribute::AcpCreateAttr, Value::from(Attribute::Uuid)),
            (
                Attribute::AcpCreateAttr,
                Value::from(Attribute::FernetPrivateKeyStr)
            ),
            (
                Attribute::AcpCreateAttr,
                Value::from(Attribute::Es256PrivateKeyDer)
            ),
            (
                Attribute::AcpCreateAttr,
                Value::from(Attribute::PrivateCookieKey)
            ),
            (Attribute::AcpCreateAttr, Value::from(Attribute::Version))
        );
        pub static ref PRELOAD: Vec<EntryInitNew> =
            vec![TEST_ACCOUNT.clone(), TEST_GROUP.clone(), ALLOW_ALL.clone()];
        pub static ref E_TEST_ACCOUNT: Arc<EntrySealedCommitted> =
            Arc::new(TEST_ACCOUNT.clone().into_sealed_committed());
    }

    #[test]
    fn test_pre_create_deny() {
        // Test creating with class: system is rejected.
        let e = entry_init!(
            (Attribute::Class, EntryClass::Account.to_value()),
            (Attribute::Class, EntryClass::Person.to_value()),
            (Attribute::Class, EntryClass::System.to_value()),
            (Attribute::Name, Value::new_iname("testperson")),
            (
                Attribute::DisplayName,
                Value::Utf8("testperson".to_string())
            )
        );

        let create = vec![e];
        let preload = PRELOAD.clone();

        run_create_test!(
            Err(OperationError::SystemProtectedObject),
            preload,
            create,
            Some(E_TEST_ACCOUNT.clone()),
            |_| {}
        );
    }

    #[test]
    fn test_pre_modify_system_deny() {
        // Test modify of class to a system is denied
        let e = entry_init!(
            (Attribute::Class, EntryClass::Account.to_value()),
            (Attribute::Class, EntryClass::Person.to_value()),
            (Attribute::Class, EntryClass::System.to_value()),
            (Attribute::Name, Value::new_iname("testperson")),
            (
                Attribute::DisplayName,
                Value::Utf8("testperson".to_string())
            )
        );

        let mut preload = PRELOAD.clone();
        preload.push(e);

        run_modify_test!(
            Err(OperationError::SystemProtectedObject),
            preload,
            filter!(f_eq(Attribute::Name, PartialValue::new_iname("testperson"))),
            modlist!([
                m_purge(Attribute::DisplayName),
                m_pres(Attribute::DisplayName, &Value::new_utf8s("system test")),
            ]),
            Some(E_TEST_ACCOUNT.clone()),
            |_| {},
            |_| {}
        );
    }

    #[test]
    fn test_pre_modify_class_add_deny() {
        // Show that adding a system class is denied
        // TODO: replace this with a `SchemaClass` object
        let e = entry_init!(
            (Attribute::Class, EntryClass::Object.to_value()),
            (Attribute::Class, EntryClass::ClassType.to_value()),
            (Attribute::ClassName, Value::new_iutf8("testclass")),
            (
                Attribute::Uuid,
                Value::Uuid(uuid::uuid!("66c68b2f-d02c-4243-8013-7946e40fe321"))
            ),
            (
                Attribute::Description,
                Value::Utf8("class test".to_string())
            )
        );
        let mut preload = PRELOAD.clone();
        preload.push(e);

        run_modify_test!(
            Ok(()),
            preload,
            filter!(f_eq(
                Attribute::ClassName,
                PartialValue::new_iutf8("testclass")
            )),
            modlist!([
                m_pres(Attribute::May, &Value::from(Attribute::Name)),
                m_pres(Attribute::Must, &Value::from(Attribute::Name)),
            ]),
            Some(E_TEST_ACCOUNT.clone()),
            |_| {},
            |_| {}
        );
    }

    #[test]
    fn test_pre_delete_deny() {
        // Test deleting with class: system is rejected.
        let e = entry_init!(
            (Attribute::Class, EntryClass::Account.to_value()),
            (Attribute::Class, EntryClass::Person.to_value()),
            (Attribute::Class, EntryClass::System.to_value()),
            (Attribute::Name, Value::new_iname("testperson")),
            (
                Attribute::DisplayName,
                Value::Utf8("testperson".to_string())
            )
        );

        let mut preload = PRELOAD.clone();
        preload.push(e);

        run_delete_test!(
            Err(OperationError::SystemProtectedObject),
            preload,
            filter!(f_eq(Attribute::Name, PartialValue::new_iname("testperson"))),
            Some(E_TEST_ACCOUNT.clone()),
            |_| {}
        );
    }

    #[test]
    fn test_modify_domain() {
        // Can edit *my* domain_ssid and domain_name
        // Show that adding a system class is denied
        let e = entry_init!(
            (Attribute::Class, EntryClass::DomainInfo.to_value()),
            (Attribute::Name, Value::new_iname("domain_example.net.au")),
            (Attribute::Uuid, Value::Uuid(uuid::uuid!("96fd1112-28bc-48ae-9dda-5acb4719aaba"))),
            (
                Attribute::Description,
                Value::new_utf8s("Demonstration of a remote domain's info being created for uuid generation in test_modify_domain")
            ),
            (Attribute::DomainUuid, Value::Uuid(uuid::uuid!("96fd1112-28bc-48ae-9dda-5acb4719aaba"))),
            (Attribute::DomainName, Value::new_iname("example.net.au")),
            (Attribute::DomainDisplayName, Value::Utf8("example.net.au".to_string())),
            (Attribute::DomainSsid, Value::Utf8("Example_Wifi".to_string())),
            (Attribute::Version, Value::Uint32(1))
        );

        let mut preload = PRELOAD.clone();
        preload.push(e);

        run_modify_test!(
            Ok(()),
            preload,
            filter!(f_eq(
                Attribute::Name,
                PartialValue::new_iname("domain_example.net.au")
            )),
            modlist!([
                m_purge(Attribute::DomainSsid),
                m_pres(Attribute::DomainSsid, &Value::new_utf8s("NewExampleWifi")),
            ]),
            Some(E_TEST_ACCOUNT.clone()),
            |_| {},
            |_| {}
        );
    }

    #[test]
    fn test_ext_create_domain() {
        // can not add a domain_info type - note the lack of class: system
        let e = entry_init!(
            (Attribute::Class, EntryClass::DomainInfo.to_value()),
            (Attribute::Name, Value::new_iname("domain_example.net.au")),
            (Attribute::Uuid, Value::Uuid(uuid::uuid!("96fd1112-28bc-48ae-9dda-5acb4719aaba"))),
            (
                Attribute::Description,
                Value::new_utf8s("Demonstration of a remote domain's info being created for uuid generation in test_modify_domain")
            ),
            (Attribute::DomainUuid, Value::Uuid(uuid::uuid!("96fd1112-28bc-48ae-9dda-5acb4719aaba"))),
            (Attribute::DomainName, Value::new_iname("example.net.au")),
            (Attribute::DomainDisplayName, Value::Utf8("example.net.au".to_string())),
            (Attribute::DomainSsid, Value::Utf8("Example_Wifi".to_string())),
            (Attribute::Version, Value::Uint32(1))
        );

        let create = vec![e];
        let preload = PRELOAD.clone();

        run_create_test!(
            Err(OperationError::SystemProtectedObject),
            preload,
            create,
            Some(E_TEST_ACCOUNT.clone()),
            |_| {}
        );
    }

    #[test]
    fn test_delete_domain() {
        // On the real thing we have a class: system, but to prove the point ...
        let e = entry_init!(
            (Attribute::Class, EntryClass::DomainInfo.to_value()),
            (Attribute::Name, Value::new_iname("domain_example.net.au")),
            (Attribute::Uuid, Value::Uuid(uuid::uuid!("96fd1112-28bc-48ae-9dda-5acb4719aaba"))),
            (
                Attribute::Description,
                Value::new_utf8s("Demonstration of a remote domain's info being created for uuid generation in test_modify_domain")
            ),
            (Attribute::DomainUuid, Value::Uuid(uuid::uuid!("96fd1112-28bc-48ae-9dda-5acb4719aaba"))),
            (Attribute::DomainName, Value::new_iname("example.net.au")),
            (Attribute::DomainDisplayName, Value::Utf8("example.net.au".to_string())),
            (Attribute::DomainSsid, Value::Utf8("Example_Wifi".to_string())),
            (Attribute::Version, Value::Uint32(1))
        );

        let mut preload = PRELOAD.clone();
        preload.push(e);

        run_delete_test!(
            Err(OperationError::SystemProtectedObject),
            preload,
            filter!(f_eq(
                Attribute::Name,
                PartialValue::new_iname("domain_example.net.au")
            )),
            Some(E_TEST_ACCOUNT.clone()),
            |_| {}
        );
    }
}