diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
deleted file mode 100644
index f8d96a4..0000000
--- a/.github/FUNDING.yml
+++ /dev/null
@@ -1 +0,0 @@
-github: [ngoduykhanh]
diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml
index 46e92ef..4b103f8 100644
--- a/.github/workflows/build-and-publish.yml
+++ b/.github/workflows/build-and-publish.yml
@@ -21,6 +21,7 @@ jobs:
images: |
ngoduykhanh/powerdns-admin
tags: |
+ type=ref,event=tag
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
@@ -46,9 +47,10 @@ jobs:
- name: Build release image
uses: docker/build-push-action@v2
- if: ${{ github.event_name == 'create' && github.event.ref_type == 'tag' }}
+ if: ${{ startsWith(github.ref, 'refs/tags/v') }}
with:
context: ./
file: ./docker/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
diff --git a/README.md b/README.md
index e924bb0..bec39a5 100644
--- a/README.md
+++ b/README.md
@@ -58,7 +58,3 @@ You can then access PowerDNS-Admin by pointing your browser to http://localhost:
## LICENSE
MIT. See [LICENSE](https://github.com/ngoduykhanh/PowerDNS-Admin/blob/master/LICENSE)
-## Support
-If you like the project and want to support it, you can *buy me a coffee* ☕
-
-
diff --git a/configs/development.py b/configs/development.py
index 06e32bc..2c2e63d 100644
--- a/configs/development.py
+++ b/configs/development.py
@@ -1,5 +1,6 @@
import os
-basedir = os.path.abspath(os.path.abspath(os.path.dirname(__file__)))
+#import urllib.parse
+basedir = os.path.abspath(os.path.dirname(__file__))
### BASIC APP CONFIG
SALT = '$2b$12$yLUMTIfl21FKJQpTkRQXCu'
@@ -16,7 +17,12 @@ SQLA_DB_NAME = 'pda'
SQLALCHEMY_TRACK_MODIFICATIONS = True
### DATABASE - MySQL
-# SQLALCHEMY_DATABASE_URI = 'mysql://' + SQLA_DB_USER + ':' + SQLA_DB_PASSWORD + '@' + SQLA_DB_HOST + '/' + SQLA_DB_NAME
+#SQLALCHEMY_DATABASE_URI = 'mysql://{}:{}@{}/{}'.format(
+# urllib.parse.quote_plus(SQLA_DB_USER),
+# urllib.parse.quote_plus(SQLA_DB_PASSWORD),
+# SQLA_DB_HOST,
+# SQLA_DB_NAME
+#)
### DATABASE - SQLite
SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'pdns.db')
diff --git a/configs/docker_config.py b/configs/docker_config.py
index d06f220..6666fc2 100644
--- a/configs/docker_config.py
+++ b/configs/docker_config.py
@@ -5,6 +5,9 @@ SQLALCHEMY_DATABASE_URI = 'sqlite:////data/powerdns-admin.db'
legal_envvars = (
'SECRET_KEY',
+ 'OIDC_OAUTH_API_URL',
+ 'OIDC_OAUTH_TOKEN_URL',
+ 'OIDC_OAUTH_AUTHORIZE_URL',
'BIND_ADDRESS',
'PORT',
'LOG_LEVEL',
diff --git a/configs/test.py b/configs/test.py
index 7b7345d..6bbdd80 100644
--- a/configs/test.py
+++ b/configs/test.py
@@ -1,5 +1,5 @@
import os
-basedir = os.path.abspath(os.path.abspath(os.path.dirname(__file__)))
+basedir = os.path.abspath(os.path.dirname(__file__))
### BASIC APP CONFIG
SALT = '$2b$12$yLUMTIfl21FKJQpTkRQXCu'
diff --git a/docs/API.md b/docs/API.md
index ae52025..890f556 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -1,105 +1,134 @@
### API Usage
+#### Getting started with docker
+
1. Run docker image docker-compose up, go to UI http://localhost:9191, at http://localhost:9191/swagger is swagger API specification
2. Click to register user, type e.g. user: admin and password: admin
3. Login to UI in settings enable allow domain creation for users, now you can create and manage domains with admin account and also ordinary users
-4. Encode your user and password to base64, in our example we have user admin and password admin so in linux cmd line we type:
+4. Click on the API Keys menu then click on teh "Add Key" button to add a new Administrator Key
+5. Keep the base64 encoded apikey somewhere safe as it won't be available in clear anymore
-```
+
+#### Accessing the API
+
+The PDA API consists of two distinct parts:
+
+- The /powerdnsadmin endpoints manages PDA content (accounts, users, apikeys) and also allow domain creation/deletion
+- The /server endpoints are proxying queries to the backend PowerDNS instance's API. PDA acts as a proxy managing several API Keys and permissions to the PowerDNS content.
+
+The requests to the API needs two headers:
+
+- The classic 'Content-Type: application/json' is required to all POST and PUT requests, though it's armless to use it on each call
+- The authentication header to provide either the login:password basic authentication or the Api Key authentication.
+
+When you access the `/powerdnsadmin` endpoint, you must use the Basic Auth:
+
+```bash
+# Encode your user and password to base64
$ echo -n 'admin:admin'|base64
YWRtaW46YWRtaW4=
+# Use the ouput as your basic auth header
+curl -H 'Authorization: Basic YWRtaW46YWRtaW4=' -X
```
-we use generated output in basic authentication, we authenticate as user,
-with basic authentication, we can create/delete/get zone and create/delete/get/update apikeys
-
-creating domain:
+When you access the `/server` endpoint, you must use the ApiKey
+```bash
+# Use the already base64 encoded key in your header
+curl -H 'X-API-Key: YUdDdGhQM0tMQWV5alpJ' -X
```
+
+Finally, the `/sync_domains` endpoint accepts both basic and apikey authentication
+
+#### Examples
+
+Creating domain via `/powerdnsadmin`:
+
+```bash
curl -L -vvv -H 'Content-Type: application/json' -H 'Authorization: Basic YWRtaW46YWRtaW4=' -X POST http://localhost:9191/api/v1/pdnsadmin/zones --data '{"name": "yourdomain.com.", "kind": "NATIVE", "nameservers": ["ns1.mydomain.com."]}'
```
-creating apikey which has Administrator role, apikey can have also User role, when creating such apikey you have to specify also domain for which apikey is valid:
+Creating an apikey which has the Administrator role:
-```
+```bash
+# Create the key
curl -L -vvv -H 'Content-Type: application/json' -H 'Authorization: Basic YWRtaW46YWRtaW4=' -X POST http://localhost:9191/api/v1/pdnsadmin/apikeys --data '{"description": "masterkey","domains":[], "role": "Administrator"}'
```
+Example response (don't forget to save the plain key from the output)
-call above will return response like this:
-
-```
-[{"description": "samekey", "domains": [], "role": {"name": "Administrator", "id": 1}, "id": 2, "plain_key": "aGCthP3KLAeyjZI"}]
+```json
+[
+ {
+ "accounts": [],
+ "description": "masterkey",
+ "domains": [],
+ "role": {
+ "name": "Administrator",
+ "id": 1
+ },
+ "id": 2,
+ "plain_key": "aGCthP3KLAeyjZI"
+ }
+]
```
-we take plain_key and base64 encode it, this is the only time we can get API key in plain text and save it somewhere:
+We can use the apikey for all calls to PowerDNS (don't forget to specify Content-Type):
-```
-$ echo -n 'aGCthP3KLAeyjZI'|base64
-YUdDdGhQM0tMQWV5alpJ
-```
+Getting powerdns configuration (Administrator Key is needed):
-We can use apikey for all calls specified in our API specification (it tries to follow powerdns API 1:1, only tsigkeys endpoints are not yet implemented), don't forget to specify Content-Type!
-
-getting powerdns configuration:
-
-```
+```bash
curl -L -vvv -H 'Content-Type: application/json' -H 'X-API-KEY: YUdDdGhQM0tMQWV5alpJ' -X GET http://localhost:9191/api/v1/servers/localhost/config
```
-creating and updating records:
+Creating and updating records:
-```
+```bash
curl -X PATCH -H 'Content-Type: application/json' --data '{"rrsets": [{"name": "test1.yourdomain.com.","type": "A","ttl": 86400,"changetype": "REPLACE","records": [ {"content": "192.0.2.5", "disabled": false} ]},{"name": "test2.yourdomain.com.","type": "AAAA","ttl": 86400,"changetype": "REPLACE","records": [ {"content": "2001:db8::6", "disabled": false} ]}]}' -H 'X-API-Key: YUdDdGhQM0tMQWV5alpJ' http://127.0.0.1:9191/api/v1/servers/localhost/zones/yourdomain.com.
```
-getting domain:
+Getting a domain:
-```
+```bash
curl -L -vvv -H 'Content-Type: application/json' -H 'X-API-KEY: YUdDdGhQM0tMQWV5alpJ' -X GET http://localhost:9191/api/v1/servers/localhost/zones/yourdomain.com
```
-list zone records:
+List a zone's records:
-```
+```bash
curl -H 'Content-Type: application/json' -H 'X-API-Key: YUdDdGhQM0tMQWV5alpJ' http://localhost:9191/api/v1/servers/localhost/zones/yourdomain.com
```
-add new record:
+Add a new record:
-```
+```bash
curl -H 'Content-Type: application/json' -X PATCH --data '{"rrsets": [ {"name": "test.yourdomain.com.", "type": "A", "ttl": 86400, "changetype": "REPLACE", "records": [ {"content": "192.0.5.4", "disabled": false } ] } ] }' -H 'X-API-Key: YUdDdGhQM0tMQWV5alpJ' http://localhost:9191/api/v1/servers/localhost/zones/yourdomain.com | jq .
```
-update record:
+Update a record:
-```
+```bash
curl -H 'Content-Type: application/json' -X PATCH --data '{"rrsets": [ {"name": "test.yourdomain.com.", "type": "A", "ttl": 86400, "changetype": "REPLACE", "records": [ {"content": "192.0.2.5", "disabled": false, "name": "test.yourdomain.com.", "ttl": 86400, "type": "A"}]}]}' -H 'X-API-Key: YUdDdGhQM0tMQWV5alpJ' http://localhost:9191/api/v1/servers/localhost/zones/yourdomain.com | jq .
```
-delete record:
+Delete a record:
-```
+```bash
curl -H 'Content-Type: application/json' -X PATCH --data '{"rrsets": [ {"name": "test.yourdomain.com.", "type": "A", "ttl": 86400, "changetype": "DELETE"}]}' -H 'X-API-Key: YUdDdGhQM0tMQWV5alpJ' http://localhost:9191/api/v1/servers/localhost/zones/yourdomain.com | jq
```
### Generate ER diagram
-```
+With docker
+
+```bash
+# Install build packages
apt-get install python-dev graphviz libgraphviz-dev pkg-config
-```
-
-```
+# Get the required python libraries
pip install graphviz mysqlclient ERAlchemy
-```
-
-```
+# Start the docker container
docker-compose up -d
-```
-
-```
+# Set environment variables
source .env
-```
-
-```
+# Generate the diagrams
eralchemy -i 'mysql://${PDA_DB_USER}:${PDA_DB_PASSWORD}@'$(docker inspect powerdns-admin-mysql|jq -jr '.[0].NetworkSettings.Networks.powerdnsadmin_default.IPAddress')':3306/powerdns_admin' -o /tmp/output.pdf
```
diff --git a/docs/oauth.md b/docs/oauth.md
index bc0e9af..f84ac69 100644
--- a/docs/oauth.md
+++ b/docs/oauth.md
@@ -17,4 +17,83 @@ Now you can enable the OAuth in PowerDNS-Admin.
* Replace the [tenantID] in the default URLs for authorize and token with your Tenant ID.
* Restart PowerDNS-Admin
-This should allow you to log in using OAuth.
\ No newline at end of file
+This should allow you to log in using OAuth.
+
+#### Keycloak
+
+To link to Keycloak for authentication, you need to create a new client in the Keycloak Administration Console.
+* Log in to the Keycloak Administration Console
+* Go to Clients > Create
+* Enter a Client ID (for example 'powerdns-admin') and click 'Save'
+* Scroll down to 'Access Type' and choose 'Confidential'.
+* Scroll down to 'Valid Redirect URIs' and enter 'https:///oidc/authorized'
+* Click 'Save'
+* Go to the 'Credentials' tab and copy the Client Secret
+* Log in to PowerDNS-Admin and go to 'Settings > Authentication > OpenID Connect OAuth'
+* Enter the following details:
+ * Client key -> Client ID
+ * Client secret > Client secret copied from keycloak
+ * Scope: `profile`
+ * API URL: https:///auth/realms//protocol/openid-connect/
+ * Token URL: https:///auth/realms//protocol/openid-connect/token
+ * Authorize URL: https:///auth/realms//protocol/openid-connect/auth
+ * Logout URL: https:///auth/realms//protocol/openid-connect/logout
+ * Leave the rest default
+* Save the changes and restart PowerDNS-Admin
+* Use the new 'Sign in using OpenID Connect' button to log in.
+
+#### OpenID Connect OAuth
+To link to oidc service for authenticationregister your PowerDNS-Admin in the OIDC Provider. This requires your PowerDNS-Admin web interface to use an HTTPS URL.
+
+Enable OpenID Connect OAuth option.
+* Client key, The client ID
+* Scope, The scope of the data.
+* API URL, /auth (The ending can be different with each provider)
+* Token URL, /token
+* Authorize URL, /auth
+* Logout URL, /logout
+
+* Username, This will be the claim that will be used as the username. (Usually preferred_username)
+* First Name, This will be the firstname of the user. (Usually given_name)
+* Last Name, This will be the lastname of the user. (Usually family_name)
+* Email, This will be the email of the user. (Usually email)
+
+#### To create accounts on oidc login use the following properties:
+* Autoprovision Account Name Property, This property will set the name of the created account.
+ This property can be a string or a list.
+* Autoprovision Account Description Property, This property will set the description of the created account.
+ This property can be a string or a list.
+
+If we get a variable named "groups" and "groups_description" from our IdP.
+This variable contains groups that the user is a part of.
+We will put the variable name "groups" in the "Name Property" and "groups_description" in the "Description Property".
+This will result in the following account being created:
+Input we get from the Idp:
+
+```
+{
+ "preferred_username": "example_username",
+ "given_name": "example_firstame",
+ "family_name": "example_lastname",
+ "email": "example_email",
+ "groups": ["github", "gitlab"]
+ "groups_description": ["github.com", "gitlab.com"]
+}
+```
+
+The user properties will be:
+```
+Username: customer_username
+First Name: customer_firstame
+Last Name: customer_lastname
+Email: customer_email
+Role: User
+```
+
+The groups properties will be:
+```
+Name: github Description: github.com Members: example_username
+Name: gitlab Description: gitlab.com Members: example_username
+```
+
+If the option "delete_sso_accounts" is turned on the user will only be apart of groups the IdP provided and removed from all other accoubnts.
diff --git a/migrations/versions/0967658d9c0d_add_apikey_account_mapping_table.py b/migrations/versions/0967658d9c0d_add_apikey_account_mapping_table.py
new file mode 100644
index 0000000..f2c87ed
--- /dev/null
+++ b/migrations/versions/0967658d9c0d_add_apikey_account_mapping_table.py
@@ -0,0 +1,41 @@
+"""add apikey account mapping table
+
+Revision ID: 0967658d9c0d
+Revises: 0d3d93f1c2e0
+Create Date: 2021-11-13 22:28:46.133474
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '0967658d9c0d'
+down_revision = '0d3d93f1c2e0'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('apikey_account',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('apikey_id', sa.Integer(), nullable=False),
+ sa.Column('account_id', sa.Integer(), nullable=False),
+ sa.ForeignKeyConstraint(['account_id'], ['account.id'], ),
+ sa.ForeignKeyConstraint(['apikey_id'], ['apikey.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ with op.batch_alter_table('history', schema=None) as batch_op:
+ batch_op.create_index(batch_op.f('ix_history_created_on'), ['created_on'], unique=False)
+
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table('history', schema=None) as batch_op:
+ batch_op.drop_index(batch_op.f('ix_history_created_on'))
+
+ op.drop_table('apikey_account')
+ # ### end Alembic commands ###
diff --git a/package.json b/package.json
index 26b67ea..76982c8 100644
--- a/package.json
+++ b/package.json
@@ -2,6 +2,7 @@
"dependencies": {
"admin-lte": "2.4.9",
"bootstrap": "^3.4.1",
+ "bootstrap-datepicker": "^1.8.0",
"bootstrap-validator": "^0.11.9",
"datatables.net-plugins": "^1.10.19",
"icheck": "^1.0.2",
diff --git a/powerdnsadmin/__init__.py b/powerdnsadmin/__init__.py
index 98690c2..c70b273 100755
--- a/powerdnsadmin/__init__.py
+++ b/powerdnsadmin/__init__.py
@@ -55,6 +55,8 @@ def create_app(config=None):
csrf.exempt(routes.api.api_list_account_users)
csrf.exempt(routes.api.api_add_account_user)
csrf.exempt(routes.api.api_remove_account_user)
+ csrf.exempt(routes.api.api_zone_cryptokeys)
+ csrf.exempt(routes.api.api_zone_cryptokey)
# Load config from env variables if using docker
if os.path.exists(os.path.join(app.root_path, 'docker_config.py')):
diff --git a/powerdnsadmin/assets.py b/powerdnsadmin/assets.py
index 31b6ce2..dfe79ff 100644
--- a/powerdnsadmin/assets.py
+++ b/powerdnsadmin/assets.py
@@ -39,6 +39,7 @@ css_main = Bundle(
'node_modules/admin-lte/dist/css/AdminLTE.css',
'node_modules/admin-lte/dist/css/skins/_all-skins.css',
'custom/css/custom.css',
+ 'node_modules/bootstrap-datepicker/dist/css/bootstrap-datepicker.css',
filters=('cssmin', 'cssrewrite'),
output='generated/main.css')
@@ -58,6 +59,7 @@ js_main = Bundle('node_modules/jquery/dist/jquery.js',
'node_modules/jtimeout/src/jTimeout.js',
'node_modules/jquery.quicksearch/src/jquery.quicksearch.js',
'custom/js/custom.js',
+ 'node_modules/bootstrap-datepicker/dist/js/bootstrap-datepicker.js',
filters=(ConcatFilter, 'jsmin'),
output='generated/main.js')
diff --git a/powerdnsadmin/decorators.py b/powerdnsadmin/decorators.py
index 44e545b..e2a35bb 100644
--- a/powerdnsadmin/decorators.py
+++ b/powerdnsadmin/decorators.py
@@ -192,6 +192,24 @@ def is_json(f):
return decorated_function
+def callback_if_request_body_contains_key(callback, http_methods=[], keys=[]):
+ """
+ If request body contains one or more of specified keys, call
+ :param callback
+ """
+ def decorator(f):
+ @wraps(f)
+ def decorated_function(*args, **kwargs):
+ check_current_http_method = not http_methods or request.method in http_methods
+ if (check_current_http_method and
+ set(request.get_json(force=True).keys()).intersection(set(keys))
+ ):
+ callback(*args, **kwargs)
+ return f(*args, **kwargs)
+ return decorated_function
+ return decorator
+
+
def api_role_can(action, roles=None, allow_self=False):
"""
Grant access if:
@@ -246,6 +264,48 @@ def api_can_create_domain(f):
return decorated_function
+def apikey_can_create_domain(f):
+ """
+ Grant access if:
+ - user is in Operator role or higher, or
+ - allow_user_create_domain is on
+ """
+ @wraps(f)
+ def decorated_function(*args, **kwargs):
+ if g.apikey.role.name not in [
+ 'Administrator', 'Operator'
+ ] and not Setting().get('allow_user_create_domain'):
+ msg = "ApiKey #{0} does not have enough privileges to create domain"
+ current_app.logger.error(msg.format(g.apikey.id))
+ raise NotEnoughPrivileges()
+ return f(*args, **kwargs)
+
+ return decorated_function
+
+
+def apikey_can_remove_domain(http_methods=[]):
+ """
+ Grant access if:
+ - user is in Operator role or higher, or
+ - allow_user_remove_domain is on
+ """
+ def decorator(f):
+ @wraps(f)
+ def decorated_function(*args, **kwargs):
+ check_current_http_method = not http_methods or request.method in http_methods
+
+ if (check_current_http_method and
+ g.apikey.role.name not in ['Administrator', 'Operator'] and
+ not Setting().get('allow_user_remove_domain')
+ ):
+ msg = "ApiKey #{0} does not have enough privileges to remove domain"
+ current_app.logger.error(msg.format(g.apikey.id))
+ raise NotEnoughPrivileges()
+ return f(*args, **kwargs)
+ return decorated_function
+ return decorator
+
+
def apikey_is_admin(f):
"""
Grant access if user is in Administrator role
@@ -262,21 +322,52 @@ def apikey_is_admin(f):
def apikey_can_access_domain(f):
+ """
+ Grant access if:
+ - user has Operator role or higher, or
+ - user has explicitly been granted access to domain
+ """
@wraps(f)
def decorated_function(*args, **kwargs):
- apikey = g.apikey
if g.apikey.role.name not in ['Administrator', 'Operator']:
- domains = apikey.domains
zone_id = kwargs.get('zone_id').rstrip(".")
- domain_names = [item.name for item in domains]
+ domain_names = [item.name for item in g.apikey.domains]
- if zone_id not in domain_names:
+ accounts = g.apikey.accounts
+ accounts_domains = [domain.name for a in accounts for domain in a.domains]
+
+ allowed_domains = set(domain_names + accounts_domains)
+
+ if zone_id not in allowed_domains:
raise DomainAccessForbidden()
return f(*args, **kwargs)
return decorated_function
+def apikey_can_configure_dnssec(http_methods=[]):
+ """
+ Grant access if:
+ - user is in Operator role or higher, or
+ - dnssec_admins_only is off
+ """
+ def decorator(f=None):
+ @wraps(f)
+ def decorated_function(*args, **kwargs):
+ check_current_http_method = not http_methods or request.method in http_methods
+
+ if (check_current_http_method and
+ g.apikey.role.name not in ['Administrator', 'Operator'] and
+ Setting().get('dnssec_admins_only')
+ ):
+ msg = "ApiKey #{0} does not have enough privileges to configure dnssec"
+ current_app.logger.error(msg.format(g.apikey.id))
+ raise DomainAccessForbidden(message=msg)
+ return f(*args, **kwargs) if f else None
+ return decorated_function
+ return decorator
+
+
def apikey_auth(f):
@wraps(f)
def decorated_function(*args, **kwargs):
diff --git a/powerdnsadmin/default_config.py b/powerdnsadmin/default_config.py
index 42b26b4..16b8161 100644
--- a/powerdnsadmin/default_config.py
+++ b/powerdnsadmin/default_config.py
@@ -1,5 +1,6 @@
import os
-basedir = os.path.abspath(os.path.abspath(os.path.dirname(__file__)))
+import urllib.parse
+basedir = os.path.abspath(os.path.dirname(__file__))
### BASIC APP CONFIG
SALT = '$2b$12$yLUMTIfl21FKJQpTkRQXCu'
@@ -18,7 +19,12 @@ SQLA_DB_NAME = 'pda'
SQLALCHEMY_TRACK_MODIFICATIONS = True
### DATABASE - MySQL
-SQLALCHEMY_DATABASE_URI = 'mysql://'+SQLA_DB_USER+':'+SQLA_DB_PASSWORD+'@'+SQLA_DB_HOST+'/'+SQLA_DB_NAME
+SQLALCHEMY_DATABASE_URI = 'mysql://{}:{}@{}/{}'.format(
+ urllib.parse.quote_plus(SQLA_DB_USER),
+ urllib.parse.quote_plus(SQLA_DB_PASSWORD),
+ SQLA_DB_HOST,
+ SQLA_DB_NAME
+)
### DATABASE - SQLite
# SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'pdns.db')
diff --git a/powerdnsadmin/lib/errors.py b/powerdnsadmin/lib/errors.py
index 8642cf9..687f554 100644
--- a/powerdnsadmin/lib/errors.py
+++ b/powerdnsadmin/lib/errors.py
@@ -60,7 +60,8 @@ class ApiKeyNotUsable(StructuredException):
def __init__(
self,
name=None,
- message="Api key must have domains or have administrative role"):
+ message=("Api key must have domains or accounts"
+ " or an administrative role")):
StructuredException.__init__(self)
self.message = message
self.name = name
@@ -120,6 +121,15 @@ class AccountDeleteFail(StructuredException):
self.name = name
+class AccountNotExists(StructuredException):
+ status_code = 404
+
+ def __init__(self, name=None, message="Account does not exist"):
+ StructuredException.__init__(self)
+ self.message = message
+ self.name = name
+
+
class UserCreateFail(StructuredException):
status_code = 500
diff --git a/powerdnsadmin/lib/schema.py b/powerdnsadmin/lib/schema.py
index 78d8369..e0d7efc 100644
--- a/powerdnsadmin/lib/schema.py
+++ b/powerdnsadmin/lib/schema.py
@@ -11,10 +11,21 @@ class RoleSchema(Schema):
name = fields.String()
+class AccountSummarySchema(Schema):
+ id = fields.Integer()
+ name = fields.String()
+ domains = fields.Embed(schema=DomainSchema, many=True)
+
+class ApiKeySummarySchema(Schema):
+ id = fields.Integer()
+ description = fields.String()
+
+
class ApiKeySchema(Schema):
id = fields.Integer()
role = fields.Embed(schema=RoleSchema)
domains = fields.Embed(schema=DomainSchema, many=True)
+ accounts = fields.Embed(schema=AccountSummarySchema, many=True)
description = fields.String()
key = fields.String()
@@ -23,15 +34,11 @@ class ApiPlainKeySchema(Schema):
id = fields.Integer()
role = fields.Embed(schema=RoleSchema)
domains = fields.Embed(schema=DomainSchema, many=True)
+ accounts = fields.Embed(schema=AccountSummarySchema, many=True)
description = fields.String()
plain_key = fields.String()
-class AccountSummarySchema(Schema):
- id = fields.Integer()
- name = fields.String()
-
-
class UserSchema(Schema):
id = fields.Integer()
username = fields.String()
@@ -56,3 +63,4 @@ class AccountSchema(Schema):
contact = fields.String()
mail = fields.String()
domains = fields.Embed(schema=DomainSchema, many=True)
+ apikeys = fields.Embed(schema=ApiKeySummarySchema, many=True)
diff --git a/powerdnsadmin/models/__init__.py b/powerdnsadmin/models/__init__.py
index 1562319..9c009f2 100644
--- a/powerdnsadmin/models/__init__.py
+++ b/powerdnsadmin/models/__init__.py
@@ -8,6 +8,7 @@ from .account_user import AccountUser
from .server import Server
from .history import History
from .api_key import ApiKey
+from .api_key_account import ApiKeyAccount
from .setting import Setting
from .domain import Domain
from .domain_setting import DomainSetting
diff --git a/powerdnsadmin/models/account.py b/powerdnsadmin/models/account.py
index ad46ef9..4d08fc1 100644
--- a/powerdnsadmin/models/account.py
+++ b/powerdnsadmin/models/account.py
@@ -17,6 +17,9 @@ class Account(db.Model):
contact = db.Column(db.String(128))
mail = db.Column(db.String(128))
domains = db.relationship("Domain", back_populates="account")
+ apikeys = db.relationship("ApiKey",
+ secondary="apikey_account",
+ back_populates="accounts")
def __init__(self, name=None, description=None, contact=None, mail=None):
self.name = name
diff --git a/powerdnsadmin/models/api_key.py b/powerdnsadmin/models/api_key.py
index 9c7d9d3..4c26cd2 100644
--- a/powerdnsadmin/models/api_key.py
+++ b/powerdnsadmin/models/api_key.py
@@ -1,12 +1,12 @@
-import random
+import secrets
import string
import bcrypt
from flask import current_app
-from .base import db, domain_apikey
+from .base import db
from ..models.role import Role
from ..models.domain import Domain
-
+from ..models.account import Account
class ApiKey(db.Model):
__tablename__ = "apikey"
@@ -16,17 +16,21 @@ class ApiKey(db.Model):
role_id = db.Column(db.Integer, db.ForeignKey('role.id'))
role = db.relationship('Role', back_populates="apikeys", lazy=True)
domains = db.relationship("Domain",
- secondary=domain_apikey,
+ secondary="domain_apikey",
back_populates="apikeys")
+ accounts = db.relationship("Account",
+ secondary="apikey_account",
+ back_populates="apikeys")
- def __init__(self, key=None, desc=None, role_name=None, domains=[]):
+ def __init__(self, key=None, desc=None, role_name=None, domains=[], accounts=[]):
self.id = None
self.description = desc
self.role_name = role_name
self.domains[:] = domains
+ self.accounts[:] = accounts
if not key:
rand_key = ''.join(
- random.choice(string.ascii_letters + string.digits)
+ secrets.choice(string.ascii_letters + string.digits)
for _ in range(15))
self.plain_key = rand_key
self.key = self.get_hashed_password(rand_key).decode('utf-8')
@@ -54,7 +58,7 @@ class ApiKey(db.Model):
db.session.rollback()
raise e
- def update(self, role_name=None, description=None, domains=None):
+ def update(self, role_name=None, description=None, domains=None, accounts=None):
try:
if role_name:
role = Role.query.filter(Role.name == role_name).first()
@@ -63,12 +67,18 @@ class ApiKey(db.Model):
if description:
self.description = description
- if domains:
+ if domains is not None:
domain_object_list = Domain.query \
.filter(Domain.name.in_(domains)) \
.all()
self.domains[:] = domain_object_list
+ if accounts is not None:
+ account_object_list = Account.query \
+ .filter(Account.name.in_(accounts)) \
+ .all()
+ self.accounts[:] = account_object_list
+
db.session.commit()
except Exception as e:
msg_str = 'Update of apikey failed. Error: {0}'
@@ -87,6 +97,15 @@ class ApiKey(db.Model):
else:
pw = self.plain_text_password
+ # The salt value is currently re-used here intentionally because
+ # the implementation relies on just the API key's value itself
+ # for database lookup: ApiKey.is_validate() would have no way of
+ # discerning whether any given key is valid if bcrypt.gensalt()
+ # was used. As far as is known, this is fine as long as the
+ # value of new API keys is randomly generated in a
+ # cryptographically secure fashion, as this then makes
+ # expendable as an exception the otherwise vital protection of
+ # proper salting as provided by bcrypt.gensalt().
return bcrypt.hashpw(pw.encode('utf-8'),
current_app.config.get('SALT').encode('utf-8'))
@@ -112,3 +131,12 @@ class ApiKey(db.Model):
raise Exception("Unauthorized")
return apikey
+
+ def associate_account(self, account):
+ return True
+
+ def dissociate_account(self, account):
+ return True
+
+ def get_accounts(self):
+ return True
diff --git a/powerdnsadmin/models/api_key_account.py b/powerdnsadmin/models/api_key_account.py
new file mode 100644
index 0000000..904f335
--- /dev/null
+++ b/powerdnsadmin/models/api_key_account.py
@@ -0,0 +1,20 @@
+from .base import db
+
+
+class ApiKeyAccount(db.Model):
+ __tablename__ = 'apikey_account'
+ id = db.Column(db.Integer, primary_key=True)
+ apikey_id = db.Column(db.Integer,
+ db.ForeignKey('apikey.id'),
+ nullable=False)
+ account_id = db.Column(db.Integer,
+ db.ForeignKey('account.id'),
+ nullable=False)
+ db.UniqueConstraint('apikey_id', 'account_id', name='uniq_apikey_account')
+
+ def __init__(self, apikey_id, account_id):
+ self.apikey_id = apikey_id
+ self.account_id = account_id
+
+ def __repr__(self):
+ return ''.format(self.apikey_id, self.account_id)
diff --git a/powerdnsadmin/models/history.py b/powerdnsadmin/models/history.py
index b17e4ed..650e843 100644
--- a/powerdnsadmin/models/history.py
+++ b/powerdnsadmin/models/history.py
@@ -8,6 +8,7 @@ from .base import db
class History(db.Model):
id = db.Column(db.Integer, primary_key=True)
+ # format of msg field must not change. History traversing is done using part of the msg field
msg = db.Column(db.String(256))
# detail = db.Column(db.Text().with_variant(db.Text(length=2**24-2), 'mysql'))
detail = db.Column(db.Text())
diff --git a/powerdnsadmin/models/setting.py b/powerdnsadmin/models/setting.py
index 63e055f..3a84b95 100644
--- a/powerdnsadmin/models/setting.py
+++ b/powerdnsadmin/models/setting.py
@@ -28,7 +28,9 @@ class Setting(db.Model):
'allow_user_create_domain': False,
'allow_user_remove_domain': False,
'allow_user_view_history': False,
+ 'delete_sso_accounts': False,
'bg_domain_updates': False,
+ 'enable_api_rr_history': True,
'site_name': 'PowerDNS-Admin',
'site_url': 'http://localhost:9191',
'session_timeout': 10,
@@ -191,6 +193,7 @@ class Setting(db.Model):
'ttl_options': '1 minute,5 minutes,30 minutes,60 minutes,24 hours',
'otp_field_enabled': True,
'custom_css': '',
+ 'max_history_records': 1000
}
def __init__(self, id=None, name=None, value=None):
@@ -271,16 +274,23 @@ class Setting(db.Model):
def get(self, setting):
if setting in self.defaults:
- result = self.query.filter(Setting.name == setting).first()
+
+ if setting.upper() in current_app.config:
+ result = current_app.config[setting.upper()]
+ else:
+ result = self.query.filter(Setting.name == setting).first()
+
if result is not None:
- return strtobool(result.value) if result.value in [
+ if hasattr(result,'value'):
+ result = result.value
+ return strtobool(result) if result in [
'True', 'False'
- ] else result.value
+ ] else result
else:
return self.defaults[setting]
else:
current_app.logger.error('Unknown setting queried: {0}'.format(setting))
-
+
def get_records_allow_to_edit(self):
return list(
set(self.get_forward_records_allow_to_edit() +
diff --git a/powerdnsadmin/models/user.py b/powerdnsadmin/models/user.py
index 7ea6227..1d318a9 100644
--- a/powerdnsadmin/models/user.py
+++ b/powerdnsadmin/models/user.py
@@ -628,6 +628,7 @@ class User(db.Model):
Account)\
.filter(self.id == AccountUser.user_id)\
.filter(Account.id == AccountUser.account_id)\
+ .order_by(Account.name)\
.all()
for q in query:
accounts.append(q[1])
diff --git a/powerdnsadmin/routes/admin.py b/powerdnsadmin/routes/admin.py
index 55bd9d1..414bac4 100644
--- a/powerdnsadmin/routes/admin.py
+++ b/powerdnsadmin/routes/admin.py
@@ -4,7 +4,7 @@ import traceback
import re
from base64 import b64encode
from ast import literal_eval
-from flask import Blueprint, render_template, make_response, url_for, current_app, request, redirect, jsonify, abort, flash, session
+from flask import Blueprint, render_template, render_template_string, make_response, url_for, current_app, request, redirect, jsonify, abort, flash, session
from flask_login import login_required, current_user
from ..decorators import operator_role_required, admin_role_required, history_access_required
@@ -32,13 +32,172 @@ admin_bp = Blueprint('admin',
template_folder='templates',
url_prefix='/admin')
+"""
+changeSet is a list of tuples, in the following format
+(old_state, new_state, change_type)
+
+old_state: dictionary with "disabled" and "content" keys. {"disabled" : False, "content" : "1.1.1.1" }
+new_state: similarly
+change_type: "addition" or "deletion" or "status" for status change or "unchanged" for no change
+
+Note: A change in "content", is considered a deletion and recreation of the same record,
+holding the new content value.
+"""
+def get_record_changes(del_rrest, add_rrest):
+ changeSet = []
+ delSet = del_rrest['records'] if 'records' in del_rrest else []
+ addSet = add_rrest['records'] if 'records' in add_rrest else []
+ for d in delSet: # get the deletions and status changes
+ exists = False
+ for a in addSet:
+ if d['content'] == a['content']:
+ exists = True
+ if d['disabled'] != a['disabled']:
+ changeSet.append( ({"disabled":d['disabled'],"content":d['content']},
+ {"disabled":a['disabled'],"content":a['content']},
+ "status") )
+ break
+
+ if not exists: # deletion
+ changeSet.append( ({"disabled":d['disabled'],"content":d['content']},
+ None,
+ "deletion") )
+
+ for a in addSet: # get the additions
+ exists = False
+ for d in delSet:
+ if d['content'] == a['content']:
+ exists = True
+ # already checked for status change
+ break
+ if not exists:
+ changeSet.append( (None, {"disabled":a['disabled'], "content":a['content']}, "addition") )
+ continue
+
+ for a in addSet: # get the unchanged
+ exists = False
+ for c in changeSet:
+ if c[1] != None and c[1]["content"] == a['content']:
+ exists = True
+ break
+ if not exists:
+ changeSet.append( ( {"disabled":a['disabled'], "content":a['content']}, {"disabled":a['disabled'], "content":a['content']}, "unchanged") )
+
+ return changeSet
+
+# out_changes is a list of HistoryRecordEntry objects in which we will append the new changes
+# a HistoryRecordEntry represents a pair of add_rrest and del_rrest
+def extract_changelogs_from_a_history_entry(out_changes, history_entry, change_num, record_name=None, record_type=None):
+
+ if history_entry.detail is None:
+ return
+
+ if "add_rrests" in history_entry.detail:
+ detail_dict = json.loads(history_entry.detail.replace("\'", ''))
+ else: # not a record entry
+ return
+
+ add_rrests = detail_dict['add_rrests']
+ del_rrests = detail_dict['del_rrests']
+
+
+ for add_rrest in add_rrests:
+ exists = False
+ for del_rrest in del_rrests:
+ if del_rrest['name'] == add_rrest['name'] and del_rrest['type'] == add_rrest['type']:
+ exists = True
+ if change_num not in out_changes:
+ out_changes[change_num] = []
+ out_changes[change_num].append(HistoryRecordEntry(history_entry, del_rrest, add_rrest, "*"))
+ break
+ if not exists: # this is a new record
+ if change_num not in out_changes:
+ out_changes[change_num] = []
+ out_changes[change_num].append(HistoryRecordEntry(history_entry, [], add_rrest, "+")) # (add_rrest, del_rrest, change_type)
+ for del_rrest in del_rrests:
+ exists = False
+ for add_rrest in add_rrests:
+ if del_rrest['name'] == add_rrest['name'] and del_rrest['type'] == add_rrest['type']:
+ exists = True # no need to add in the out_changes set
+ break
+ if not exists: # this is a deletion
+ if change_num not in out_changes:
+ out_changes[change_num] = []
+ out_changes[change_num].append(HistoryRecordEntry(history_entry, del_rrest, [], "-"))
+
+
+ # only used for changelog per record
+ if record_name != None and record_type != None: # then get only the records with the specific (record_name, record_type) tuple
+ if change_num in out_changes:
+ changes_i = out_changes[change_num]
+ else:
+ return
+ for hre in changes_i: # for each history record entry in changes_i
+ if 'type' in hre.add_rrest and hre.add_rrest['name'] == record_name and hre.add_rrest['type'] == record_type:
+ continue
+ elif 'type' in hre.del_rrest and hre.del_rrest['name'] == record_name and hre.del_rrest['type'] == record_type:
+ continue
+ else:
+ out_changes[change_num].remove(hre)
+
+
+
+# records with same (name,type) are considered as a single HistoryRecordEntry
+# history_entry is of type History - used to extract created_by and created_on
+# add_rrest is a dictionary of replace
+# del_rrest is a dictionary of remove
+class HistoryRecordEntry:
+ def __init__(self, history_entry, del_rrest, add_rrest, change_type):
+ # search the add_rrest index into the add_rrest set for the key (name, type)
+
+ self.history_entry = history_entry
+ self.add_rrest = add_rrest
+ self.del_rrest = del_rrest
+ self.change_type = change_type # "*": edit or unchanged, "+" new tuple(name,type), "-" deleted (name,type) tuple
+ self.changed_fields = [] # contains a subset of : [ttl, name, type]
+ self.changeSet = [] # all changes for the records of this add_rrest-del_rrest pair
+
+
+ if change_type == "+": # addition
+ self.changed_fields.append("name")
+ self.changed_fields.append("type")
+ self.changed_fields.append("ttl")
+ self.changeSet = get_record_changes(del_rrest, add_rrest)
+ elif change_type == "-": # removal
+ self.changed_fields.append("name")
+ self.changed_fields.append("type")
+ self.changed_fields.append("ttl")
+ self.changeSet = get_record_changes(del_rrest, add_rrest)
+
+ elif change_type == "*": # edit of unchanged
+ if add_rrest['ttl'] != del_rrest['ttl']:
+ self.changed_fields.append("ttl")
+ self.changeSet = get_record_changes(del_rrest, add_rrest)
+
+
+
+ def toDict(self):
+ return {
+ "add_rrest" : self.add_rrest,
+ "del_rrest" : self.del_rrest,
+ "changed_fields" : self.changed_fields,
+ "created_on" : self.history_entry.created_on,
+ "created_by" : self.history_entry.created_by,
+ "change_type" : self.change_type,
+ "changeSet" : self.changeSet
+ }
+
+ def __eq__(self, obj2): # used for removal of objects from a list
+ return True if obj2.toDict() == self.toDict() else False
@admin_bp.before_request
def before_request():
# Manage session timeout
session.permanent = True
+ # current_app.permanent_session_lifetime = datetime.timedelta(
+ # minutes=int(Setting().get('session_timeout')))
current_app.permanent_session_lifetime = datetime.timedelta(
- minutes=int(Setting().get('session_timeout')))
+ minutes=int(Setting().get('session_timeout')))
session.modified = True
@@ -102,17 +261,17 @@ def edit_user(user_username=None):
fdata = request.form
if create:
- user_username = fdata['username']
+ user_username = fdata.get('username', '').strip()
user = User(username=user_username,
- plain_text_password=fdata['password'],
- firstname=fdata['firstname'],
- lastname=fdata['lastname'],
- email=fdata['email'],
+ plain_text_password=fdata.get('password', ''),
+ firstname=fdata.get('firstname', '').strip(),
+ lastname=fdata.get('lastname', '').strip(),
+ email=fdata.get('email', '').strip(),
reload_info=False)
if create:
- if fdata['password'] == "":
+ if not fdata.get('password', ''):
return render_template('admin_edit_user.html',
user=user,
create=create,
@@ -142,6 +301,7 @@ def edit_user(user_username=None):
@operator_role_required
def edit_key(key_id=None):
domains = Domain.query.all()
+ accounts = Account.query.all()
roles = Role.query.all()
apikey = None
create = True
@@ -158,6 +318,7 @@ def edit_key(key_id=None):
return render_template('admin_edit_key.html',
key=apikey,
domains=domains,
+ accounts=accounts,
roles=roles,
create=create)
@@ -165,14 +326,21 @@ def edit_key(key_id=None):
fdata = request.form
description = fdata['description']
role = fdata.getlist('key_role')[0]
- doamin_list = fdata.getlist('key_multi_domain')
+ domain_list = fdata.getlist('key_multi_domain')
+ account_list = fdata.getlist('key_multi_account')
# Create new apikey
if create:
- domain_obj_list = Domain.query.filter(Domain.name.in_(doamin_list)).all()
+ if role == "User":
+ domain_obj_list = Domain.query.filter(Domain.name.in_(domain_list)).all()
+ account_obj_list = Account.query.filter(Account.name.in_(account_list)).all()
+ else:
+ account_obj_list, domain_obj_list = [], []
+
apikey = ApiKey(desc=description,
role_name=role,
- domains=domain_obj_list)
+ domains=domain_obj_list,
+ accounts=account_obj_list)
try:
apikey.create()
except Exception as e:
@@ -186,7 +354,9 @@ def edit_key(key_id=None):
# Update existing apikey
else:
try:
- apikey.update(role,description,doamin_list)
+ if role != "User":
+ domain_list, account_list = [], []
+ apikey.update(role,description,domain_list, account_list)
history_message = "Updated API key {0}".format(apikey.id)
except Exception as e:
current_app.logger.error('Error: {0}'.format(e))
@@ -196,14 +366,16 @@ def edit_key(key_id=None):
'key': apikey.id,
'role': apikey.role.name,
'description': apikey.description,
- 'domain_acl': [domain.name for domain in apikey.domains]
+ 'domains': [domain.name for domain in apikey.domains],
+ 'accounts': [a.name for a in apikey.accounts]
}),
created_by=current_user.username)
history.add()
-
+
return render_template('admin_edit_key.html',
key=apikey,
domains=domains,
+ accounts=accounts,
roles=roles,
create=create,
plain_key=plain_key)
@@ -232,7 +404,7 @@ def manage_keys():
history_apikey_role = apikey.role.name
history_apikey_description = apikey.description
history_apikey_domains = [ domain.name for domain in apikey.domains]
-
+
apikey.delete()
except Exception as e:
current_app.logger.error('Error: {0}'.format(e))
@@ -581,55 +753,498 @@ def manage_account():
}), 400)
+class DetailedHistory():
+ def __init__(self, history, change_set):
+ self.history = history
+ self.detailed_msg = ""
+ self.change_set = change_set
+
+ if not history.detail:
+ self.detailed_msg = ""
+ return
+
+ if 'add_rrest' in history.detail:
+ detail_dict = json.loads(history.detail.replace("\'", ''))
+ else:
+ detail_dict = json.loads(history.detail.replace("'", '"'))
+
+ if 'domain_type' in detail_dict and 'account_id' in detail_dict: # this is a domain creation
+ self.detailed_msg = render_template_string("""
+
+
Domain type:
{{ domaintype }}
+
Account:
{{ account }}
+
+ """,
+ domaintype=detail_dict['domain_type'],
+ account=Account.get_name_by_id(self=None, account_id=detail_dict['account_id']) if detail_dict['account_id'] != "0" else "None")
+
+ elif 'authenticator' in detail_dict: # this is a user authentication
+ self.detailed_msg = render_template_string("""
+
+
+
+
+
User {{ username }} authentication {{ auth_result }}
+
+
+
+
+
+
Authenticator Type:
+
{{ authenticator }}
+
+
+
IP Address
+
{{ ip_address }}
+
+
+
+ """,
+ background_rgba="68,157,68" if detail_dict['success'] == 1 else "201,48,44",
+ username=detail_dict['username'],
+ auth_result="success" if detail_dict['success'] == 1 else "failure",
+ authenticator=detail_dict['authenticator'],
+ ip_address=detail_dict['ip_address'])
+
+ elif 'add_rrests' in detail_dict: # this is a domain record change
+ # changes_set = []
+ self.detailed_msg = ""
+ # extract_changelogs_from_a_history_entry(changes_set, history, 0)
+
+ elif 'name' in detail_dict and 'template' in history.msg: # template creation / deletion
+ self.detailed_msg = render_template_string("""
+
+
Template name:
{{ template_name }}
+
Description:
{{ description }}
+
+ """,
+ template_name=DetailedHistory.get_key_val(detail_dict, "name"),
+ description=DetailedHistory.get_key_val(detail_dict, "description"))
+
+ elif 'Change domain' in history.msg and 'access control' in history.msg: # added or removed a user from a domain
+ users_with_access = DetailedHistory.get_key_val(detail_dict, "user_has_access")
+ self.detailed_msg = render_template_string("""
+
+
Users with access to this domain
{{ users_with_access }}
+
Number of users:
{{ users_with_access | length }}
+
+ """,
+ users_with_access=users_with_access)
+
+ elif 'Created API key' in history.msg or 'Updated API key' in history.msg:
+ self.detailed_msg = render_template_string("""
+
+
Key:
{{ keyname }}
+
Role:
{{ rolename }}
+
Description:
{{ description }}
+
Accessible domains with this API key:
{{ linked_domains }}
+
Accessible accounts with this API key:
{{ linked_accounts }}
+
+ """,
+ keyname=DetailedHistory.get_key_val(detail_dict, "key"),
+ rolename=DetailedHistory.get_key_val(detail_dict, "role"),
+ description=DetailedHistory.get_key_val(detail_dict, "description"),
+ linked_domains=DetailedHistory.get_key_val(detail_dict, "domains" if "domains" in detail_dict else "domain_acl"),
+ linked_accounts=DetailedHistory.get_key_val(detail_dict, "accounts"))
+
+ elif 'Delete API key' in history.msg:
+ self.detailed_msg = render_template_string("""
+
+
Key:
{{ keyname }}
+
Role:
{{ rolename }}
+
Description:
{{ description }}
+
Accessible domains with this API key:
{{ linked_domains }}
+
+ """,
+ keyname=DetailedHistory.get_key_val(detail_dict, "key"),
+ rolename=DetailedHistory.get_key_val(detail_dict, "role"),
+ description=DetailedHistory.get_key_val(detail_dict, "description"),
+ linked_domains=DetailedHistory.get_key_val(detail_dict, "domains"))
+
+ elif 'Update type for domain' in history.msg:
+ self.detailed_msg = render_template_string("""
+