| 1 |
|
-module(shurbej_admin). |
| 2 |
|
-include_lib("shurbej_store/include/shurbej_records.hrl"). |
| 3 |
|
-include_lib("stdlib/include/ms_transform.hrl"). |
| 4 |
|
|
| 5 |
|
-export([ |
| 6 |
|
%% Users |
| 7 |
|
create_user/2, create_user/3, list_users/0, delete_user/1, |
| 8 |
|
%% API keys |
| 9 |
|
create_api_key/2, create_api_key/3, |
| 10 |
|
%% Groups |
| 11 |
|
create_group/3, create_group/4, delete_group/1, list_groups/0, |
| 12 |
|
add_member/3, remove_member/2, list_members/1, list_user_groups/1 |
| 13 |
|
]). |
| 14 |
|
|
| 15 |
|
%% =================================================================== |
| 16 |
|
%% Users |
| 17 |
|
%% =================================================================== |
| 18 |
|
|
| 19 |
|
create_user(Username, Password) when is_binary(Username), is_binary(Password) -> |
| 20 |
|
%% Allocate user_id and insert in one transaction so two concurrent |
| 21 |
|
%% admin calls can't race into the same id. |
| 22 |
38 |
Result = mnesia:transaction(fun() -> |
| 23 |
38 |
case mnesia:read(shurbej_user, Username) of |
| 24 |
:-( |
[_] -> {error, already_exists}; |
| 25 |
|
[] -> |
| 26 |
38 |
UserId = tx_next_user_id(), |
| 27 |
38 |
tx_create_user(Username, Password, UserId), |
| 28 |
38 |
{ok, UserId} |
| 29 |
|
end |
| 30 |
|
end), |
| 31 |
38 |
case Result of |
| 32 |
|
{atomic, {ok, UserId}} -> |
| 33 |
38 |
logger:notice("created user ~s (user_id=~p)", [Username, UserId]), |
| 34 |
38 |
ok; |
| 35 |
|
{atomic, {error, _} = Err} -> |
| 36 |
:-( |
Err |
| 37 |
|
end. |
| 38 |
|
|
| 39 |
|
create_user(Username, Password, UserId) when is_binary(Username), is_binary(Password) -> |
| 40 |
3 |
Result = mnesia:transaction(fun() -> |
| 41 |
3 |
case mnesia:read(shurbej_user, Username) of |
| 42 |
1 |
[_] -> {error, already_exists}; |
| 43 |
|
[] -> |
| 44 |
2 |
tx_create_user(Username, Password, UserId), |
| 45 |
2 |
ok |
| 46 |
|
end |
| 47 |
|
end), |
| 48 |
3 |
case Result of |
| 49 |
|
{atomic, ok} -> |
| 50 |
2 |
logger:notice("created user ~s (user_id=~p)", [Username, UserId]), |
| 51 |
2 |
ok; |
| 52 |
|
{atomic, {error, _} = Err} -> |
| 53 |
1 |
Err |
| 54 |
|
end. |
| 55 |
|
|
| 56 |
|
%% Mirror shurbej_db:create_user's writes without its own transaction wrapper |
| 57 |
|
%% so the caller's transaction stays single-level. |
| 58 |
|
tx_create_user(Username, Password, UserId) -> |
| 59 |
40 |
Salt = crypto:strong_rand_bytes(16), |
| 60 |
40 |
Hash = shurbej_db:hash_password(Password, Salt), |
| 61 |
40 |
mnesia:write(#shurbej_user{ |
| 62 |
|
username = Username, |
| 63 |
|
password_hash = Hash, |
| 64 |
|
salt = Salt, |
| 65 |
|
user_id = UserId |
| 66 |
|
}), |
| 67 |
40 |
LibRef = {user, UserId}, |
| 68 |
40 |
case mnesia:read(shurbej_library, LibRef) of |
| 69 |
40 |
[] -> mnesia:write(#shurbej_library{ref = LibRef, version = 0}); |
| 70 |
:-( |
_ -> ok |
| 71 |
|
end. |
| 72 |
|
|
| 73 |
|
tx_next_user_id() -> |
| 74 |
38 |
MS = ets:fun2ms(fun(#shurbej_user{user_id = Id}) -> Id end), |
| 75 |
38 |
case mnesia:select(shurbej_user, MS) of |
| 76 |
:-( |
[] -> 1; |
| 77 |
38 |
Ids -> lists:max(Ids) + 1 |
| 78 |
|
end. |
| 79 |
|
|
| 80 |
|
list_users() -> |
| 81 |
38 |
MS = ets:fun2ms( |
| 82 |
|
fun(#shurbej_user{username = U, user_id = Id}) -> {U, Id} end), |
| 83 |
38 |
mnesia:dirty_select(shurbej_user, MS). |
| 84 |
|
|
| 85 |
|
delete_user(Username) -> |
| 86 |
1 |
mnesia:dirty_delete({shurbej_user, Username}). |
| 87 |
|
|
| 88 |
|
|
| 89 |
|
%% =================================================================== |
| 90 |
|
%% API keys |
| 91 |
|
%% =================================================================== |
| 92 |
|
|
| 93 |
|
%% Shortcut: create_api_key(UserId, Name) — full access to the user library |
| 94 |
|
%% and every group they belong to. |
| 95 |
|
create_api_key(UserId, Name) -> |
| 96 |
1 |
create_api_key(UserId, Name, full). |
| 97 |
|
|
| 98 |
|
%% create_api_key(UserId, Name, Access) where Access is one of: |
| 99 |
|
%% full — library+write+files+notes on user, library+write on groups.all |
| 100 |
|
%% read_only — library only on user, library only on groups.all |
| 101 |
|
%% Map — canonical perms map; passes through normalize_perms. |
| 102 |
|
create_api_key(UserId, Name, Access) when is_integer(UserId), is_binary(Name) -> |
| 103 |
37 |
case shurbej_db:get_user_by_id(UserId) of |
| 104 |
1 |
undefined -> {error, user_not_found}; |
| 105 |
|
{ok, _} -> |
| 106 |
36 |
Perms = resolve_access(Access), |
| 107 |
36 |
ApiKey = generate_api_key(), |
| 108 |
36 |
shurbej_db:create_key(ApiKey, UserId, Perms), |
| 109 |
36 |
logger:notice("created API key '~s' for user_id=~p", [Name, UserId]), |
| 110 |
36 |
{ok, ApiKey} |
| 111 |
|
end. |
| 112 |
|
|
| 113 |
|
resolve_access(full) -> |
| 114 |
33 |
shurbej_http_common:normalize_perms(undefined); |
| 115 |
|
resolve_access(read_only) -> |
| 116 |
1 |
shurbej_http_common:normalize_perms(#{ |
| 117 |
|
user => #{library => true, write => false, files => false, notes => false}, |
| 118 |
|
groups => #{all => #{library => true, write => false}} |
| 119 |
|
}); |
| 120 |
|
resolve_access(Map) when is_map(Map) -> |
| 121 |
2 |
shurbej_http_common:normalize_perms(Map). |
| 122 |
|
|
| 123 |
|
generate_api_key() -> |
| 124 |
36 |
binary:encode_hex(crypto:strong_rand_bytes(32), lowercase). |
| 125 |
|
|
| 126 |
|
%% =================================================================== |
| 127 |
|
%% Groups |
| 128 |
|
%% =================================================================== |
| 129 |
|
|
| 130 |
|
%% create_group(Name, OwnerUserId, Type) — owner auto-added as member. |
| 131 |
|
create_group(Name, OwnerUserId, Type) -> |
| 132 |
3 |
create_group(Name, OwnerUserId, Type, #{}). |
| 133 |
|
|
| 134 |
|
%% Opts: description, url, library_editing, library_reading, file_editing. |
| 135 |
|
create_group(Name, OwnerUserId, Type, Opts) |
| 136 |
|
when is_binary(Name), is_integer(OwnerUserId), |
| 137 |
|
(Type =:= private orelse Type =:= public_closed |
| 138 |
|
orelse Type =:= public_open) -> |
| 139 |
16 |
create_group_1(Name, OwnerUserId, Type, Opts); |
| 140 |
|
create_group(_, _, Type, _) -> |
| 141 |
1 |
{error, {bad_type, Type}}. |
| 142 |
|
|
| 143 |
|
create_group_1(Name, OwnerUserId, Type, Opts) -> |
| 144 |
16 |
case shurbej_db:get_user_by_id(OwnerUserId) of |
| 145 |
:-( |
undefined -> {error, owner_not_found}; |
| 146 |
|
{ok, _} -> |
| 147 |
16 |
LibEd = maps:get(library_editing, Opts, members), |
| 148 |
16 |
LibRd = maps:get(library_reading, Opts, members), |
| 149 |
16 |
FileEd = maps:get(file_editing, Opts, admins), |
| 150 |
|
%% Allocate group_id and write within a single transaction so |
| 151 |
|
%% concurrent creates can't collide on the same id. |
| 152 |
16 |
{atomic, GroupId} = mnesia:transaction(fun() -> |
| 153 |
16 |
Gid = tx_next_group_id(), |
| 154 |
16 |
mnesia:write(#shurbej_group{ |
| 155 |
|
group_id = Gid, |
| 156 |
|
name = Name, |
| 157 |
|
owner_id = OwnerUserId, |
| 158 |
|
type = Type, |
| 159 |
|
description = maps:get(description, Opts, <<>>), |
| 160 |
|
url = maps:get(url, Opts, <<>>), |
| 161 |
|
has_image = false, |
| 162 |
|
library_editing = LibEd, |
| 163 |
|
library_reading = LibRd, |
| 164 |
|
file_editing = FileEd, |
| 165 |
|
created = erlang:system_time(second), |
| 166 |
|
version = 0 |
| 167 |
|
}), |
| 168 |
16 |
mnesia:write(#shurbej_group_member{ |
| 169 |
|
id = {Gid, OwnerUserId}, role = owner |
| 170 |
|
}), |
| 171 |
16 |
mnesia:write(#shurbej_library{ |
| 172 |
|
ref = {group, Gid}, version = 0 |
| 173 |
|
}), |
| 174 |
16 |
Gid |
| 175 |
|
end), |
| 176 |
16 |
logger:notice("created group ~s (group_id=~p, owner=~p)", |
| 177 |
|
[Name, GroupId, OwnerUserId]), |
| 178 |
16 |
{ok, GroupId} |
| 179 |
|
end. |
| 180 |
|
|
| 181 |
|
tx_next_group_id() -> |
| 182 |
16 |
MS = ets:fun2ms(fun(#shurbej_group{group_id = Id}) -> Id end), |
| 183 |
16 |
case mnesia:select(shurbej_group, MS) of |
| 184 |
1 |
[] -> 1; |
| 185 |
15 |
Ids -> lists:max(Ids) + 1 |
| 186 |
|
end. |
| 187 |
|
|
| 188 |
|
delete_group(GroupId) when is_integer(GroupId) -> |
| 189 |
1 |
shurbej_db:delete_group(GroupId). |
| 190 |
|
|
| 191 |
|
list_groups() -> |
| 192 |
2 |
[{G#shurbej_group.group_id, G#shurbej_group.name, G#shurbej_group.owner_id, |
| 193 |
2 |
G#shurbej_group.type} || G <- shurbej_db:list_groups()]. |
| 194 |
|
|
| 195 |
|
add_member(GroupId, UserId, Role) |
| 196 |
|
when is_integer(GroupId), is_integer(UserId), |
| 197 |
|
(Role =:= owner orelse Role =:= admin orelse Role =:= member) -> |
| 198 |
10 |
case {shurbej_db:get_group(GroupId), shurbej_db:get_user_by_id(UserId)} of |
| 199 |
1 |
{undefined, _} -> {error, group_not_found}; |
| 200 |
1 |
{_, undefined} -> {error, user_not_found}; |
| 201 |
|
{{ok, _}, {ok, _}} -> |
| 202 |
8 |
shurbej_db:add_group_member(GroupId, UserId, Role) |
| 203 |
|
end; |
| 204 |
|
add_member(_, _, Role) -> |
| 205 |
:-( |
{error, {bad_role, Role}}. |
| 206 |
|
|
| 207 |
|
remove_member(GroupId, UserId) -> |
| 208 |
1 |
shurbej_db:remove_group_member(GroupId, UserId). |
| 209 |
|
|
| 210 |
|
list_members(GroupId) -> |
| 211 |
2 |
[{U, R} || #shurbej_group_member{id = {_, U}, role = R} |
| 212 |
2 |
<- shurbej_db:list_group_members(GroupId)]. |
| 213 |
|
|
| 214 |
|
list_user_groups(UserId) -> |
| 215 |
1 |
[{G, R} || #shurbej_group_member{id = {G, _}, role = R} |
| 216 |
1 |
<- shurbej_db:list_user_groups(UserId)]. |
| 217 |
|
|