Преглед изворни кода

feat(api): add Token.perm_manage_tokens and surrounding functionality

The migration sets `perm_manage_tokens = True` for all existing tokens,
and then sets the default to `False`.

Related: #347
Peter Thomassen пре 4 година
родитељ
комит
d720ed5610

+ 23 - 0
api/desecapi/migrations/0008_token_perm_manage_tokens.py

@@ -0,0 +1,23 @@
+# Generated by Django 3.1.2 on 2020-11-06 23:02
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('desecapi', '0007_email_citext'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='token',
+            name='perm_manage_tokens',
+            field=models.BooleanField(default=True),
+        ),
+        migrations.AlterField(
+            model_name='token',
+            name='perm_manage_tokens',
+            field=models.BooleanField(default=False),
+        ),
+    ]

+ 2 - 0
api/desecapi/models.py

@@ -197,6 +197,8 @@ class Token(ExportModelOperationsMixin('Token'), rest_framework.authtoken.models
     )
     )
     name = models.CharField('Name', blank=True, max_length=64)
     name = models.CharField('Name', blank=True, max_length=64)
     last_used = models.DateTimeField(null=True, blank=True)
     last_used = models.DateTimeField(null=True, blank=True)
+    perm_manage_tokens = models.BooleanField(default=False)
+
     plain = None
     plain = None
 
 
     def generate_key(self):
     def generate_key(self):

+ 6 - 0
api/desecapi/permissions.py

@@ -32,6 +32,12 @@ class IsVPNClient(permissions.BasePermission):
         return ip in IPv4Network('10.8.0.0/24')
         return ip in IPv4Network('10.8.0.0/24')
 
 
 
 
+class ManageTokensPermission(permissions.BasePermission):
+
+    def has_permission(self, request, view):
+        return request.auth.perm_manage_tokens
+
+
 class WithinDomainLimitOnPOST(permissions.BasePermission):
 class WithinDomainLimitOnPOST(permissions.BasePermission):
     """
     """
     Permission that requires that the user still has domain limit quota available, if the request is using POST.
     Permission that requires that the user still has domain limit quota available, if the request is using POST.

+ 1 - 1
api/desecapi/serializers.py

@@ -52,7 +52,7 @@ class TokenSerializer(serializers.ModelSerializer):
 
 
     class Meta:
     class Meta:
         model = models.Token
         model = models.Token
-        fields = ('id', 'created', 'last_used', 'name', 'token',)
+        fields = ('id', 'created', 'last_used', 'name', 'perm_manage_tokens', 'token',)
         read_only_fields = ('id', 'created', 'last_used', 'token')
         read_only_fields = ('id', 'created', 'last_used', 'token')
 
 
     def __init__(self, *args, include_plain=False, **kwargs):
     def __init__(self, *args, include_plain=False, **kwargs):

+ 61 - 3
api/desecapi/tests/test_tokens.py

@@ -4,10 +4,12 @@ from desecapi.models import Token
 from desecapi.tests.base import DomainOwnerTestCase
 from desecapi.tests.base import DomainOwnerTestCase
 
 
 
 
-class TokenTestCase(DomainOwnerTestCase):
+class TokenPermittedTestCase(DomainOwnerTestCase):
 
 
     def setUp(self):
     def setUp(self):
         super().setUp()
         super().setUp()
+        self.token.perm_manage_tokens = True
+        self.token.save()
         self.token2 = self.create_token(self.owner, name='testtoken')
         self.token2 = self.create_token(self.owner, name='testtoken')
         self.other_token = self.create_token(self.user)
         self.other_token = self.create_token(self.user)
 
 
@@ -41,7 +43,8 @@ class TokenTestCase(DomainOwnerTestCase):
 
 
         response = self.client.get(url)
         response = self.client.get(url)
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertStatus(response, status.HTTP_200_OK)
-        self.assertTrue(all(field in response.data for field in ['created', 'id', 'last_used', 'name']))
+        self.assertTrue(all(field in response.data for field in ['created', 'id', 'last_used', 'name',
+                                                                 'perm_manage_tokens']))
         self.assertFalse(any(field in response.data for field in ['token', 'key', 'value']))
         self.assertFalse(any(field in response.data for field in ['token', 'key', 'value']))
 
 
     def test_retrieve_other_token(self):
     def test_retrieve_other_token(self):
@@ -57,7 +60,15 @@ class TokenTestCase(DomainOwnerTestCase):
         for method in [self.client.patch, self.client.put]:
         for method in [self.client.patch, self.client.put]:
             response = method(url, data={'name': method.__name__})
             response = method(url, data={'name': method.__name__})
             self.assertStatus(response, status.HTTP_200_OK)
             self.assertStatus(response, status.HTTP_200_OK)
-            self.assertEqual(response.data['name'], method.__name__)
+            self.assertEqual(Token.objects.get(pk=self.token.id).name, method.__name__)
+
+        # Revoke token management permission
+        response = self.client.patch(url, data={'perm_manage_tokens': False})
+        self.assertStatus(response, status.HTTP_200_OK)
+
+        # Verify that the change cannot be undone
+        response = self.client.patch(url, data={'perm_manage_tokens': True})
+        self.assertStatus(response, status.HTTP_403_FORBIDDEN)
 
 
     def test_create_token(self):
     def test_create_token(self):
         n = len(Token.objects.filter(user=self.owner).all())
         n = len(Token.objects.filter(user=self.owner).all())
@@ -71,3 +82,50 @@ class TokenTestCase(DomainOwnerTestCase):
             self.assertIsNone(response.data['last_used'])
             self.assertIsNone(response.data['last_used'])
 
 
         self.assertEqual(len(Token.objects.filter(user=self.owner).all()), n + len(datas))
         self.assertEqual(len(Token.objects.filter(user=self.owner).all()), n + len(datas))
+
+
+class TokenForbiddenTestCase(DomainOwnerTestCase):
+
+    def setUp(self):
+        super().setUp()
+        self.token2 = self.create_token(self.owner, name='testtoken')
+        self.other_token = self.create_token(self.user)
+
+    def test_token_last_used(self):
+        self.assertIsNone(Token.objects.get(pk=self.token.id).last_used)
+        self.client.get(self.reverse('v1:root'))
+        self.assertIsNotNone(Token.objects.get(pk=self.token.id).last_used)
+
+    def test_list_tokens(self):
+        response = self.client.get(self.reverse('v1:token-list'))
+        self.assertStatus(response, status.HTTP_403_FORBIDDEN)
+
+    def test_delete_my_token(self):
+        for token_id in [Token.objects.get(user=self.owner, name='testtoken').id, self.token.id]:
+            url = self.reverse('v1:token-detail', pk=token_id)
+            response = self.client.delete(url)
+            self.assertStatus(response, status.HTTP_403_FORBIDDEN)
+
+    def test_retrieve_my_token(self):
+        for token_id in [Token.objects.get(user=self.owner, name='testtoken').id, self.token.id]:
+            url = self.reverse('v1:token-detail', pk=token_id)
+            response = self.client.get(url)
+            self.assertStatus(response, status.HTTP_403_FORBIDDEN)
+
+    def test_retrieve_other_token(self):
+        token_id = Token.objects.get(user=self.user).id
+        url = self.reverse('v1:token-detail', pk=token_id)
+        response = self.client.get(url)
+        self.assertStatus(response, status.HTTP_403_FORBIDDEN)
+
+    def test_update_my_token(self):
+        url = self.reverse('v1:token-detail', pk=self.token.id)
+        for method in [self.client.patch, self.client.put]:
+            response = method(url, data={'name': method.__name__})
+            self.assertStatus(response, status.HTTP_403_FORBIDDEN)
+
+    def test_create_token(self):
+        datas = [{}, {'name': ''}, {'name': 'foobar'}]
+        for data in datas:
+            response = self.client.post(self.reverse('v1:token-list'), data=data)
+            self.assertStatus(response, status.HTTP_403_FORBIDDEN)

+ 3 - 3
api/desecapi/views.py

@@ -24,7 +24,7 @@ from desecapi import metrics, models, serializers
 from desecapi.exceptions import ConcurrencyException
 from desecapi.exceptions import ConcurrencyException
 from desecapi.pdns import get_serials
 from desecapi.pdns import get_serials
 from desecapi.pdns_change_tracker import PDNSChangeTracker
 from desecapi.pdns_change_tracker import PDNSChangeTracker
-from desecapi.permissions import IsDomainOwner, IsOwner, IsVPNClient, WithinDomainLimitOnPOST
+from desecapi.permissions import ManageTokensPermission, IsDomainOwner, IsOwner, IsVPNClient, WithinDomainLimitOnPOST
 from desecapi.renderers import PlainTextRenderer
 from desecapi.renderers import PlainTextRenderer
 
 
 
 
@@ -78,7 +78,7 @@ class DomainViewMixin:
 
 
 class TokenViewSet(IdempotentDestroyMixin, viewsets.ModelViewSet):
 class TokenViewSet(IdempotentDestroyMixin, viewsets.ModelViewSet):
     serializer_class = serializers.TokenSerializer
     serializer_class = serializers.TokenSerializer
-    permission_classes = (IsAuthenticated,)
+    permission_classes = (IsAuthenticated, ManageTokensPermission,)
     throttle_scope = 'account_management_passive'
     throttle_scope = 'account_management_passive'
 
 
     def get_queryset(self):
     def get_queryset(self):
@@ -510,7 +510,7 @@ class AccountLoginView(generics.GenericAPIView):
     def post(self, request, *args, **kwargs):
     def post(self, request, *args, **kwargs):
         user = self.request.user
         user = self.request.user
 
 
-        token = models.Token.objects.create(user=user, name="login")
+        token = models.Token.objects.create(user=user, name="login", perm_manage_tokens=True)
         user_logged_in.send(sender=user.__class__, request=self.request, user=user)
         user_logged_in.send(sender=user.__class__, request=self.request, user=user)
 
 
         data = serializers.TokenSerializer(token, include_plain=True).data
         data = serializers.TokenSerializer(token, include_plain=True).data

+ 164 - 49
docs/auth/tokens.rst

@@ -3,77 +3,195 @@
 Manage Tokens
 Manage Tokens
 ~~~~~~~~~~~~~
 ~~~~~~~~~~~~~
 
 
-To make authentication more flexible, the API can provide you with multiple
+To make authentication more flexible, you can create and configure multiple
 authentication tokens. To that end, we provide a set of token management
 authentication tokens. To that end, we provide a set of token management
-endpoints that are separate from the above-mentioned log in and log out
-endpoints. The most notable difference is that the log in endpoint needs
-authentication with email address and password, whereas the token management
-endpoint is authenticated using already issued tokens.
+endpoints that are separate from the login and logout endpoints. The most
+notable differences are that the login endpoint needs authentication with
+the user credentials (email address and password) and its purpose is to return
+a token with a broad range of permissions, whereas the token management
+endpoints are authenticated using an already issued token, for the purpose of
+configuring more fine-grained token permissions.
 
 
+When accessing the token management endpoints using a token without sufficient
+permission, the server will reply with ``403 Forbidden``.
 
 
-Retrieving All Current Tokens
-`````````````````````````````
 
 
-To retrieve a list of currently valid tokens, issue a ``GET`` request::
+.. _`token object`:
 
 
-    curl -X GET https://desec.io/api/v1/auth/tokens/ \
-        --header "Authorization: Token mu4W4MHuSc0HyrGD1h/dnKuZBond"
+Token Field Reference
+`````````````````````
+
+A JSON object representing a token has the following structure::
+
+    {
+        "created": "2018-09-06T09:08:43.762697Z",
+        "id": "3a6b94b5-d20e-40bd-a7cc-521f5c79fab3",
+        "last_used": null,
+        "name": "my new token",
+        "perm_manage_tokens": false,
+        "token": "4pnk7u-NHvrEkFzrhFDRTjGFyX_S"
+    }
+
+Field details:
+
+``created``
+    :Access mode: read-only
+    :Type: timestamp
+
+    Timestamp of token creation, in ISO 8601 format (e.g.
+    ``2018-09-06T09:08:43.762697Z``).
+
+``id``
+    :Access mode: read-only
+    :Type: UUID
 
 
-The server will respond with a list of token objects, each containing
-timestamps of when the token was created and last used (or ``null``; note the
-``Z`` indicating UTC timezone), and a UUID to identify that token. Furthermore,
-each token can carry a name that has no operational meaning (it is meant for
-user reference only). Certain API operations will automatically populate the
-``name`` field with suitable values such as "login" or "dyndns".
+    Token ID, used for identification only (e.g. when deleting a token). This
+    is *not* the token value.
 
 
-::
+``last_used``
+    :Access mode: read-only
+    :Type: timestamp (nullable)
 
 
-    [
-        {
-            "created": "2018-09-06T07:05:54.080564Z",
-            "id": "3159e485-5499-46c0-ae2b-aeb84d627a8e",
-            "last_used": "2019-04-29T18:01:09.894594Z",
-            "name": "login",
-        },
-        {
-            "created": "2018-09-06T08:53:26.428396Z",
-            "id": "76d6e39d-65bc-4ab2-a1b7-6e94eee0a534",
-            "last_used": null,
-            "name": ""
-        }
-    ]
+    Timestamp of when the token was last successfully authenticated, or
+    ``null`` if the token has never been used.
 
 
-You can also retrieve an individual token by appending ``{id}/`` to the URL,
-for example in order to look up a token's name or creation timestamp.
+    In most cases, this corresponds to the last time when an API operation
+    was performed using this token.  However, if the operation was not
+    executed because it was found that the token did not have sufficient
+    permission, this field will still be updated.
 
 
+``name``
+    :Access mode: read, write
+    :Type: string
 
 
-Create Additional Tokens
-````````````````````````
+    Token name.  It is meant for user reference only and carries no
+    operational meaning.  If omitted, the empty string is assumed.
 
 
-To create another token using the token management interface, issue a
-``POST`` request to the same endpoint::
+    Certain API operations will automatically populate the ``name`` field with
+    suitable values such as "login" or "dyndns".
+
+``perm_manage_tokens``
+    :Access mode: read, write
+    :Type: boolean
+
+    Permission to manage tokens (this one and also all others).  A token which
+    does not have this flag set cannot access the ``auth/tokens/`` endpoints.
+
+``token``
+    :Access mode: read-once
+    :Type: string
+
+    Token value that is used to authenticate API requests.  It is only
+    returned once, upon creation of the token.  The value of an existing token
+    cannot be recovered (we store it in irreversibly hashed form).  For
+    security details, see `Security Considerations`_.
+
+
+Creating a Token
+````````````````
+
+To create a new token, issue a ``POST`` request to the tokens endpoint::
 
 
     curl -X POST https://desec.io/api/v1/auth/tokens/ \
     curl -X POST https://desec.io/api/v1/auth/tokens/ \
         --header "Authorization: Token mu4W4MHuSc0HyrGD1h/dnKuZBond" \
         --header "Authorization: Token mu4W4MHuSc0HyrGD1h/dnKuZBond" \
         --header "Content-Type: application/json" --data @- <<< \
         --header "Content-Type: application/json" --data @- <<< \
         '{"name": "my new token"}'
         '{"name": "my new token"}'
 
 
-Note that the name is optional and will be empty if not specified. The server
-will reply with ``201 Created`` and the created token in the response body::
+Note that the name and other fields are optional.  The server will reply with
+``201 Created`` and the created token in the response body::
 
 
     {
     {
         "created": "2018-09-06T09:08:43.762697Z",
         "created": "2018-09-06T09:08:43.762697Z",
         "id": "3a6b94b5-d20e-40bd-a7cc-521f5c79fab3",
         "id": "3a6b94b5-d20e-40bd-a7cc-521f5c79fab3",
         "last_used": null,
         "last_used": null,
-        "token": "4pnk7u-NHvrEkFzrhFDRTjGFyX_S",
-        "name": "my new token"
+        "name": "my new token",
+        "perm_manage_tokens": false,
+        "token": "4pnk7u-NHvrEkFzrhFDRTjGFyX_S"
     }
     }
 
 
+The new token will, by default, possess fewer permissions than a login token.
+In particular, the ``perm_manage_tokens`` flag will not be set, so that the
+new token cannot be used to retrieve, modify, or delete any tokens (including
+itself).
+
+With the default set of permissions, tokens qualify for carrying out all API
+operations related to DNS management (i.e. managing both domains and DNS
+records).  Note that it is always possible to use the :ref:`log-out` endpoint
+to delete a token.
+
+If you require tokens with extra permissions, you can provide the desired
+configuration during creation:
+
+- ``perm_manage_tokens``:  If set to ``true``, the token can be used to
+  authorize token management operations (as described in this chapter).
+
+If a field is provided but has invalid content, ``400 Bad Request`` is
+returned, with error details in the body.
+
+
+Modifying a Token
+`````````````````
+
+To modify a token, send a ``PATCH`` or ``PUT`` request to the
+``auth/tokens/{id}/`` endpoint of the token you would like to modify::
+
+    curl -X POST https://desec.io/api/v1/auth/tokens/{id}/ \
+        --header "Authorization: Token mu4W4MHuSc0HyrGD1h/dnKuZBond" \
+        --header "Content-Type: application/json" --data @- <<< \
+        '{"name": "my new token"}'
+
+The ID given in the URL is the ID of the token that will be modified.  Upon
+success, the server will reply with ``200 OK``.
+
+The token given in the ``Authorization`` header requires the
+``perm_manage_tokens`` permission.  If permissions are insufficient, the
+server will return ``403 Forbidden``.
+
+``name`` and all other fields are optional.  The list of fields that can be
+given is the same as when `Creating a Token`_.  If a field is provided but has
+invalid content, ``400 Bad Request`` is returned, with error details in the
+body.
+
+**Note:**  As long as the ``perm_manage_tokens`` permission is in effect, it
+is possible for a token to grant and revoke its own permissions.  However, if
+the ``perm_manage_tokens`` permission is removed, the operation can only be
+reversed by means of another token that has this permission.
+
+
+Listing Tokens
+``````````````
+
+To retrieve a list of currently valid tokens, issue a ``GET`` request as
+follows::
+
+    curl -X GET https://desec.io/api/v1/auth/tokens/ \
+        --header "Authorization: Token mu4W4MHuSc0HyrGD1h/dnKuZBond"
+
+The server will respond with a list of token objects.  Up to 500 items are
+returned at a time. If you have a larger number of tokens configured, the use
+of :ref:`pagination` is required.
+
+
+Retrieving a Specific Token
+```````````````````````````
+
+To retrieve a list of currently valid tokens, issue a ``GET`` request to the
+token's endpoint::
+
+    curl -X GET https://desec.io/api/v1/auth/tokens/{id}/ \
+        --header "Authorization: Token mu4W4MHuSc0HyrGD1h/dnKuZBond"
+
+The response will contain a token object as described under `Token Field
+Reference`_.  You can use it to check a token's properties, such as name,
+timestamps of creation and last use, or permissions.
+
+**Note:** The response does *not* contain the token value itself!
+
 
 
 .. _delete-tokens:
 .. _delete-tokens:
 
 
-Delete Tokens
-`````````````
+Deleting a Token
+````````````````
 
 
 To delete an existing token by its ID via the token management endpoints, issue a
 To delete an existing token by its ID via the token management endpoints, issue a
 ``DELETE`` request on the token's endpoint, replacing ``{id}`` with the
 ``DELETE`` request on the token's endpoint, replacing ``{id}`` with the
@@ -87,15 +205,12 @@ The server will reply with ``204 No Content``, even if the token was not found.
 If you do not have the token UUID, but you do have the token value itself, you
 If you do not have the token UUID, but you do have the token value itself, you
 can use the :ref:`log-out` endpoint to delete it.
 can use the :ref:`log-out` endpoint to delete it.
 
 
-Note that, for now, all tokens have equal power -- every token can authorize
-any action. We are planning to implement scoped tokens in the future.
-
 
 
 Security Considerations
 Security Considerations
 ```````````````````````
 ```````````````````````
 
 
-This section is for information only. Token length and encoding may change in
-the future.
+This section is for purely informational. Token length and encoding may change
+in the future.
 
 
 Any token is generated from 168 bits of randomness at the server and stored in
 Any token is generated from 168 bits of randomness at the server and stored in
 hashed format (PBKDF2-HMAC-SHA256). Guessing the token correctly or reversing
 hashed format (PBKDF2-HMAC-SHA256). Guessing the token correctly or reversing
@@ -106,5 +221,5 @@ base64 encoding. It comprises only the characters ``A-Z``, ``a-z``, ``0-9``, ``-
 and ``_``. (Base64 padding is not needed as the string length is a multiple of 4.)
 and ``_``. (Base64 padding is not needed as the string length is a multiple of 4.)
 
 
 Old versions of the API encoded 20-byte tokens in 40 characters with hexadecimal
 Old versions of the API encoded 20-byte tokens in 40 characters with hexadecimal
-representation. Such tokens will not be issued anymore, but remain valid until
+representation. Such tokens are not issued anymore, but remain valid until
 invalidated by the user.
 invalidated by the user.

+ 52 - 0
webapp/src/components/Field/SwitchBox.vue

@@ -0,0 +1,52 @@
+<template>
+  <v-switch
+    :label="label"
+    :disabled="disabled || readonly"
+    :error-messages="errorMessages"
+    :input-value="value"
+    :required="required"
+    :rules="[v => !required || !!v || 'Required.']"
+    @change="input($event)"
+    @keyup="keyup($event)"
+  />
+</template>
+
+<script>
+export default {
+  name: 'SwitchBox',
+  props: {
+    disabled: {
+      type: Boolean,
+      required: false,
+    },
+    errorMessages: {
+      type: [String, Array],
+      default: () => [],
+    },
+    label: {
+      type: String,
+      required: false,
+    },
+    readonly: {
+      type: Boolean,
+      required: false,
+    },
+    required: {
+      type: Boolean,
+      default: false,
+    },
+    value: {
+      type: Boolean,
+      required: true,
+    },
+  },
+  methods: {
+    input(event) {
+      this.$emit('input', event);
+    },
+    keyup(event) {
+      this.$emit('keyup', event);
+    },
+  },
+};
+</script>

+ 2 - 0
webapp/src/views/CrudList.vue

@@ -306,6 +306,7 @@ import Code from '@/components/Field/Code';
 import GenericText from '@/components/Field/GenericText';
 import GenericText from '@/components/Field/GenericText';
 import Record from '@/components/Field/Record';
 import Record from '@/components/Field/Record';
 import RRSet from '@/components/Field/RRSet';
 import RRSet from '@/components/Field/RRSet';
+import SwitchBox from '@/components/Field/SwitchBox';
 import TTL from '@/components/Field/TTL';
 import TTL from '@/components/Field/TTL';
 
 
 // safely access deeply nested objects
 // safely access deeply nested objects
@@ -316,6 +317,7 @@ export default {
   components: {
   components: {
     RRSetType,
     RRSetType,
     TimeAgo,
     TimeAgo,
+    SwitchBox,
     Code,
     Code,
     GenericText,
     GenericText,
     Record,
     Record,

+ 13 - 1
webapp/src/views/TokenList.vue

@@ -16,7 +16,7 @@ export default {
           destroy: 'Delete Token',
           destroy: 'Delete Token',
         },
         },
         texts: {
         texts: {
-          banner: () => ('Any API Token can be used to perform any DNS operation on any domain in this account. Token scoping is on our roadmap.'),
+          banner: () => ('<strong>New feature:</strong> You can now configure your tokens for finer access control. Check out the new settings below!'),
           create: () => ('<p>You can create a new API token here. The token is displayed after submitting this form.</p><p><strong>Warning:</strong> Be sure to protect your tokens appropriately! Knowledge of an API token allows performing actions on your behalf.</p>'),
           create: () => ('<p>You can create a new API token here. The token is displayed after submitting this form.</p><p><strong>Warning:</strong> Be sure to protect your tokens appropriately! Knowledge of an API token allows performing actions on your behalf.</p>'),
           createSuccess: (item) => `Your new token is: <code>${item.token}</code><br />It is only displayed once.`,
           createSuccess: (item) => `Your new token is: <code>${item.token}</code><br />It is only displayed once.`,
           destroy: d => (d.name ? `Delete token with name "${d.name}" and ID ${d.id}?` : `Delete unnamed token with ID ${d.id}?`),
           destroy: d => (d.name ? `Delete token with name "${d.name}" and ID ${d.id}?` : `Delete unnamed token with ID ${d.id}?`),
@@ -45,6 +45,18 @@ export default {
             datatype: 'GenericText',
             datatype: 'GenericText',
             searchable: true,
             searchable: true,
           },
           },
+          perm_manage_tokens: {
+            name: 'item.perm_manage_tokens',
+            text: 'Can manage tokens',
+            textCreate: 'Can manage tokens?',
+            align: 'left',
+            sortable: true,
+            value: 'perm_manage_tokens',
+            readonly: false,
+            writeOnCreate: true,
+            datatype: 'SwitchBox',
+            searchable: false,
+          },
           created: {
           created: {
             name: 'item.created',
             name: 'item.created',
             text: 'Created',
             text: 'Created',