Skip to content

Commit 17119cb

Browse files
authored
Add admin role (#354)
* Add admin role * Fix 1 test * Fix tests * Prevent privilege escalation to admin, add test
1 parent 3a2dd2a commit 17119cb

File tree

13 files changed

+266
-132
lines changed

13 files changed

+266
-132
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
*_cache
2+
13
Pipfile
24
Pipfile.lock
35
.venv

gramps_webapi/api/resources/token.py

Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
"""Authentication endpoint blueprint."""
2121

22-
from typing import Optional, Iterable
22+
from typing import Iterable, Optional
2323

2424
from flask import abort, current_app
2525
from flask_jwt_extended import (
@@ -29,11 +29,11 @@
2929
)
3030
from webargs import fields, validate
3131

32-
from ...auth.const import CLAIM_LIMITED_SCOPE, SCOPE_CREATE_OWNER
32+
from ...auth import SQLAuth
33+
from ...auth.const import CLAIM_LIMITED_SCOPE, SCOPE_CREATE_ADMIN
3334
from ..ratelimiter import limiter
34-
from ..util import use_args
35+
from ..util import get_tree_id, use_args
3536
from . import RefreshProtectedResource, Resource
36-
from ...dbmanager import WebDbManager
3737

3838

3939
def get_tokens(
@@ -56,17 +56,6 @@ def get_tokens(
5656
}
5757

5858

59-
def get_tree_id(guid: str) -> str:
60-
"""Get the appropriate tree ID for a user."""
61-
auth_provider = current_app.config.get("AUTH_PROVIDER")
62-
tree_id = auth_provider.get_tree(guid)
63-
if not tree_id:
64-
# needed for backwards compatibility!
65-
dbmgr = WebDbManager(name=current_app.config["TREE"], create_if_missing=False)
66-
tree_id = dbmgr.dirname
67-
return tree_id
68-
69-
7059
class TokenResource(Resource):
7160
"""Resource for obtaining a JWT."""
7261

@@ -119,19 +108,19 @@ def post(self):
119108

120109

121110
class TokenCreateOwnerResource(Resource):
122-
"""Resource for getting a token that allows creating an owner account."""
111+
"""Resource for getting a token that allows creating a site owner account."""
123112

124113
@limiter.limit("1/second")
125114
def get(self):
126115
"""Get a token."""
127-
auth_provider = current_app.config.get("AUTH_PROVIDER")
128-
if auth_provider.get_all_user_details():
116+
auth_provider: SQLAuth = current_app.config.get("AUTH_PROVIDER")
117+
if auth_provider.get_all_user_details(tree=None):
129118
# users already exist!
130119
abort(405)
131120
token = create_access_token(
132-
identity="owner",
121+
identity="admin",
133122
additional_claims={
134-
CLAIM_LIMITED_SCOPE: SCOPE_CREATE_OWNER,
123+
CLAIM_LIMITED_SCOPE: SCOPE_CREATE_ADMIN,
135124
},
136125
)
137126
return {"access_token": token}

gramps_webapi/api/resources/user.py

Lines changed: 95 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -21,27 +21,36 @@
2121

2222
import datetime
2323
from gettext import gettext as _
24+
from typing import Tuple
2425

2526
from flask import abort, current_app, jsonify, render_template
2627
from flask_jwt_extended import create_access_token, get_jwt, get_jwt_identity
2728
from webargs import fields
2829

30+
from ...auth import SQLAuth
2931
from ...auth.const import (
3032
CLAIM_LIMITED_SCOPE,
33+
PERM_ADD_OTHER_TREE_USER,
3134
PERM_ADD_USER,
35+
PERM_DEL_OTHER_TREE_USER,
3236
PERM_DEL_USER,
37+
PERM_EDIT_OTHER_TREE_USER,
38+
PERM_EDIT_OTHER_TREE_USER_ROLE,
3339
PERM_EDIT_OTHER_USER,
3440
PERM_EDIT_OWN_USER,
3541
PERM_EDIT_USER_ROLE,
42+
PERM_MAKE_ADMIN,
43+
PERM_VIEW_OTHER_TREE_USER,
3644
PERM_VIEW_OTHER_USER,
45+
ROLE_ADMIN,
3746
ROLE_DISABLED,
3847
ROLE_OWNER,
3948
ROLE_UNCONFIRMED,
4049
SCOPE_CONF_EMAIL,
41-
SCOPE_CREATE_OWNER,
50+
SCOPE_CREATE_ADMIN,
4251
SCOPE_RESET_PW,
4352
)
44-
from ..auth import require_permissions
53+
from ..auth import has_permissions, require_permissions
4554
from ..ratelimiter import limiter
4655
from ..tasks import (
4756
AsyncResult,
@@ -51,52 +60,81 @@
5160
send_email_new_user,
5261
send_email_reset_password,
5362
)
54-
from ..util import use_args
63+
from ..util import get_tree_from_jwt, get_tree_id, use_args
5564
from . import LimitedScopeProtectedResource, ProtectedResource, Resource
5665

5766

5867
class UserChangeBase(ProtectedResource):
5968
"""Base class for user change endpoints."""
6069

61-
def prepare_edit(self, user_name: str):
70+
def prepare_edit(self, user_name: str) -> Tuple[SQLAuth, str, bool]:
6271
"""Cheks to do before processing the request."""
63-
auth_provider = current_app.config.get("AUTH_PROVIDER")
72+
auth_provider: SQLAuth = current_app.config.get("AUTH_PROVIDER")
6473
if user_name == "-":
6574
require_permissions([PERM_EDIT_OWN_USER])
6675
user_id = get_jwt_identity()
6776
try:
6877
user_name = auth_provider.get_name(user_id)
6978
except ValueError:
7079
abort(401)
80+
other_tree = False
7181
else:
72-
require_permissions([PERM_EDIT_OTHER_USER])
73-
return auth_provider, user_name
82+
try:
83+
user_id = auth_provider.get_guid(user_name)
84+
except ValueError():
85+
abort(404)
86+
source_tree = get_tree_from_jwt()
87+
destination_tree = get_tree_id(user_id)
88+
if source_tree == destination_tree:
89+
require_permissions([PERM_EDIT_OTHER_USER])
90+
other_tree = False
91+
else:
92+
require_permissions([PERM_EDIT_OTHER_TREE_USER])
93+
other_tree = True
94+
return auth_provider, user_name, other_tree
7495

7596

7697
class UsersResource(ProtectedResource):
7798
"""Resource for all users."""
7899

79100
def get(self):
80101
"""Get users' details."""
102+
auth_provider: SQLAuth = current_app.config.get("AUTH_PROVIDER")
103+
if has_permissions([PERM_VIEW_OTHER_TREE_USER]):
104+
# return all users from all trees
105+
return jsonify(auth_provider.get_all_user_details(tree=None)), 200
81106
require_permissions([PERM_VIEW_OTHER_USER])
82-
auth_provider = current_app.config.get("AUTH_PROVIDER")
83-
return jsonify(auth_provider.get_all_user_details()), 200
107+
tree = get_tree_from_jwt()
108+
# return only this tree's users
109+
return jsonify(auth_provider.get_all_user_details(tree=tree)), 200
84110

85111

86112
class UserResource(UserChangeBase):
87113
"""Resource for a single user."""
88114

89115
def get(self, user_name: str):
90116
"""Get a user's details."""
91-
auth_provider = current_app.config.get("AUTH_PROVIDER")
117+
auth_provider: SQLAuth = current_app.config.get("AUTH_PROVIDER")
92118
if user_name == "-":
119+
# own user
93120
user_id = get_jwt_identity()
94121
try:
95122
user_name = auth_provider.get_name(user_id)
96123
except ValueError:
97124
abort(401)
98125
else:
99126
require_permissions([PERM_VIEW_OTHER_USER])
127+
if user_name != "_" and not has_permissions([PERM_VIEW_OTHER_TREE_USER]):
128+
# check if this is our tree
129+
try:
130+
user_id = auth_provider.get_guid(user_name)
131+
except ValueError:
132+
abort(404)
133+
source_tree = get_tree_from_jwt()
134+
destination_tree = get_tree_id(user_id)
135+
if source_tree != destination_tree:
136+
# user lives in other tree, not allowed to view
137+
abort(403)
100138
details = auth_provider.get_user_details(user_name)
101139
if details is None:
102140
# user does not exist
@@ -113,9 +151,15 @@ def get(self, user_name: str):
113151
)
114152
def put(self, args, user_name: str):
115153
"""Update a user's details."""
116-
auth_provider, user_name = self.prepare_edit(user_name)
154+
auth_provider, user_name, other_tree = self.prepare_edit(user_name)
117155
if "role" in args:
118-
require_permissions([PERM_EDIT_USER_ROLE])
156+
if args["role"] >= ROLE_ADMIN:
157+
# only admins can elevate users to admins
158+
require_permissions([PERM_MAKE_ADMIN])
159+
if other_tree:
160+
require_permissions([PERM_EDIT_OTHER_TREE_USER_ROLE])
161+
else:
162+
require_permissions([PERM_EDIT_USER_ROLE])
119163
auth_provider.modify_user(
120164
name=user_name,
121165
email=args.get("email"),
@@ -130,6 +174,7 @@ def put(self, args, user_name: str):
130174
"full_name": fields.Str(required=True),
131175
"password": fields.Str(required=True),
132176
"role": fields.Int(required=True),
177+
"tree": fields.Str(required=False),
133178
},
134179
location="json",
135180
)
@@ -138,15 +183,24 @@ def post(self, args, user_name: str):
138183
if user_name == "-":
139184
# Adding a new user does not make sense for "own" user
140185
abort(404)
141-
auth_provider = current_app.config.get("AUTH_PROVIDER")
142-
require_permissions([PERM_ADD_USER])
186+
auth_provider: SQLAuth = current_app.config.get("AUTH_PROVIDER")
187+
if args["role"] >= ROLE_ADMIN:
188+
# only admins can create new admin users
189+
require_permissions([PERM_MAKE_ADMIN])
190+
tree = get_tree_from_jwt()
191+
if not args.get("tree") or tree == args.get("tree"):
192+
require_permissions([PERM_ADD_USER])
193+
else:
194+
require_permissions([PERM_ADD_OTHER_TREE_USER])
143195
try:
144196
auth_provider.add_user(
145197
name=user_name,
146198
password=args["password"],
147199
email=args["email"],
148200
fullname=args["full_name"],
149201
role=args["role"],
202+
# use posting user's tree unless explicitly specified
203+
tree=args.get("tree") or tree,
150204
)
151205
except ValueError:
152206
abort(409)
@@ -157,12 +211,19 @@ def delete(self, user_name: str):
157211
if user_name == "-":
158212
# Deleting the own user is currently not allowed
159213
abort(404)
160-
auth_provider = current_app.config.get("AUTH_PROVIDER")
161-
require_permissions([PERM_DEL_USER])
214+
auth_provider: SQLAuth = current_app.config.get("AUTH_PROVIDER")
215+
162216
try:
163-
auth_provider.delete_user(name=user_name)
217+
user_id = auth_provider.get_guid(name=user_name)
164218
except ValueError:
165219
abort(404) # user not found
220+
source_tree = get_tree_from_jwt()
221+
destination_tree = get_tree_id(user_id)
222+
if source_tree == destination_tree:
223+
require_permissions([PERM_DEL_USER])
224+
else:
225+
require_permissions([PERM_DEL_OTHER_TREE_USER])
226+
auth_provider.delete_user(name=user_name)
166227
return "", 200
167228

168229

@@ -175,6 +236,7 @@ class UserRegisterResource(Resource):
175236
"email": fields.Str(required=True),
176237
"full_name": fields.Str(required=True),
177238
"password": fields.Str(required=True),
239+
"tree": fields.Str(required=False),
178240
},
179241
location="json",
180242
)
@@ -183,16 +245,20 @@ def post(self, args, user_name: str):
183245
if user_name == "-":
184246
# Registering a new user does not make sense for "own" user
185247
abort(404)
186-
auth_provider = current_app.config.get("AUTH_PROVIDER")
187-
# do not allow registration if no admin account exists!
188-
if auth_provider.get_number_users(roles=(ROLE_OWNER,)) == 0:
248+
auth_provider: SQLAuth = current_app.config.get("AUTH_PROVIDER")
249+
# do not allow registration if no tree owner account exists!
250+
if (
251+
auth_provider.get_number_users(tree=args.get("tree"), roles=(ROLE_OWNER,))
252+
== 0
253+
):
189254
abort(405)
190255
try:
191256
auth_provider.add_user(
192257
name=user_name,
193258
password=args["password"],
194259
email=args["email"],
195260
fullname=args["full_name"],
261+
tree=args.get("tree"),
196262
role=ROLE_UNCONFIRMED,
197263
)
198264
except ValueError:
@@ -212,14 +278,15 @@ def post(self, args, user_name: str):
212278

213279

214280
class UserCreateOwnerResource(LimitedScopeProtectedResource):
215-
"""Resource for creating an owner when the user database is empty."""
281+
"""Resource for creating a site admin when the user database is empty."""
216282

217283
@limiter.limit("1/second")
218284
@use_args(
219285
{
220286
"email": fields.Str(required=True),
221287
"full_name": fields.Str(required=True),
222288
"password": fields.Str(required=True),
289+
"tree": fields.Str(required=False),
223290
},
224291
location="json",
225292
)
@@ -228,20 +295,21 @@ def post(self, args, user_name: str):
228295
if user_name == "-":
229296
# User name - is not allowed
230297
abort(404)
231-
auth_provider = current_app.config.get("AUTH_PROVIDER")
298+
auth_provider: SQLAuth = current_app.config.get("AUTH_PROVIDER")
232299
if auth_provider.get_number_users() > 0:
233300
# there is already a user in the user DB
234301
abort(405)
235302
claims = get_jwt()
236-
if claims[CLAIM_LIMITED_SCOPE] != SCOPE_CREATE_OWNER:
303+
if claims[CLAIM_LIMITED_SCOPE] != SCOPE_CREATE_ADMIN:
237304
# This is a wrong token!
238305
abort(403)
239306
auth_provider.add_user(
240307
name=user_name,
241308
password=args["password"],
242309
email=args["email"],
243310
fullname=args["full_name"],
244-
role=ROLE_OWNER,
311+
tree=args.get("tree"),
312+
role=ROLE_ADMIN,
245313
)
246314
return "", 201
247315

@@ -258,7 +326,7 @@ class UserChangePasswordResource(UserChangeBase):
258326
)
259327
def post(self, args, user_name: str):
260328
"""Post new password."""
261-
auth_provider, user_name = self.prepare_edit(user_name)
329+
auth_provider, user_name, _ = self.prepare_edit(user_name)
262330
if len(args["new_password"]) == "":
263331
abort(400)
264332
if not auth_provider.authorized(user_name, args["old_password"]):
@@ -377,11 +445,13 @@ def get(self):
377445
if current_details["role"] == ROLE_UNCONFIRMED:
378446
# otherwise it has been confirmed already
379447
auth_provider.modify_user(name=username, role=ROLE_DISABLED)
448+
tree = get_tree_from_jwt()
380449
run_task(
381450
send_email_new_user,
382451
username=username,
383452
fullname=current_details.get("full_name", ""),
384453
email=claims["email"],
454+
tree=tree,
385455
)
386456
title = _("E-mail address confirmation")
387457
message = _("Thank you for confirming your e-mail address.")

gramps_webapi/api/tasks.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,15 +72,15 @@ def send_email_confirm_email(email: str, token: str):
7272

7373

7474
@shared_task()
75-
def send_email_new_user(username: str, fullname: str, email: str):
75+
def send_email_new_user(username: str, fullname: str, email: str, tree: str):
7676
"""Send an email to owners to notify of a new registered user."""
7777
base_url = get_config("BASE_URL").rstrip("/")
7878
body = email_new_user(
7979
base_url=base_url, username=username, fullname=fullname, email=email
8080
)
8181
subject = _("New registered user")
8282
auth = current_app.config.get("AUTH_PROVIDER")
83-
emails = auth.get_owner_emails()
83+
emails = auth.get_owner_emails(tree=tree)
8484
if emails:
8585
send_email(subject=subject, body=body, to=emails)
8686

0 commit comments

Comments
 (0)