Skip to content

Commit 4b8b770

Browse files
authored
Allow tree owner creation (#379)
* Implement tree owner token & creation endpoints * Prevent tree owner creation for different tree in single-tree setup
1 parent ac35c4f commit 4b8b770

File tree

6 files changed

+280
-49
lines changed

6 files changed

+280
-49
lines changed

gramps_webapi/api/resources/token.py

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#
22
# Gramps Web API - A RESTful API for the Gramps genealogy program
33
#
4-
# Copyright (C) 2020 David Straub
4+
# Copyright (C) 2020-2023 David Straub
55
#
66
# This program is free software; you can redistribute it and/or modify
77
# it under the terms of the GNU Affero General Public License as published by
@@ -30,15 +30,16 @@
3030
from webargs import fields, validate
3131

3232
from ...auth import (
33+
authorized,
3334
get_all_user_details,
34-
get_permissions,
3535
get_guid,
36-
authorized,
3736
get_name,
37+
get_permissions,
3838
)
39-
from ...auth.const import CLAIM_LIMITED_SCOPE, SCOPE_CREATE_ADMIN
39+
from ...auth.const import CLAIM_LIMITED_SCOPE, SCOPE_CREATE_ADMIN, SCOPE_CREATE_OWNER
40+
from ...const import TREE_MULTI
4041
from ..ratelimiter import limiter
41-
from ..util import get_tree_id, use_args
42+
from ..util import get_tree_id, tree_exists, use_args
4243
from . import RefreshProtectedResource, Resource
4344

4445

@@ -112,11 +113,12 @@ def post(self):
112113

113114

114115
class TokenCreateOwnerResource(Resource):
115-
"""Resource for getting a token that allows creating a site owner account."""
116+
"""Resource for getting a token that allows creating a site admin or tree owner account."""
116117

117118
@limiter.limit("1/second")
118119
def get(self):
119120
"""Get a token."""
121+
# This GET method is deprecated and only kept for backward compatibility!
120122
if get_all_user_details(tree=None):
121123
# users already exist!
122124
abort(405)
@@ -127,3 +129,34 @@ def get(self):
127129
},
128130
)
129131
return {"access_token": token}
132+
133+
@limiter.limit("1/second")
134+
@use_args(
135+
{
136+
"tree": fields.Str(required=False),
137+
},
138+
location="json",
139+
)
140+
def post(self, args):
141+
"""Get a token."""
142+
tree = args.get("tree")
143+
if (
144+
tree
145+
and current_app.config["TREE"] != TREE_MULTI
146+
and tree != current_app.config["TREE"]
147+
):
148+
abort(403)
149+
if tree and not tree_exists(tree):
150+
abort(404)
151+
if get_all_user_details(tree=tree):
152+
# users already exist!
153+
abort(405)
154+
if tree:
155+
claims = {
156+
CLAIM_LIMITED_SCOPE: SCOPE_CREATE_OWNER,
157+
"tree": tree,
158+
}
159+
else:
160+
claims = {CLAIM_LIMITED_SCOPE: SCOPE_CREATE_ADMIN}
161+
token = create_access_token(identity="owner", additional_claims=claims)
162+
return {"access_token": token}, 201

gramps_webapi/api/resources/user.py

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#
22
# Gramps Web API - A RESTful API for the Gramps genealogy program
33
#
4-
# Copyright (C) 2020 David Straub
4+
# Copyright (C) 2020-2023 David Straub
55
#
66
# This program is free software; you can redistribute it and/or modify
77
# it under the terms of the GNU Affero General Public License as published by
@@ -61,6 +61,7 @@
6161
ROLE_UNCONFIRMED,
6262
SCOPE_CONF_EMAIL,
6363
SCOPE_CREATE_ADMIN,
64+
SCOPE_CREATE_OWNER,
6465
SCOPE_RESET_PW,
6566
)
6667
from ...const import TREE_MULTI
@@ -304,6 +305,12 @@ def post(self, args, user_name: str):
304305
# do not allow registration if no tree owner account exists!
305306
if get_number_users(tree=args.get("tree"), roles=(ROLE_OWNER,)) == 0:
306307
abort(405)
308+
if (
309+
"tree" in args
310+
and current_app.config["TREE"] != TREE_MULTI
311+
and args["tree"] != current_app.config["TREE"]
312+
):
313+
abort(422)
307314
if "tree" in args and not tree_exists(args["tree"]):
308315
abort(422)
309316
try:
@@ -349,24 +356,47 @@ def post(self, args, user_name: str):
349356
if user_name == "-":
350357
# User name - is not allowed
351358
abort(404)
352-
# FIXME what about multi-tree
353-
if get_number_users() > 0:
354-
# there is already a user in the user DB
355-
abort(405)
356359
claims = get_jwt()
357-
if claims[CLAIM_LIMITED_SCOPE] != SCOPE_CREATE_ADMIN:
358-
# This is a wrong token!
359-
abort(403)
360360
if "tree" in args and not tree_exists(args["tree"]):
361361
abort(422)
362-
add_user(
363-
name=user_name,
364-
password=args["password"],
365-
email=args["email"],
366-
fullname=args["full_name"],
367-
tree=args.get("tree"),
368-
role=ROLE_ADMIN,
369-
)
362+
if (
363+
"tree" in args
364+
and current_app.config["TREE"] != TREE_MULTI
365+
and args["tree"] != current_app.config["TREE"]
366+
):
367+
abort(422)
368+
if claims[CLAIM_LIMITED_SCOPE] == SCOPE_CREATE_ADMIN:
369+
if get_number_users() > 0:
370+
# there is already a user in the user DB
371+
abort(405)
372+
add_user(
373+
name=user_name,
374+
password=args["password"],
375+
email=args["email"],
376+
fullname=args["full_name"],
377+
tree=args.get("tree"),
378+
role=ROLE_ADMIN,
379+
)
380+
elif claims[CLAIM_LIMITED_SCOPE] == SCOPE_CREATE_OWNER:
381+
tree = claims["tree"]
382+
if "tree" in args and args["tree"] != tree:
383+
abort(422)
384+
if not tree_exists(tree_id=tree):
385+
abort(422)
386+
if get_number_users(tree=tree) > 0:
387+
# there is already a user in this tree
388+
abort(405)
389+
add_user(
390+
name=user_name,
391+
password=args["password"],
392+
email=args["email"],
393+
fullname=args["full_name"],
394+
tree=tree,
395+
role=ROLE_OWNER,
396+
)
397+
else:
398+
# This is a wrong token!
399+
abort(403)
370400
return "", 201
371401

372402

gramps_webapi/auth/const.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#
22
# Gramps Web API - A RESTful API for the Gramps genealogy program
33
#
4-
# Copyright (C) 2020-2022 David Straub
4+
# Copyright (C) 2020-2023 David Straub
55
#
66
# This program is free software; you can redistribute it and/or modify
77
# it under the terms of the GNU Affero General Public License as published by
@@ -117,3 +117,4 @@
117117
SCOPE_RESET_PW = "reset_password"
118118
SCOPE_CONF_EMAIL = "confirm_email"
119119
SCOPE_CREATE_ADMIN = "create_admin"
120+
SCOPE_CREATE_OWNER = "create_owner"

gramps_webapi/data/apispec.yaml

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -145,13 +145,20 @@ paths:
145145
description: "Unprocessable Entity: Invalid token."
146146

147147
/token/create_owner/:
148-
get:
148+
post:
149149
tags:
150150
- authentication
151-
summary: "Obtain a JWT access token that allows creating an owner account if no other user exists yet."
151+
summary: "Obtain a JWT access token that allows creating an admin or owner account if no other user exists yet."
152152
operationId: getTokenCreateOwner
153+
parameters:
154+
- name: tree
155+
in: body
156+
required: false
157+
type: string
158+
description: "If present, request a token for creating a tree owner. Otherwise, request a token for creating a site admin."
159+
example: 0de59650-9bc5-4ee8-957d-4c3eb0851981
153160
responses:
154-
200:
161+
201:
155162
description: "OK: Successful operation."
156163
schema:
157164
$ref: "#/definitions/JWTAccessToken"
@@ -376,27 +383,30 @@ paths:
376383
post:
377384
tags:
378385
- users
379-
summary: "Create an owner account if no other user exists yet."
386+
summary: "Create an admin or owner account if no other user exists yet."
380387
operationId: createOwner
381388
parameters:
382389
- name: user_name
383390
in: path
384391
required: true
385392
type: string
386-
description: "The user name for the owner account."
393+
description: "The user name for the account."
387394
- name: user_details
388395
in: body
389396
schema:
390397
type: object
391398
properties:
392399
email:
393-
description: "The owner's e-mail address."
400+
description: "The new user's e-mail address."
394401
type: string
395402
full_name:
396-
description: "The owner's full name."
403+
description: "The new user's full name."
397404
type: string
398405
password:
399-
description: "The owner's password."
406+
description: "The new user's password."
407+
type: string
408+
tree:
409+
description: "The new user's tree (optional)."
400410
type: string
401411
responses:
402412
201:

tests/test_endpoints/test_token.py

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#
22
# Gramps Web API - A RESTful API for the Gramps genealogy program
33
#
4-
# Copyright (C) 2020 David Straub
4+
# Copyright (C) 2020-2023 David Straub
55
#
66
# This program is free software; you can redistribute it and/or modify
77
# it under the terms of the GNU Affero General Public License as published by
@@ -22,15 +22,18 @@
2222
import unittest
2323
from unittest.mock import patch
2424

25+
from flask_jwt_extended.utils import decode_token
2526
from gramps.cli.clidbman import CLIDbManager
2627
from gramps.gen.dbstate import DbState
2728

2829
from gramps_webapi.app import create_app
2930
from gramps_webapi.auth import user_db
3031
from gramps_webapi.auth.const import ROLE_OWNER
3132
from gramps_webapi.const import ENV_CONFIG_FILE, TEST_AUTH_CONFIG
33+
from gramps_webapi.dbmanager import WebDbManager
3234

3335
from . import BASE_URL, TEST_USERS, get_test_client
36+
from .util import fetch_header
3437

3538

3639
class TestToken(unittest.TestCase):
@@ -83,6 +86,20 @@ def test_create_owner_response(self):
8386
rv = self.client.get(f"{BASE_URL}/token/create_owner/")
8487
self.assertEqual(rv.status_code, 405)
8588

89+
def test_create_owner_post_response(self):
90+
"""Test response to create_owner."""
91+
rv = self.client.post(f"{BASE_URL}/token/create_owner/")
92+
self.assertEqual(rv.status_code, 405)
93+
94+
def test_create_owner_tree_response(self):
95+
"""Test response to create_owner."""
96+
headers = fetch_header(self.client)
97+
rv = self.client.get(f"{BASE_URL}/trees/-", headers=headers)
98+
assert rv.status_code == 200
99+
tree = rv.json["id"]
100+
rv = self.client.post(f"{BASE_URL}/token/create_owner/", json={"tree": tree})
101+
self.assertEqual(rv.status_code, 405)
102+
86103

87104
class TestTokenRefresh(unittest.TestCase):
88105
"""Test cases for the /api/token/refresh endpoint."""
@@ -155,14 +172,58 @@ def setUpClass(cls):
155172
cls.client = cls.app.test_client()
156173
with cls.app.app_context():
157174
user_db.create_all()
175+
db_manager = WebDbManager(name=cls.name, create_if_missing=False)
176+
cls.tree_id = db_manager.dirname
158177

159178
@classmethod
160179
def tearDownClass(cls):
161180
cls.dbman.remove_database(cls.name)
162181

163-
def test_create_owner_response(self):
182+
def test_create_admin_response_get(self):
164183
"""Test response to create_owner."""
165-
# FIXME requires tree
166184
rv = self.client.get(f"{BASE_URL}/token/create_owner/")
167185
self.assertEqual(rv.status_code, 200)
168186
self.assertIn("access_token", rv.json)
187+
encoded_token = rv.json["access_token"]
188+
with self.app.app_context():
189+
token = decode_token(encoded_token)
190+
assert token["limited_scope"] == "create_admin"
191+
192+
def test_create_admin_response_post(self):
193+
"""Test response to create_owner."""
194+
rv = self.client.post(f"{BASE_URL}/token/create_owner/")
195+
self.assertEqual(rv.status_code, 201)
196+
self.assertIn("access_token", rv.json)
197+
encoded_token = rv.json["access_token"]
198+
with self.app.app_context():
199+
token = decode_token(encoded_token)
200+
assert token["limited_scope"] == "create_admin"
201+
202+
def test_create_admin_response_post_tree_wrong(self):
203+
"""Test response to create_owner."""
204+
rv = self.client.post(
205+
f"{BASE_URL}/token/create_owner/", json={"tree": "idontexist"}
206+
)
207+
self.assertEqual(rv.status_code, 404)
208+
209+
def test_create_admin_response_post_tree_empty_string(self):
210+
"""Test response to create_owner."""
211+
rv = self.client.post(f"{BASE_URL}/token/create_owner/", json={"tree": ""})
212+
self.assertEqual(rv.status_code, 201)
213+
self.assertIn("access_token", rv.json)
214+
encoded_token = rv.json["access_token"]
215+
with self.app.app_context():
216+
token = decode_token(encoded_token)
217+
assert token["limited_scope"] == "create_admin"
218+
219+
def test_create_admin_response_post_tree(self):
220+
"""Test response to create_owner."""
221+
rv = self.client.post(
222+
f"{BASE_URL}/token/create_owner/", json={"tree": self.tree_id}
223+
)
224+
self.assertEqual(rv.status_code, 201)
225+
self.assertIn("access_token", rv.json)
226+
encoded_token = rv.json["access_token"]
227+
with self.app.app_context():
228+
token = decode_token(encoded_token)
229+
assert token["limited_scope"] == "create_owner"

0 commit comments

Comments
 (0)