%% @author Marc Worrell <marc@worrell.nl>
%% @copyright 2010-2021 Marc Worrell
%% @doc Access control for Zotonic. Interfaces to modules implementing the ACL events.
%% Copyright 2010-2021 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,
maybe_allowed/3,
rsc_visible/2,
rsc_prop_visible/3,
rsc_editable/2,
rsc_deletable/2,
rsc_linkable/2,
cache_key/1,
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
]).
-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.
-spec is_allowed(action(), object(), z:context()) -> 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(Action, Object, Context) of
undefined -> false;
true -> true;
false -> false
end.
-spec maybe_allowed(action(), object(), z:context()) -> maybe_boolean().
maybe_allowed(_Action, _Object, #context{ acl = admin }) ->
true;
maybe_allowed(UpdateAction, _Object, #context{ acl_is_read_only = true }) when ?is_update_action(UpdateAction) ->
false;
maybe_allowed(_Action, _Object, #context{user_id = ?ACL_ADMIN_USER_ID}) ->
true;
maybe_allowed(Action, Object, Context) ->
z_notifier:first(#acl_is_allowed{action=Action, object=Object}, Context).
%% @doc Check if an action on a property of a resource is allowed for the current actor.
-spec is_allowed_prop(action(), object(), atom() | binary(), z:context()) -> true | false | undefined.
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_notifier:first(#acl_is_allowed_prop{action=Action, object=Object, prop=Property}, Context) of
undefined -> true; % Note, the default behaviour is different for props!
Other -> Other
end.
%% @doc Check if the resource is visible for the current user
-spec rsc_visible( m_rsc:resource(), z:context() ) -> boolean().
rsc_visible(undefined, _Context) ->
true;
rsc_visible(_Id, #context{user_id=?ACL_ADMIN_USER_ID}) ->
true;
rsc_visible(_Id, #context{acl=admin}) ->
true;
rsc_visible(Id, Context) when is_integer(Id) ->
case z_memo:is_enabled(Context) of
true ->
case z_memo:get({rsc_visible, Id}) of
undefined ->
Visible = is_allowed(view, Id, Context),
z_memo:set({rsc_visible, Id}, Visible),
Visible;
Visible ->
Visible
end;
false ->
is_allowed(view, Id, Context)
end;
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
-spec rsc_prop_visible(m_rsc:resource(), atom() | binary(), z:context()) -> 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) ->
case z_memo:is_enabled(Context) of
true ->
case z_memo:get({rsc_prop_visible, Id, Property}) of
undefined ->
Visible = is_allowed_prop(view, Id, Property, Context),
z_memo:set({rsc_prop_visible, Id, Property}, Visible),
Visible;
Visible ->
Visible
end;
false ->
is_allowed_prop(view, Id, Property, Context)
end;
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 the resource is editable by the current user
-spec rsc_editable(m_rsc:resource(), z:context()) -> boolean().
rsc_editable(undefined, _Context) ->
false;
rsc_editable(_Id, #context{ acl = admin }) ->
true;
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 the resource is deletable by the current user
-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{ acl = admin } = Context) ->
not z_convert:to_bool(m_rsc:p_no_acl(Id, <<"is_protected">>, Context));
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 the resource is connected to another resource by the current user
-spec rsc_linkable(m_rsc:resource(), z:context()) -> boolean().
rsc_linkable(undefined, _Context) ->
false;
rsc_linkable(_Id, #context{ acl = admin }) ->
true;
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}) ->
UserId.
%% @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.
-spec sudo( Fun, z:context() ) -> any()
when Fun :: { module(), atom() }
| mfa()
| fun( (z:context()) -> 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)).
-spec sudo(z:context()) -> z:context().
sudo(Context) ->
set_admin(Context).
-spec set_admin(z:context()) -> z:context().
set_admin(#context{ acl = undefined } = Context) ->
Context#context{ acl = admin, user_id = ?ACL_ADMIN_USER_ID };
set_admin(Context) ->
Context#context{ acl = admin }.
%% @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.
-spec anondo(Fun, z:context()) -> any()
when Fun :: { module(), atom() }
| mfa()
| fun( (z:context()) -> 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)).
-spec anondo( z:context() ) -> z:context().
anondo(Context) ->
set_anonymous(Context).
-spec set_anonymous( z:context() ) -> z:context().
set_anonymous(#context{ acl = undefined, user_id = undefined } = Context) ->
Context;
set_anonymous(Context) ->
Context#context{ acl = undefined, user_id = undefined }.
%% @doc Log the user with the id on, fill the acl field of the context
-spec logon(m_rsc:resource() | undefined, z:context()) -> z:context().
logon(Id, #context{ user_id = Id } = Context) ->
Context;
logon(Id, Context) ->
logon(Id, #{}, Context).
-spec logon(m_rsc:resource() | undefined, map(), z:context()) -> z:context().
logon(Id, Options, Context) ->
UserId = m_rsc:rid(Id, Context),
case z_notifier:first(#acl_logon{ id = UserId, options = Options }, Context) of
undefined ->
Context#context{
acl = undefined,
user_id = UserId
};
#context{} = NewContext ->
NewContext
end.
%% @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(m_rsc:resource_id(), z:context()) -> z:context().
logon_prefs(Id, Context) ->
logon_prefs(Id, #{}, Context).
-spec logon_prefs(m_rsc:resource_id(), map(), z:context()) -> 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
-spec logoff( z:context() ) -> z:context().
logoff(#context{ user_id = undefined, acl = undefined} = Context) ->
Context;
logoff(Context) ->
case z_notifier:first(#acl_logoff{}, Context) of
undefined -> Context#context{ user_id = undefined, acl = undefined};
#context{} = NewContext -> NewContext
end.