src/support/z_acl.erl

%% @author Marc Worrell <marc@worrell.nl>
%% @copyright 2010-2024 Marc Worrell
%% @doc Access control for Zotonic.  Interfaces to modules implementing the ACL events.
%% @end

%% Copyright 2010-2024 Marc Worrell
%%
%% Licensed under the Apache License, Version 2.0 (the "License");
%% you may not use this file except in compliance with the License.
%% You may obtain a copy of the License at
%%
%%     http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing, software
%% distributed under the License is distributed on an "AS IS" BASIS,
%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
%% See the License for the specific language governing permissions and
%% limitations under the License.

-module(z_acl).
-author("Marc Worrell <marc@worrell.nl>").

-export([is_allowed/3,
         is_allowed_prop/4,
         is_allowed_link/4,
         maybe_allowed/3,

         rsc_visible/2,
         rsc_prop_visible/3,
         rsc_editable/2,
         rsc_deletable/2,
         rsc_linkable/2,

         cache_key/1,

         user/1,
         sudo_user/1,
         user_groups/1,

         is_read_only/1,
         set_read_only/2,

         is_admin_editable/1,
         is_admin/1,
         is_sudo/1,
         sudo/1,
         sudo/2,
         anondo/1,
         anondo/2,
         logon/2,
         logon/3,
         logon_prefs/2,
         logon_prefs/3,
         logon_refresh/1,
         logoff/1,

         flush/1
        ]).

-include_lib("zotonic.hrl").

-type acl() :: list( operationrequest() ).
-type operationrequest() :: {action(), object()}.
-type action() :: use
                | admin
                | view
                | insert
                | update
                | delete
                | link
                | atom().
-type object() :: m_rsc:resource()
                | #acl_rsc{}
                | #acl_edge{}
                | #acl_media{}
                | any().
-type maybe_boolean() :: undefined
                       | boolean().

-export_type([
    acl/0,
    operationrequest/0,
    action/0,
    object/0,
    maybe_boolean/0
]).

-define(is_update_action(A), A =:= admin; A =:= insert; A =:= update; A =:= delete; A =:= link).

%% @doc Check if an action is allowed for the current actor. If the ACL is inconclusive and
%% returns 'undefined' then the action is not allowed.
-spec is_allowed(Action, Object, Context) -> IsAllowed when
    Action :: action(),
    Object :: object(),
    Context :: z:context(),
    IsAllowed :: boolean().
is_allowed(_Action, _Object, #context{ acl = admin }) ->
    true;
is_allowed(UpdateAction, _Object, #context{ acl_is_read_only = true }) when ?is_update_action(UpdateAction) ->
    false;
is_allowed(_Action, _Object, #context{ user_id=?ACL_ADMIN_USER_ID }) ->
    true;
is_allowed(link, Object, Context) ->
    is_allowed(insert, #acl_edge{subject_id=Object, predicate=relation, object_id=Object}, Context);
is_allowed(Action, Object, Context) ->
    case maybe_allowed_memo(Action, Object, Context) of
        undefined -> false;
        true -> true;
        false -> false
    end.

%% @doc Check if an action is allowed for the current actor. Can return an inconclusive answer
%% with 'undefined'. The caller has then to decide what to do.
-spec maybe_allowed(Action, Object, Context) -> MaybeIsAllowed when
    Action :: action(),
    Object :: object(),
    Context :: z:context(),
    MaybeIsAllowed :: maybe_boolean().
maybe_allowed(_Action, _Object, #context{ acl = admin }) ->
    % Sudo context
    true;
maybe_allowed(UpdateAction, _Object, #context{ acl_is_read_only = true }) when ?is_update_action(UpdateAction) ->
    % Read-only context - maybe from an OAuth token
    false;
maybe_allowed(_Action, _Object, #context{user_id = ?ACL_ADMIN_USER_ID}) ->
    % Shortcut for the admin user
    true;
maybe_allowed(Action, Object, Context) ->
    maybe_allowed_memo(Action, Object, Context).

maybe_allowed_memo(Action, Object, Context) ->
    case z_memo:get({Action, Object}, Context) of
        {ok, MaybeIsAllowed} ->
            MaybeIsAllowed;
        undefined ->
            MaybeIsAllowed = z_notifier:first(#acl_is_allowed{action=Action, object=Object}, Context),
            z_memo:set({Action, Object}, {ok, MaybeIsAllowed}, Context),
            MaybeIsAllowed
    end.

%% @doc Check if an action on a property of a resource is allowed for the current actor. If the ACL
%% is inconclusive and returns 'undefined' then the property is assumed to be visible. This is
%% different then the is_allowed for resources, where an inconclusive answer is assumed to be that
%% the resource is not visible.
-spec is_allowed_prop(Action, Object, Property, Context) -> IsAllowed when
    Action :: action(),
    Object :: object(),
    Property :: atom() | binary(),
    Context :: z:context(),
    IsAllowed :: true | false.
is_allowed_prop(_Action, _Object, _Property, #context{ acl = admin }) ->
    true;
is_allowed_prop(UpdateAction, _Object, _Property, #context{ acl_is_read_only = true }) when ?is_update_action(UpdateAction) ->
    false;
is_allowed_prop(_Action, _Object, _Property, #context{ user_id = ?ACL_ADMIN_USER_ID }) ->
    true;
is_allowed_prop(Action, Object, Property, Context) when is_atom(Property) ->
    is_allowed_prop(Action, Object, atom_to_binary(Property, utf8), Context);
is_allowed_prop(Action, Object, Property, Context) ->
    case z_memo:get({rsc_prop_visible, Action, Object, Property}, Context) of
        IsAllowed when is_boolean(IsAllowed) ->
            IsAllowed;
        undefined ->
            IsAllowed = case z_notifier:first(#acl_is_allowed_prop{ action = Action, object = Object, prop = Property }, Context) of
                undefined -> true; % Note, the default behaviour is different for props!
                true -> true;
                false -> false
            end,
            z_memo:set({rsc_prop_visible, Action, Object, Property}, IsAllowed, Context)
    end.

%% @doc Check if it is allowed to create an edge between the subject and object using the predicate.
-spec is_allowed_link(Subject, Predicate, Object, Context) -> boolean() when
    Subject :: m_rsc:resource(),
    Predicate :: m_rsc:resource(),
    Object :: m_rsc:resource(),
    Context :: z:context().
is_allowed_link(SubjectId, PredicateId, ObjectId, Context) when
    is_integer(SubjectId), is_integer(PredicateId), is_integer(ObjectId) ->
    try
        case rsc_visible(SubjectId, Context)
            andalso rsc_visible(ObjectId, Context)
            andalso rsc_visible(PredicateId, Context)
            andalso m_rsc:is_a(PredicateId, predicate, Context)
        of
            true ->
                {ok, PredName} = m_predicate:id_to_name(PredicateId, Context),
                is_allowed(
                    insert,
                    #acl_edge{
                        subject_id = SubjectId,
                        predicate = PredName,
                        object_id = ObjectId
                    },
                    Context);
            false ->
                false
        end
    catch
        error:badarg -> false
    end;
is_allowed_link(undefined, _Predicate, _ObjectId, _Context) -> false;
is_allowed_link(_Subject, undefined, _ObjectId, _Context) -> false;
is_allowed_link(_Subject, _Predicate, undefined, _Context) -> false;
is_allowed_link(Subject, Predicate, Object, Context) ->
    SubjectId = m_rsc:rid(Subject, Context),
    PredicateId = m_rsc:rid(Predicate, Context),
    ObjectId = m_rsc:rid(Object, Context),
    is_allowed_link(SubjectId, PredicateId, ObjectId, Context).

%% @doc Check if a resource is visible for the current user. Non existing
%% resources are visible.
-spec rsc_visible( m_rsc:resource(), z:context() ) -> boolean().
rsc_visible(undefined, _Context) ->
    true;
rsc_visible(Id, Context) when is_integer(Id) ->
    is_allowed(view, Id, Context);
rsc_visible(RscName, Context) ->
    case m_rsc:rid(RscName, Context) of
        undefined -> true;
        RscId -> rsc_visible(RscId, Context)
    end.


%% @doc Check if a property of the resource is visible for the current user. If the
%% resource does not exist then the peoperty is visible.
-spec rsc_prop_visible(Resource, Property, Context) -> IsVisible when
    Resource :: m_rsc:resource(),
    Property :: atom() | binary(),
    Context :: z:context(),
    IsVisible :: boolean().
rsc_prop_visible(undefined, _Property, _Context) ->
    true;
rsc_prop_visible(_Id, _Property, #context{ user_id = ?ACL_ADMIN_USER_ID }) ->
    true;
rsc_prop_visible(_Id, _Property, #context{ acl = admin }) ->
    true;
rsc_prop_visible(Id, Property, Context) when is_atom(Property) ->
    rsc_prop_visible(Id, atom_to_binary(Property, utf8), Context);
rsc_prop_visible(Id, Property, Context) when is_integer(Id) ->
    is_allowed_prop(view, Id, Property, Context);
rsc_prop_visible(RscName, Property, Context) ->
    case m_rsc:rid(RscName, Context) of
        undefined -> false;
        RscId -> rsc_prop_visible(RscId, Property, Context)
    end.

%% @doc Check if a resource can be edited by the current user. Non existing
%% resources are not editable.
-spec rsc_editable(m_rsc:resource(), z:context()) -> boolean().
rsc_editable(undefined, _Context) ->
    false;
rsc_editable(Id, Context) when is_integer(Id) ->
    is_allowed(update, Id, Context);
rsc_editable(RscName, Context) ->
    case m_rsc:rid(RscName, Context) of
        undefined -> false;
        RscId -> rsc_editable(RscId, Context)
    end.

%% @doc Check if a resource can be deleted by the current user. Non existing
%% resources are not deletable.
-spec rsc_deletable(m_rsc:resource(), z:context()) -> boolean().
rsc_deletable(undefined, _Context) ->
    false;
rsc_deletable(_Id, #context{ user_id = undefined }) ->
    false;
rsc_deletable(Id, Context) when is_integer(Id) ->
    not z_convert:to_bool(m_rsc:p_no_acl(Id, <<"is_protected">>, Context))
    andalso is_allowed(delete, Id, Context);
rsc_deletable(RscName, Context) ->
    case m_rsc:rid(RscName, Context) of
        undefined -> false;
        RscId -> rsc_deletable(RscId, Context)
    end.

%% @doc Check if an connection can be added to the resource. Returns true if
%% the ACL allows adding a 'relation' edge from the resource to itself.
-spec rsc_linkable(m_rsc:resource(), z:context()) -> boolean().
rsc_linkable(undefined, _Context) ->
    false;
rsc_linkable(Id, Context) when is_integer(Id) ->
    is_allowed(link, Id, Context);
rsc_linkable(RscName, Context) ->
    case m_rsc:rid(RscName, Context) of
        undefined -> false;
        RscId -> is_allowed(link, RscId, Context)
    end.

%% @doc Return a term that can be used as the ACL part of cache key.
-spec cache_key( z:context() ) -> { m_rsc:resource_id() | undefined, any()}.
cache_key(Context) ->
    {Context#context.user_id, Context#context.acl}.


%% @doc Return the id of the current user.
-spec user(z:context()) -> m_rsc:resource_id() | undefined.
user(#context{user_id = UserId}) when is_integer(UserId) ->
    UserId;
user(#context{}) ->
    undefined.

%% @doc Return the id of the user that originally logged in, irrespective
%% of the user that was switched to. If there is no sudo user id then the
%% current user id is returned.
-spec sudo_user(z:context()) -> m_rsc:resource_id() | undefined.
sudo_user(Context) ->
    case z_context:get(auth_options, Context) of
        #{ sudo_user_id := SUid } when is_integer(SUid) -> SUid;
        _ -> user(Context)
    end.

%% @doc Return the list of user groups the current context is member of.
-spec user_groups(z:context()) -> [ m_rsc:resource_id() ].
user_groups(Context) ->
    case z_notifier:first(#acl_user_groups{}, Context) of
        undefined -> [];
        L when is_list(L) -> L
    end.

%% @doc Check if the current access permissions are set to read-only.
%% This is an authorization option for the current z.auth cookie or
%% bearer token.
-spec is_read_only( z:context() ) -> boolean().
is_read_only(#context{ acl = admin }) ->
    % Sudo is never read only.
    false;
is_read_only(#context{ acl_is_read_only = IsReadOnly }) ->
    IsReadOnly.

%% @doc Set the current context to read only. Models can use this
%% state to prevent updates to data.
-spec set_read_only( boolean(), z:context() ) -> z:context().
set_read_only(IsReadOnly, Context) ->
    Context#context{ acl_is_read_only = IsReadOnly }.

%% @doc Check if the current context acl is set using a sudo.
-spec is_sudo( z:context() ) -> boolean().
is_sudo(#context{ acl = admin }) ->
    true;
is_sudo(_) ->
    false.

%% @doc Call a function with admin privileges. If the function is a MFA
%% then the sudo-context is appended to the argument list as the last
%% function argument.
-spec sudo(Fun, ContextOrSite) -> Value when
    Fun :: { module(), atom() }
         | mfa()
         | fun( (SudoContext) -> any() ),
    ContextOrSite :: z:context() | atom(),
    SudoContext :: z:context(),
    Value :: any().
sudo({M,F}, Context) ->
    erlang:apply(M, F, [set_admin(Context)]);
sudo({M,F,A}, Context) ->
    erlang:apply(M, F, A ++ [set_admin(Context)]);
sudo(F, Context) when is_function(F, 1) ->
    F(set_admin(Context)).

%% @doc Return a context with sudo permissions set. The user of the context
%% stays the same, except when there is ACL set, then the user is set to
%% the id of the admin user (1).
-spec sudo(ContextOrSite) -> SudoContext when
    ContextOrSite :: z:context() | atom(),
    SudoContext :: z:context().
sudo(Context) ->
    set_admin(Context).

%% @doc Return a context with sudo permissions set. The user of the context
%% stays the same, except when there is no ACL set, then the user is set to
%% the id of the admin user (1).
-spec set_admin(ContextOrSite) -> SudoContext when
    ContextOrSite :: z:context() | atom(),
    SudoContext :: z:context().
set_admin(#context{ acl = undefined } = Context) ->
    Context#context{ acl = admin, user_id = ?ACL_ADMIN_USER_ID };
set_admin(#context{ acl = admin } = Context) ->
    Context;
set_admin(#context{} = Context) ->
    Context#context{ acl = admin };
set_admin(Site) when is_atom(Site) ->
    set_admin(z_context:new(Site)).

%% @doc Check if an admin is logged on and the read only flag is not set.
%% Exception for sudo, where updates are always allowed.
-spec is_admin_editable( z:context() ) -> boolean().
is_admin_editable(#context{ acl = admin }) -> true;
is_admin_editable(#context{ acl_is_read_only = true }) -> false;
is_admin_editable(Context) -> is_admin(Context).

%% @doc Check if the current user is an admin or a sudo action
-spec is_admin( z:context() ) -> boolean().
is_admin(#context{ user_id = ?ACL_ADMIN_USER_ID }) -> true;
is_admin(#context{ acl = admin }) -> true;
is_admin(#context{ acl = undefined, user_id = undefined }) -> false;
is_admin(Context) -> is_allowed(use, mod_admin_config, Context).


%% @doc Call a function as the anonymous user. The acl and user is removed
%% from the context. If the function is a MFA then the anonymous context
%% is added as the last argument.
-spec anondo(Fun, Context) -> Value when
    Fun :: { module(), atom() }
         | mfa()
         | fun( (AnonContext) -> any() ),
    Context :: z:context(),
    AnonContext :: z:context(),
    Value :: any().
anondo({M,F}, Context) ->
    erlang:apply(M, F, [set_anonymous(Context)]);
anondo({M,F,A}, Context) ->
    erlang:apply(M, F, A ++ [set_anonymous(Context)]);
anondo(F, Context) when is_function(F, 1) ->
    F(set_anonymous(Context)).

%% @doc Make the context an anymous context by stripping the acl and user
%% from the context.
-spec anondo(Context) -> AnonContext when
    Context :: z:context(),
    AnonContext :: z:context().
anondo(Context) ->
    set_anonymous(Context).

%% @doc Make the context an anymous context by stripping the acl and user
%% from the context.
-spec set_anonymous(Context) -> AnonContext when
    Context :: z:context(),
    AnonContext :: z:context().
set_anonymous(#context{ acl = undefined, user_id = undefined } = Context) ->
    Context;
set_anonymous(Context) ->
    Context#context{ acl = undefined, user_id = undefined }.


%% @doc Set the context to the user's context, with the given user id and the
%% access permissions of the user. Note that the user's preferences
%% are not set, use logon_prefs/2 to set those.
-spec logon(User, Context) -> UserContext when
    User :: m_rsc:resource(),
    Context :: z:context(),
    UserContext :: z:context().
logon(Id, #context{ user_id = Id } = Context) ->
    Context;
logon(Id, Context) ->
    logon(Id, #{}, Context).

%% @doc Set the context to the user's context, with the given user id and the
%% access permissions of the user. The options are passed to the ACL module. Check
%% the selected ACL module(s) for supported options. Note that the user's preferences
%% are not set, use logon_prefs/3 to set those.
-spec logon(User, Options, Context) -> UserContext when
    User :: m_rsc:resource(),
    Options :: map(),
    Context :: z:context(),
    UserContext :: z:context().
logon(Id, Options, Context) ->
    UserId = m_rsc:rid(Id, Context),
    ContextUser = case z_notifier:first(#acl_logon{ id = UserId, options = Options }, Context) of
        undefined ->
            Context#context{
                acl = undefined,
                user_id = UserId
            };
        #context{} = NewContext ->
            NewContext
    end,
    z_memo:set_userid(ContextUser),
    ContextUser.

%% @doc Refresh the authentication of the current user
-spec logon_refresh(z:context()) -> z:context().
logon_refresh(#context{ user_id = Id } = Context) when is_integer(Id) ->
    logon(Id, #{}, Context);
logon_refresh(Context) ->
    Context.


%% @doc Log the user with the id on, fill acl and set all user preferences (like timezone and language)
-spec logon_prefs(User, Context) -> UserContext when
    User :: m_rsc:resource_id(),
    Context :: z:context(),
    UserContext :: z:context().
logon_prefs(Id, Context) ->
    logon_prefs(Id, #{}, Context).

%% @doc Log the user with the id on, fill acl and set all user preferences (like timezone
%% and language). The options are passed to the ACL module. Check the selected ACL module(s)
%% for supported options.
-spec logon_prefs(User, Options, Context) -> UserContext when
    User :: m_rsc:resource(),
    Options :: map(),
    Context :: z:context(),
    UserContext :: z:context().
logon_prefs(Id, Options, Context) ->
    z_notifier:foldl(#user_context{ id = Id }, logon(Id, Options, Context), Context).


%% @doc Log off, reset the acl field of the context. Call the #acl_logoff notification
%% if a user is defined. This allows the ACL module to make adjustments to the context.
-spec logoff(UserContext) -> AnonContext when
    UserContext :: z:context(),
    AnonContext :: z:context().
logoff(#context{ user_id = undefined, acl = undefined} = Context) ->
    Context;
logoff(Context) ->
    ContextNoUser = case z_notifier:first(#acl_logoff{}, Context) of
        undefined -> Context#context{ user_id = undefined, acl = undefined};
        #context{} = NewContext -> NewContext
    end,
    z_memo:set_userid(ContextNoUser),
    ContextNoUser.


%% @doc Flush the memo cache of ACL lookups for the given resource id.
-spec flush(Id) -> ok when
    Id :: m_rsc:resource_id().
flush(Id) ->
     z_memo:delete({rsc_visible, Id}),
     ok.