@@ -1237,7 +1237,8 @@ allows filtering to happen with admin privs escaping the standard
12371237permissions scheme. For example access to a user's roles should be
12381238limited to the user (read only) and an admin. If you have customized
12391239your schema to implement `Restricting the list of
1240- users that are assignable to a task <customizing.html#restricting-the-list-of-users-that-are-assignable-to-a-task>`__ so that only users with a
1240+ users that are assignable to a task <customizing.html#restricting-the-list-of-users-that-are-assignable-to-a-task>`__
1241+ so that only users with a
12411242Developer role are allowed to be assigned to an issue, a rest end
12421243point must be added to provide a view that exposes users with this
12431244permission.
@@ -1333,6 +1334,248 @@ assuming user 4 is the only user with the Developer role. Note that
13331334the url passes the `roles=User` filter option which is silently
13341335ignored.
13351336
1337+ Changing Access Roles with JSON Web Tokens
1338+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1339+
1340+ As discussed above Roundup's schema is the access control mechanism.
1341+ However you may want to integrate a third party system with roundup.
1342+ E.G. suppose you use a time tracking service that takes an issue id
1343+ and keeps a running count of how much time was spent on it. Then with
1344+ a single button push it can add the recorded time to the roundup
1345+ issue.
1346+
1347+ You probably don't want to give this third party service your roundup
1348+ username and credentials. Especially if your roundup instance is under
1349+ your company's single sign on infrastructure.
1350+
1351+ So what we need is a way for this third part service to impersonate
1352+ you and have access to create a roundup timelog entry (see
1353+ `<customizing.html#adding-a-time-log-to-your-issues>`__. Then add it
1354+ to the associated issue. This should happen without sharing passwords
1355+ and without the third party service to see the issue (except the
1356+ ``times`` property), user, or other information in the tracker.
1357+
1358+ Enter the use of a JSON web token. Roundup has rudimentary ability to
1359+ manage JWTs and use them for authentication and authorization.
1360+
1361+ There are 5 steps to set this up:
1362+
1363+ 1. install pyjwt library using pip or pip3. If roundup can't find the
1364+ jwt module you will see the error ``Support for jwt disabled.``
1365+ 2. create a new role that allows Create access to timelog and edit
1366+ access to an issues' ``times`` property.
1367+ 3. add support for issuing (and validating) jwts to the rest interface.
1368+ This uses the `Adding new rest endpoints`_ mechanism.
1369+ 4. configure roundup's config.ini [web] jwt_secret with at least 32
1370+ random characters of data. (You will get a message
1371+ ``Support for jwt disabled by admin.`` if it's not long enough.)
1372+ 5. add an auditor to make sure that users with this role are appending
1373+ timelog links to the `times` property of the issue.
1374+
1375+ Create role
1376+ """""""""""
1377+
1378+ Adding this snippet of code to the tracker's ``schema.py`` should create a role with the
1379+ proper authorization::
1380+
1381+ db.security.addRole(name="User:timelog", description="allow a user to create and append timelogs")
1382+ perm = db.security.addPermission(name='Create', klass='timelog',
1383+ description="Allow timelog creation", props_only=False)
1384+ db.security.addPermissionToRole("User:timelog", perm)
1385+ perm = db.security.addPermission(name='Edit', klass='issue',
1386+ properties=('id', 'times'),
1387+ description="Allow editing timelog for issue", props_only=False)
1388+ db.security.addPermissionToRole("User:timelog", perm)
1389+
1390+ Then role is named to work with the jwt issue rest call. Starting the role
1391+ name with ``User:`` allows the jwt issue code to create a token with
1392+ this role if the user requesting the role has the User role.
1393+
1394+ Create rest endpoints
1395+ """""""""""""""""""""
1396+
1397+ Here is code to add to your tracker's ``interfaces.py`` (note code is
1398+ python3)::
1399+
1400+ from roundup.rest import Routing, RestfulInstance, _data_decorator
1401+
1402+ class RestfulInstance(object):
1403+ @Routing.route("/jwt/issue", 'POST')
1404+ @_data_decorator
1405+ def generate_jwt(self, input):
1406+ import jwt
1407+ import datetime
1408+ from roundup.anypy.strings import b2s
1409+
1410+ # require basic auth to generate a token
1411+ # At some point we can support a refresh token.
1412+ # maybe a jwt with the "refresh": True claim generated
1413+ # using: "refresh": True in the json request payload.
1414+
1415+ denialmsg='Token creation requires login with basic auth.'
1416+ if 'HTTP_AUTHORIZATION' in self.client.env:
1417+ try:
1418+ auth = self.client.env['HTTP_AUTHORIZATION']
1419+ scheme, challenge = auth.split(' ', 1)
1420+ except (ValueError, AttributeError):
1421+ # bad format for header
1422+ raise Unauthorised(denialmsg)
1423+ if scheme.lower() != 'basic':
1424+ raise Unauthorised(denialmsg)
1425+ else:
1426+ raise Unauthorised(denialmsg)
1427+
1428+ # If we reach this point we have validated that the user has
1429+ # logged in with a password using basic auth.
1430+ all_roles = list(self.db.security.role.items())
1431+ rolenames = []
1432+ for role in all_roles:
1433+ rolenames.append(role[0])
1434+
1435+ user_roles = list(self.db.user.get_roles(self.db.getuid()))
1436+
1437+ claim= { 'sub': self.db.getuid(),
1438+ 'iss': self.db.config.TRACKER_WEB,
1439+ 'aud': self.db.config.TRACKER_WEB,
1440+ 'iat': datetime.datetime.utcnow(),
1441+ }
1442+
1443+ lifetime = 0
1444+ if 'lifetime' in input:
1445+ if input['lifetime'].value != 'unlimited':
1446+ try:
1447+ lifetime = datetime.timedelta(seconds=int(input['lifetime'].value))
1448+ except ValueError:
1449+ raise UsageError("Value 'lifetime' must be 'unlimited' or an integer to specify" +
1450+ " lifetime in seconds. Got %s."%input['lifetime'].value)
1451+ else:
1452+ lifetime = datetime.timedelta(seconds=86400) # 1 day by default
1453+
1454+ if lifetime: # if lifetime = 0 make unlimited by omitting exp claim
1455+ claim['exp'] = datetime.datetime.utcnow() + lifetime
1456+
1457+ newroles = []
1458+ if 'roles' in input:
1459+ for role in input['roles'].value:
1460+ if role not in rolenames:
1461+ raise UsageError("Role %s is not valid."%role)
1462+ if role in user_roles:
1463+ newroles.append(role)
1464+ continue
1465+ parentrole = role.split(':', 1)[0]
1466+ if parentrole in user_roles:
1467+ newroles.append(role)
1468+ continue
1469+
1470+ raise UsageError("Role %s is not permitted."%role)
1471+
1472+ claim['roles'] = newroles
1473+ else:
1474+ claim['roles'] = user_roles
1475+ secret = self.db.config.WEB_JWT_SECRET
1476+ myjwt = jwt.encode(claim, secret, algorithm='HS256')
1477+
1478+ result = {"jwt": b2s(myjwt),
1479+ }
1480+
1481+ return 200, result
1482+
1483+ @Routing.route("/jwt/validate", 'GET')
1484+ @_data_decorator
1485+ def validate_jwt(self,input):
1486+ import jwt
1487+ if not 'jwt' in input:
1488+ raise UsageError("jwt key must be specified")
1489+
1490+ myjwt = input['jwt'].value
1491+
1492+ secret = self.db.config.WEB_JWT_SECRET
1493+ try:
1494+ result = jwt.decode(myjwt, secret,
1495+ algorithms=['HS256'],
1496+ audience=self.db.config.TRACKER_WEB,
1497+ issuer=self.db.config.TRACKER_WEB,
1498+ )
1499+ except jwt.exceptions.InvalidTokenError as err:
1500+ return 401, str(err)
1501+
1502+ return 200, result
1503+
1504+ **Note this is sample code. Use at your own risk.** It breaks a few
1505+ rules about jwts (e.g. it allows you to make unlimited lifetime
1506+ jwts). If you subscribe to the concept of jwt refresh tokens, this code
1507+ will have to be changed as it will only generate jwts with
1508+ username/password authentication.
1509+
1510+ Currently use of jwts an experiment. If this appeals to you consider
1511+ providing patches to existing code to:
1512+
1513+ 1. record all jwts created by a user
1514+ 2. using the record to allow jwts to be revoked and ignored by the
1515+ roundup core
1516+ 3. provide a UI page for managing/revoking jwts
1517+ 4. provide a rest api for revoking jwts
1518+
1519+ These end points can be used like::
1520+
1521+ curl -u demo -s -X POST -H "Referer: https://.../demo/" \
1522+ -H "X-requested-with: rest" \
1523+ -H "Content-Type: application/json" \
1524+ --data '{"lifetime": "3600", "roles": [ "user:timelog" ] }' \
1525+ https://.../demo/rest/jwt/issue
1526+
1527+ (note roles is a json array/list of strings not a string) to get::
1528+
1529+ {
1530+ "data": {
1531+ "jwt": "eyJ0eXAiOiJK......XxMDb-Q3oCnMpyhxPXMAk"
1532+ }
1533+ }
1534+
1535+ The jwt is shortened in the example since it's large. You can validate
1536+ a jwt to see if it's still valid using::
1537+
1538+
1539+ curl -s -H "Referer: https://.../demo/" \
1540+ -H "X-requested-with: rest" \
1541+ https://rouilj.dynamic-dns.net/demo/rest/jwt/validate?jwt=eyJ0eXAiOiJK......XxMDb-Q3oCnMpyhxPXMAk
1542+
1543+ (note no login is required) which returns::
1544+
1545+ {
1546+ "data": {
1547+ "user": "3",
1548+ "roles": [
1549+ "user:timelog"
1550+ ],
1551+ "iss": "https://.../demo/",
1552+ "aud": "https://.../demo/",
1553+ "iat": 1569542404,
1554+ "exp": 1569546004
1555+ }
1556+ }
1557+
1558+ Final steps
1559+ ^^^^^^^^^^^
1560+
1561+ See the `upgrading documentation`__ on how to regenerate an updated copy of
1562+ config.ini using roundup-admin. Then set the ``jwt_secret`` to at
1563+ least 32 characters (more is better up to 512 bits).
1564+
1565+ Writing an auditor that uses "db.user.get_roles" to see if the user
1566+ making the change has the ``user:timelog`` role, and then comparing
1567+ the original ``times`` list to the new list to verify that it is being
1568+ added to and not changed otherwise is left as an exercise for the
1569+ reader. (If you develop one, please contribute via the tracker:
1570+ https://issues.roundup-tracker.org/.)
1571+
1572+ Lastly you can create a JWT using the end point above and make a rest
1573+ call to create a new timelog entry and another call to update the
1574+ issues times property. If you have other ideas on how jwts can be
1575+ used, please share on the roundup mailing lists. See:
1576+ https://sourceforge.net/p/roundup/mailman/ for directions on
1577+ subscribing and for archives of the lists.
1578+
13361579
13371580Creating Custom Rate Limits
13381581===========================
@@ -1378,7 +1621,7 @@ and period that are specific to a user. If either is set to 0,
13781621the defaults from ``config.ini`` file are used.
13791622
13801623Test Examples
1381- =============
1624+ ^^^^^^^^^^^^^
13821625
13831626Rate limit tests:
13841627
0 commit comments