Compare commits

..

1 commit

Author SHA1 Message Date
dependabot[bot] fa4ebd3f54
Bump jquery-ui from 1.12.1 to 1.13.0
Bumps [jquery-ui](https://github.com/jquery/jquery-ui) from 1.12.1 to 1.13.0.
- [Release notes](https://github.com/jquery/jquery-ui/releases)
- [Commits](https://github.com/jquery/jquery-ui/compare/1.12.1...1.13.0)

---
updated-dependencies:
- dependency-name: jquery-ui
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-10-26 14:58:46 +00:00
55 changed files with 2376 additions and 3316 deletions

1
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1 @@
github: [ngoduykhanh]

View file

@ -1,56 +0,0 @@
on:
push:
branches:
- 'master'
tags:
- 'v*.*.*'
jobs:
build-and-push-docker-image:
name: Build Docker image and push to repositories
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Docker meta
id: meta
uses: docker/metadata-action@v3
with:
images: |
ngoduykhanh/powerdns-admin
tags: |
type=ref,event=tag
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build latest image
uses: docker/build-push-action@v2
if: github.ref == 'refs/heads/master'
with:
context: ./
file: ./docker/Dockerfile
push: true
tags: ngoduykhanh/powerdns-admin:latest
- name: Build release image
uses: docker/build-push-action@v2
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
with:
context: ./
file: ./docker/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

5
.travis.yml Normal file
View file

@ -0,0 +1,5 @@
language: minimal
script:
- docker-compose -f docker-compose-test.yml up --exit-code-from powerdns-admin --abort-on-container-exit
services:
- docker

View file

@ -1,6 +1,7 @@
# PowerDNS-Admin
A PowerDNS web interface with advanced features.
[![Build Status](https://travis-ci.org/ngoduykhanh/PowerDNS-Admin.svg?branch=master)](https://travis-ci.org/ngoduykhanh/PowerDNS-Admin)
[![Language grade: Python](https://img.shields.io/lgtm/grade/python/g/ngoduykhanh/PowerDNS-Admin.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/ngoduykhanh/PowerDNS-Admin/context:python)
[![Language grade: JavaScript](https://img.shields.io/lgtm/grade/javascript/g/ngoduykhanh/PowerDNS-Admin.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/ngoduykhanh/PowerDNS-Admin/context:javascript)
@ -58,3 +59,7 @@ 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*
<a href="https://www.buymeacoffee.com/khanhngo" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/default-orange.png" alt="Buy Me A Coffee" height="41" width="174"></a>

View file

@ -1,6 +1,5 @@
import os
#import urllib.parse
basedir = os.path.abspath(os.path.dirname(__file__))
basedir = os.path.abspath(os.path.abspath(os.path.dirname(__file__)))
### BASIC APP CONFIG
SALT = '$2b$12$yLUMTIfl21FKJQpTkRQXCu'
@ -17,12 +16,7 @@ SQLA_DB_NAME = 'pda'
SQLALCHEMY_TRACK_MODIFICATIONS = True
### DATABASE - MySQL
#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
#)
# SQLALCHEMY_DATABASE_URI = 'mysql://' + SQLA_DB_USER + ':' + SQLA_DB_PASSWORD + '@' + SQLA_DB_HOST + '/' + SQLA_DB_NAME
### DATABASE - SQLite
SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'pdns.db')

View file

@ -5,9 +5,6 @@ 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',

View file

@ -1,5 +1,5 @@
import os
basedir = os.path.abspath(os.path.dirname(__file__))
basedir = os.path.abspath(os.path.abspath(os.path.dirname(__file__)))
### BASIC APP CONFIG
SALT = '$2b$12$yLUMTIfl21FKJQpTkRQXCu'

View file

@ -65,6 +65,14 @@ RUN mkdir -p /app && \
mkdir -p /app/configs && \
cp -r /build/configs/docker_config.py /app/configs
# Cleanup
RUN pip install pip-autoremove && \
pip-autoremove cssmin -y && \
pip-autoremove jsmin -y && \
pip-autoremove pytest -y -L packaging && \
pip uninstall -y pip-autoremove && \
apk del ${BUILD_DEPENDENCIES}
# Build image
FROM alpine:3.13

View file

@ -1,134 +1,105 @@
### 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. 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
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:
#### 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 harmless 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 <method> <url>
```
When you access the `/server` endpoint, you must use the ApiKey
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:
```bash
# Use the already base64 encoded key in your header
curl -H 'X-API-Key: YUdDdGhQM0tMQWV5alpJ' -X <method> <url>
```
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 an apikey which has the Administrator role:
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:
```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)
```json
[
{
"accounts": [],
"description": "masterkey",
"domains": [],
"role": {
"name": "Administrator",
"id": 1
},
"id": 2,
"plain_key": "aGCthP3KLAeyjZI"
}
]
call above will return response like this:
```
[{"description": "samekey", "domains": [], "role": {"name": "Administrator", "id": 1}, "id": 2, "plain_key": "aGCthP3KLAeyjZI"}]
```
We can use the apikey for all calls to PowerDNS (don't forget to specify Content-Type):
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:
Getting powerdns configuration (Administrator Key is needed):
```
$ echo -n 'aGCthP3KLAeyjZI'|base64
YUdDdGhQM0tMQWV5alpJ
```
```bash
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:
```
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 a domain:
getting 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 a zone's records:
list zone records:
```bash
```
curl -H 'Content-Type: application/json' -H 'X-API-Key: YUdDdGhQM0tMQWV5alpJ' http://localhost:9191/api/v1/servers/localhost/zones/yourdomain.com
```
Add a new record:
add 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 a record:
update 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 a record:
delete 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
```

View file

@ -17,83 +17,4 @@ 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.
#### 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://<pdnsa address>/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://<keycloak url>/auth/realms/<realm>/protocol/openid-connect/
* Token URL: https://<keycloak url>/auth/realms/<realm>/protocol/openid-connect/token
* Authorize URL: https://<keycloak url>/auth/realms/<realm>/protocol/openid-connect/auth
* Logout URL: https://<keycloak url>/auth/realms/<realm>/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, <oidc_provider_link>/auth (The ending can be different with each provider)
* Token URL, <oidc_provider_link>/token
* Authorize URL, <oidc_provider_link>/auth
* Logout URL, <oidc_provider_link>/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.
This should allow you to log in using OAuth.

View file

@ -1,41 +0,0 @@
"""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 ###

View file

@ -2,7 +2,6 @@
"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",

View file

@ -55,8 +55,6 @@ 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')):

View file

@ -23,7 +23,6 @@ css_login = Bundle('node_modules/bootstrap/dist/css/bootstrap.css',
js_login = Bundle('node_modules/jquery/dist/jquery.js',
'node_modules/bootstrap/dist/js/bootstrap.js',
'node_modules/icheck/icheck.js',
'custom/js/custom.js',
filters=(ConcatFilter, 'jsmin'),
output='generated/login.js')
@ -40,7 +39,6 @@ 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')
@ -60,7 +58,6 @@ 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')

View file

@ -93,23 +93,6 @@ def can_configure_dnssec(f):
return decorated_function
def can_remove_domain(f):
"""
Grant access if:
- user is in Operator role or higher, or
- allow_user_remove_domain is on
"""
@wraps(f)
def decorated_function(*args, **kwargs):
if current_user.role.name not in [
'Administrator', 'Operator'
] and not Setting().get('allow_user_remove_domain'):
abort(403)
return f(*args, **kwargs)
return decorated_function
def can_create_domain(f):
"""
@ -192,24 +175,6 @@ 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:
@ -264,48 +229,6 @@ 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
@ -322,52 +245,21 @@ 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 g.apikey.domains]
domain_names = [item.name for item in domains]
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:
if zone_id not in domain_names:
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):

View file

@ -1,6 +1,5 @@
import os
import urllib.parse
basedir = os.path.abspath(os.path.dirname(__file__))
basedir = os.path.abspath(os.path.abspath(os.path.dirname(__file__)))
### BASIC APP CONFIG
SALT = '$2b$12$yLUMTIfl21FKJQpTkRQXCu'
@ -19,12 +18,7 @@ SQLA_DB_NAME = 'pda'
SQLALCHEMY_TRACK_MODIFICATIONS = True
### DATABASE - MySQL
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
)
SQLALCHEMY_DATABASE_URI = 'mysql://'+SQLA_DB_USER+':'+SQLA_DB_PASSWORD+'@'+SQLA_DB_HOST+'/'+SQLA_DB_NAME
### DATABASE - SQLite
# SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'pdns.db')

View file

@ -60,8 +60,7 @@ class ApiKeyNotUsable(StructuredException):
def __init__(
self,
name=None,
message=("Api key must have domains or accounts"
" or an administrative role")):
message="Api key must have domains or have administrative role"):
StructuredException.__init__(self)
self.message = message
self.name = name
@ -121,15 +120,6 @@ 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

View file

@ -11,21 +11,10 @@ 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()
@ -34,11 +23,15 @@ 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()
@ -54,7 +47,7 @@ class UserDetailedSchema(Schema):
lastname = fields.String()
email = fields.String()
role = fields.Embed(schema=RoleSchema)
accounts = fields.Embed(schema=AccountSummarySchema, many=True)
accounts = fields.Embed(schema=AccountSummarySchema)
class AccountSchema(Schema):
id = fields.Integer()
@ -63,4 +56,3 @@ class AccountSchema(Schema):
contact = fields.String()
mail = fields.String()
domains = fields.Embed(schema=DomainSchema, many=True)
apikeys = fields.Embed(schema=ApiKeySummarySchema, many=True)

View file

@ -104,13 +104,6 @@ def fetch_json(remote_url,
data = None
try:
data = json.loads(r.content.decode('utf-8'))
except UnicodeDecodeError:
# If the decoding fails, switch to slower but probably working .json()
try:
logging.warning("UTF-8 content.decode failed, switching to slower .json method")
data = r.json()
except Exception as e:
raise e
except Exception as e:
raise RuntimeError(
'Error while loading JSON data from {0}'.format(remote_url)) from e
@ -246,7 +239,7 @@ def pretty_domain_name(value):
"""
if isinstance(value, str):
if value.startswith('xn--') \
or value.find('.xn--') != -1:
or value.find('.xn--'):
try:
return value.encode().decode('idna')
except:

View file

@ -8,7 +8,6 @@ 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

View file

@ -17,9 +17,6 @@ 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

View file

@ -1,12 +1,12 @@
import secrets
import random
import string
import bcrypt
from flask import current_app
from .base import db
from .base import db, domain_apikey
from ..models.role import Role
from ..models.domain import Domain
from ..models.account import Account
class ApiKey(db.Model):
__tablename__ = "apikey"
@ -16,21 +16,17 @@ 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=[], accounts=[]):
def __init__(self, key=None, desc=None, role_name=None, domains=[]):
self.id = None
self.description = desc
self.role_name = role_name
self.domains[:] = domains
self.accounts[:] = accounts
if not key:
rand_key = ''.join(
secrets.choice(string.ascii_letters + string.digits)
random.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')
@ -58,7 +54,7 @@ class ApiKey(db.Model):
db.session.rollback()
raise e
def update(self, role_name=None, description=None, domains=None, accounts=None):
def update(self, role_name=None, description=None, domains=None):
try:
if role_name:
role = Role.query.filter(Role.name == role_name).first()
@ -67,18 +63,12 @@ class ApiKey(db.Model):
if description:
self.description = description
if domains is not None:
if domains:
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}'
@ -97,15 +87,6 @@ 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'))
@ -131,12 +112,3 @@ 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

View file

@ -1,20 +0,0 @@
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 '<ApiKey_Account {0} {1}>'.format(self.apikey_id, self.account_id)

View file

@ -8,7 +8,6 @@ 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())

View file

@ -65,9 +65,6 @@ class Record(object):
rrsets=[]
for r in jdata['rrsets']:
if len(r['records']) == 0:
continue
while len(r['comments'])<len(r['records']):
r['comments'].append({"content": "", "account": ""})
r['records'], r['comments'] = (list(t) for t in zip(*sorted(zip(r['records'], r['comments']), key=by_record_content_pair)))
@ -165,8 +162,6 @@ class Record(object):
for record in submitted_records:
# Format the record name
#
# Translate template placeholders into proper record data
record['record_data'] = record['record_data'].replace('[ZONE]', domain_name)
# Translate record name into punycode (IDN) as that's the only way
# to convey non-ascii records to the dns server
record['record_name'] = record['record_name'].encode('idna').decode()

View file

@ -26,11 +26,8 @@ class Setting(db.Model):
'pretty_ipv6_ptr': False,
'dnssec_admins_only': False,
'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,
@ -187,10 +184,6 @@ class Setting(db.Model):
'URI': False
},
'ttl_options': '1 minute,5 minutes,30 minutes,60 minutes,24 hours',
'otp_field_enabled': True,
'custom_css': '',
'otp_force': False,
'max_history_records': 1000
}
def __init__(self, id=None, name=None, value=None):
@ -271,23 +264,16 @@ class Setting(db.Model):
def get(self, setting):
if setting in self.defaults:
if setting.upper() in current_app.config:
result = current_app.config[setting.upper()]
else:
result = self.query.filter(Setting.name == setting).first()
result = self.query.filter(Setting.name == setting).first()
if result is not None:
if hasattr(result,'value'):
result = result.value
return strtobool(result) if result in [
return strtobool(result.value) if result.value in [
'True', 'False'
] else result
] else result.value
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() +

View file

@ -8,9 +8,6 @@ import ldap.filter
from flask import current_app
from flask_login import AnonymousUserMixin
from sqlalchemy import orm
import qrcode as qrc
import qrcode.image.svg as qrc_svg
from io import BytesIO
from .base import db
from .role import Role
@ -631,18 +628,10 @@ 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])
return accounts
def get_qrcode_value(self):
img = qrc.make(self.get_totp_uri(),
image_factory=qrc_svg.SvgPathImage)
stream = BytesIO()
img.save(stream)
return stream.getvalue()
def read_entitlements(self, key):
@ -804,4 +793,7 @@ def getUserInfo(DomainsOrAccounts):
current=[]
for DomainOrAccount in DomainsOrAccounts:
current.append(DomainOrAccount.name)
return current
return current

View file

@ -4,7 +4,7 @@ import traceback
import re
from base64 import b64encode
from ast import literal_eval
from flask import Blueprint, render_template, render_template_string, make_response, url_for, current_app, request, redirect, jsonify, abort, flash, session
from flask import Blueprint, render_template, 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,172 +32,13 @@ 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
@ -261,17 +102,17 @@ def edit_user(user_username=None):
fdata = request.form
if create:
user_username = fdata.get('username', '').strip()
user_username = fdata['username']
user = User(username=user_username,
plain_text_password=fdata.get('password', ''),
firstname=fdata.get('firstname', '').strip(),
lastname=fdata.get('lastname', '').strip(),
email=fdata.get('email', '').strip(),
plain_text_password=fdata['password'],
firstname=fdata['firstname'],
lastname=fdata['lastname'],
email=fdata['email'],
reload_info=False)
if create:
if not fdata.get('password', ''):
if fdata['password'] == "":
return render_template('admin_edit_user.html',
user=user,
create=create,
@ -301,7 +142,6 @@ 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
@ -318,7 +158,6 @@ def edit_key(key_id=None):
return render_template('admin_edit_key.html',
key=apikey,
domains=domains,
accounts=accounts,
roles=roles,
create=create)
@ -326,21 +165,14 @@ def edit_key(key_id=None):
fdata = request.form
description = fdata['description']
role = fdata.getlist('key_role')[0]
domain_list = fdata.getlist('key_multi_domain')
account_list = fdata.getlist('key_multi_account')
doamin_list = fdata.getlist('key_multi_domain')
# Create new apikey
if create:
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 = [], []
domain_obj_list = Domain.query.filter(Domain.name.in_(doamin_list)).all()
apikey = ApiKey(desc=description,
role_name=role,
domains=domain_obj_list,
accounts=account_obj_list)
domains=domain_obj_list)
try:
apikey.create()
except Exception as e:
@ -354,9 +186,7 @@ def edit_key(key_id=None):
# Update existing apikey
else:
try:
if role != "User":
domain_list, account_list = [], []
apikey.update(role,description,domain_list, account_list)
apikey.update(role,description,doamin_list)
history_message = "Updated API key {0}".format(apikey.id)
except Exception as e:
current_app.logger.error('Error: {0}'.format(e))
@ -366,16 +196,14 @@ def edit_key(key_id=None):
'key': apikey.id,
'role': apikey.role.name,
'description': apikey.description,
'domains': [domain.name for domain in apikey.domains],
'accounts': [a.name for a in apikey.accounts]
'domain_acl': [domain.name for domain in apikey.domains]
}),
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)
@ -404,7 +232,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))
@ -753,498 +581,55 @@ 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("""
<table class="table table-bordered table-striped">
<tr><td>Domain type:</td><td>{{ domaintype }}</td></tr>
<tr><td>Account:</td><td>{{ account }}</td></tr>
</table>
""",
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("""
<table class="table table-bordered table-striped" style="width:565px;">
<thead>
<tr>
<th colspan="3" style="background: rgba({{ background_rgba }});">
<p style="color:white;">User {{ username }} authentication {{ auth_result }}</p>
</th>
</tr>
</thead>
<tbody>
<tr>
<td>Authenticator Type:</td>
<td colspan="2">{{ authenticator }}</td>
</tr>
<tr>
<td>IP Address</td>
<td colspan="2">{{ ip_address }}</td>
</tr>
</tbody>
</table>
""",
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("""
<table class="table table-bordered table-striped">
<tr><td>Template name:</td><td>{{ template_name }}</td></tr>
<tr><td>Description:</td><td>{{ description }}</td></tr>
</table>
""",
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("""
<table class="table table-bordered table-striped">
<tr><td>Users with access to this domain</td><td>{{ users_with_access }}</td></tr>
<tr><td>Number of users:</td><td>{{ users_with_access | length }}</td><tr>
</table>
""",
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("""
<table class="table table-bordered table-striped">
<tr><td>Key: </td><td>{{ keyname }}</td></tr>
<tr><td>Role:</td><td>{{ rolename }}</td></tr>
<tr><td>Description:</td><td>{{ description }}</td></tr>
<tr><td>Accessible domains with this API key:</td><td>{{ linked_domains }}</td></tr>
<tr><td>Accessible accounts with this API key:</td><td>{{ linked_accounts }}</td></tr>
</table>
""",
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("""
<table class="table table-bordered table-striped">
<tr><td>Key: </td><td>{{ keyname }}</td></tr>
<tr><td>Role:</td><td>{{ rolename }}</td></tr>
<tr><td>Description:</td><td>{{ description }}</td></tr>
<tr><td>Accessible domains with this API key:</td><td>{{ linked_domains }}</td></tr>
</table>
""",
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("""
<table class="table table-bordered table-striped">
<tr><td>Domain: </td><td>{{ domain }}</td></tr>
<tr><td>Domain type:</td><td>{{ domain_type }}</td></tr>
<tr><td>Masters:</td><td>{{ masters }}</td></tr>
</table>
""",
domain=DetailedHistory.get_key_val(detail_dict, "domain"),
domain_type=DetailedHistory.get_key_val(detail_dict, "type"),
masters=DetailedHistory.get_key_val(detail_dict, "masters"))
elif 'reverse' in history.msg:
self.detailed_msg = render_template_string("""
<table class="table table-bordered table-striped">
<tr><td>Domain Type: </td><td>{{ domain_type }}</td></tr>
<tr><td>Domain Master IPs:</td><td>{{ domain_master_ips }}</td></tr>
</table>
""",
domain_type=DetailedHistory.get_key_val(detail_dict, "domain_type"),
domain_master_ips=DetailedHistory.get_key_val(detail_dict, "domain_master_ips"))
# check for lower key as well for old databases
@staticmethod
def get_key_val(_dict, key):
return str(_dict.get(key, _dict.get(key.title(), '')))
# convert a list of History objects into DetailedHistory objects
def convert_histories(histories):
changes_set = dict()
detailedHistories = []
j = 0
for i in range(len(histories)):
if histories[i].detail and ('add_rrests' in histories[i].detail or 'del_rrests' in histories[i].detail):
extract_changelogs_from_a_history_entry(changes_set, histories[i], j)
if j in changes_set:
detailedHistories.append(DetailedHistory(histories[i], changes_set[j]))
else: # no changes were found
detailedHistories.append(DetailedHistory(histories[i], None))
j += 1
else:
detailedHistories.append(DetailedHistory(histories[i], None))
return detailedHistories
@admin_bp.route('/history', methods=['GET', 'POST'])
@login_required
@history_access_required
def history():
if request.method == 'POST':
if current_user.role.name != 'Administrator':
return make_response(
jsonify({
'status': 'error',
'msg': 'You do not have permission to remove history.'
}), 401)
if request.method == 'POST':
if current_user.role.name != 'Administrator':
return make_response(
jsonify({
'status': 'error',
'msg': 'You do not have permission to remove history.'
}), 401)
h = History()
result = h.remove_all()
if result:
history = History(msg='Remove all histories',
created_by=current_user.username)
history.add()
return make_response(
jsonify({
'status': 'ok',
'msg': 'Changed user role successfully.'
}), 200)
else:
return make_response(
jsonify({
'status': 'error',
'msg': 'Can not remove histories.'
}), 500)
h = History()
result = h.remove_all()
if result:
history = History(msg='Remove all histories',
created_by=current_user.username)
history.add()
return make_response(
jsonify({
'status': 'ok',
'msg': 'Changed user role successfully.'
}), 200)
else:
return make_response(
jsonify({
'status': 'error',
'msg': 'Can not remove histories.'
}), 500)
if request.method == 'GET':
if current_user.role.name in [ 'Administrator', 'Operator' ]:
histories = History.query.all()
else:
# if the user isn't an administrator or operator,
# allow_user_view_history must be enabled to get here,
# so include history for the domains for the user
histories = db.session.query(History) \
.join(Domain, History.domain_id == Domain.id) \
.outerjoin(DomainUser, Domain.id == DomainUser.domain_id) \
.outerjoin(Account, Domain.account_id == Account.id) \
.outerjoin(AccountUser, Account.id == AccountUser.account_id) \
.filter(
db.or_(
DomainUser.user_id == current_user.id,
AccountUser.user_id == current_user.id
))
if request.method == 'GET':
doms = accounts = users = ""
if current_user.role.name in [ 'Administrator', 'Operator']:
all_domain_names = Domain.query.all()
all_account_names = Account.query.all()
all_user_names = User.query.all()
for d in all_domain_names:
doms += d.name + " "
for acc in all_account_names:
accounts += acc.name + " "
for usr in all_user_names:
users += usr.username + " "
else: # special autocomplete for users
all_domain_names = db.session.query(Domain) \
.outerjoin(DomainUser, Domain.id == DomainUser.domain_id) \
.outerjoin(Account, Domain.account_id == Account.id) \
.outerjoin(AccountUser, Account.id == AccountUser.account_id) \
.filter(
db.or_(
DomainUser.user_id == current_user.id,
AccountUser.user_id == current_user.id
)).all()
all_account_names = db.session.query(Account) \
.outerjoin(Domain, Domain.account_id == Account.id) \
.outerjoin(DomainUser, Domain.id == DomainUser.domain_id) \
.outerjoin(AccountUser, Account.id == AccountUser.account_id) \
.filter(
db.or_(
DomainUser.user_id == current_user.id,
AccountUser.user_id == current_user.id
)).all()
all_user_names = []
for a in all_account_names:
temp = db.session.query(User) \
.join(AccountUser, AccountUser.user_id == User.id) \
.outerjoin(Account, Account.id == AccountUser.account_id) \
.filter(
db.or_(
Account.id == a.id,
AccountUser.account_id == a.id
)
) \
.all()
for u in temp:
if u in all_user_names:
continue
all_user_names.append(u)
for d in all_domain_names:
doms += d.name + " "
for a in all_account_names:
accounts += a.name + " "
for u in all_user_names:
users += u.username + " "
return render_template('admin_history.html', all_domain_names=doms, all_account_names=accounts, all_usernames=users)
# local_offset is the offset of the utc to the local time
# offset must be int
# return the date converted and simplified
def from_utc_to_local(local_offset, timeframe):
offset = str(local_offset *(-1))
date_split = str(timeframe).split(".")[0]
date_converted = datetime.datetime.strptime(date_split, '%Y-%m-%d %H:%M:%S') + datetime.timedelta(minutes=int(offset))
return date_converted
@admin_bp.route('/history_table', methods=['GET', 'POST'])
@login_required
@history_access_required
def history_table(): # ajax call data
if request.method == 'POST':
if current_user.role.name != 'Administrator':
return make_response(
jsonify({
'status': 'error',
'msg': 'You do not have permission to remove history.'
}), 401)
h = History()
result = h.remove_all()
if result:
history = History(msg='Remove all histories',
created_by=current_user.username)
history.add()
return make_response(
jsonify({
'status': 'ok',
'msg': 'Changed user role successfully.'
}), 200)
else:
return make_response(
jsonify({
'status': 'error',
'msg': 'Can not remove histories.'
}), 500)
detailedHistories = []
lim = int(Setting().get('max_history_records')) # max num of records
if request.method == 'GET':
if current_user.role.name in [ 'Administrator', 'Operator' ]:
base_query = History.query
else:
# if the user isn't an administrator or operator,
# allow_user_view_history must be enabled to get here,
# so include history for the domains for the user
base_query = db.session.query(History) \
.join(Domain, History.domain_id == Domain.id) \
.outerjoin(DomainUser, Domain.id == DomainUser.domain_id) \
.outerjoin(Account, Domain.account_id == Account.id) \
.outerjoin(AccountUser, Account.id == AccountUser.account_id) \
.filter(
db.or_(
DomainUser.user_id == current_user.id,
AccountUser.user_id == current_user.id
))
domain_name = request.args.get('domain_name_filter') if request.args.get('domain_name_filter') != None \
and len(request.args.get('domain_name_filter')) != 0 else None
account_name = request.args.get('account_name_filter') if request.args.get('account_name_filter') != None \
and len(request.args.get('account_name_filter')) != 0 else None
user_name = request.args.get('auth_name_filter') if request.args.get('auth_name_filter') != None \
and len(request.args.get('auth_name_filter')) != 0 else None
min_date = request.args.get('min') if request.args.get('min') != None and len( request.args.get('min')) != 0 else None
if min_date != None: # get 1 day earlier, to check for timezone errors
min_date = str(datetime.datetime.strptime(min_date, '%Y-%m-%d') - datetime.timedelta(days=1))
max_date = request.args.get('max') if request.args.get('max') != None and len( request.args.get('max')) != 0 else None
if max_date != None: # get 1 day later, to check for timezone errors
max_date = str(datetime.datetime.strptime(max_date, '%Y-%m-%d') + datetime.timedelta(days=1))
tzoffset = request.args.get('tzoffset') if request.args.get('tzoffset') != None and len(request.args.get('tzoffset')) != 0 else None
changed_by = request.args.get('user_name_filter') if request.args.get('user_name_filter') != None \
and len(request.args.get('user_name_filter')) != 0 else None
"""
Auth methods: LOCAL, Github OAuth, Azure OAuth, SAML, OIDC OAuth, Google OAuth
"""
auth_methods = []
if (request.args.get('auth_local_only_checkbox') is None \
and request.args.get('auth_oauth_only_checkbox') is None \
and request.args.get('auth_saml_only_checkbox') is None and request.args.get('auth_all_checkbox') is None):
auth_methods = []
if request.args.get('auth_all_checkbox') == "on":
auth_methods.append("")
if request.args.get('auth_local_only_checkbox') == "on":
auth_methods.append("LOCAL")
if request.args.get('auth_oauth_only_checkbox') == "on":
auth_methods.append("OAuth")
if request.args.get('auth_saml_only_checkbox') == "on":
auth_methods.append("SAML")
if request.args.get('domain_changelog_only_checkbox') != None:
changelog_only = True if request.args.get('domain_changelog_only_checkbox') == "on" else False
else:
changelog_only = False
# users cannot search for authentication
if user_name != None and current_user.role.name not in [ 'Administrator', 'Operator']:
histories = []
elif domain_name != None:
if not changelog_only:
histories = base_query \
.filter(
db.and_(
db.or_(
History.msg.like("%domain "+ domain_name) if domain_name != "*" else History.msg.like("%domain%"),
History.msg.like("%domain "+ domain_name + " access control") if domain_name != "*" else History.msg.like("%domain%access control")
),
History.created_on <= max_date if max_date != None else True,
History.created_on >= min_date if min_date != None else True,
History.created_by == changed_by if changed_by != None else True
)
).order_by(History.created_on.desc()).limit(lim).all()
else:
# search for records changes only
histories = base_query \
.filter(
db.and_(
History.msg.like("Apply record changes to domain " + domain_name) if domain_name != "*" \
else History.msg.like("Apply record changes to domain%"),
History.created_on <= max_date if max_date != None else True,
History.created_on >= min_date if min_date != None else True,
History.created_by == changed_by if changed_by != None else True
)
).order_by(History.created_on.desc()) \
.limit(lim).all()
elif account_name != None:
if current_user.role.name in ['Administrator', 'Operator']:
histories = base_query \
.join(Domain, History.domain_id == Domain.id) \
.outerjoin(Account, Domain.account_id == Account.id) \
.filter(
db.and_(
Account.id == Domain.account_id,
account_name == Account.name if account_name != "*" else True,
History.created_on <= max_date if max_date != None else True,
History.created_on >= min_date if min_date != None else True,
History.created_by == changed_by if changed_by != None else True
)
).order_by(History.created_on.desc()) \
.limit(lim).all()
else:
histories = base_query \
.filter(
db.and_(
Account.id == Domain.account_id,
account_name == Account.name if account_name != "*" else True,
History.created_on <= max_date if max_date != None else True,
History.created_on >= min_date if min_date != None else True,
History.created_by == changed_by if changed_by != None else True
)
).order_by(History.created_on.desc()) \
.limit(lim).all()
elif user_name != None and current_user.role.name in [ 'Administrator', 'Operator']: # only admins can see the user login-logouts
histories = History.query \
.filter(
db.and_(
db.or_(
History.msg.like("User "+ user_name + " authentication%") if user_name != "*" and user_name != None else History.msg.like("%authentication%"),
History.msg.like("User "+ user_name + " was not authorized%") if user_name != "*" and user_name != None else History.msg.like("User%was not authorized%")
),
History.created_on <= max_date if max_date != None else True,
History.created_on >= min_date if min_date != None else True,
History.created_by == changed_by if changed_by != None else True
)
) \
.order_by(History.created_on.desc()).limit(lim).all()
temp = []
for h in histories:
for method in auth_methods:
if method in h.detail:
temp.append(h)
break
histories = temp
elif (changed_by != None or max_date != None) and current_user.role.name in [ 'Administrator', 'Operator'] : # select changed by and date filters only
histories = History.query \
.filter(
db.and_(
History.created_on <= max_date if max_date != None else True,
History.created_on >= min_date if min_date != None else True,
History.created_by == changed_by if changed_by != None else True
)
) \
.order_by(History.created_on.desc()).limit(lim).all()
elif (changed_by != None or max_date != None): # special filtering for user because one user does not have access to log-ins logs
histories = base_query \
.filter(
db.and_(
History.created_on <= max_date if max_date != None else True,
History.created_on >= min_date if min_date != None else True,
History.created_by == changed_by if changed_by != None else True
)
) \
.order_by(History.created_on.desc()).limit(lim).all()
elif max_date != None: # if changed by == null and only date is applied
histories = base_query.filter(
db.and_(
History.created_on <= max_date if max_date != None else True,
History.created_on >= min_date if min_date != None else True,
)
).order_by(History.created_on.desc()).limit(lim).all()
else: # default view
if current_user.role.name in [ 'Administrator', 'Operator']:
histories = History.query.order_by(History.created_on.desc()).limit(lim).all()
else:
histories = db.session.query(History) \
.join(Domain, History.domain_id == Domain.id) \
.outerjoin(DomainUser, Domain.id == DomainUser.domain_id) \
.outerjoin(Account, Domain.account_id == Account.id) \
.outerjoin(AccountUser, Account.id == AccountUser.account_id) \
.order_by(History.created_on.desc()) \
.filter(
db.or_(
DomainUser.user_id == current_user.id,
AccountUser.user_id == current_user.id
)).limit(lim).all()
detailedHistories = convert_histories(histories)
# Remove dates from previous or next day that were brought over
if tzoffset != None:
if min_date != None:
min_date_split = min_date.split()[0]
if max_date != None:
max_date_split = max_date.split()[0]
for i, history_rec in enumerate(detailedHistories):
local_date = str(from_utc_to_local(int(tzoffset), history_rec.history.created_on).date())
if (min_date != None and local_date == min_date_split) or (max_date != None and local_date == max_date_split):
detailedHistories[i] = None
# Remove elements previously flagged as None
detailedHistories = [h for h in detailedHistories if h is not None]
return render_template('admin_history_table.html', histories=detailedHistories, len_histories=len(detailedHistories), lim=lim)
return render_template('admin_history.html', histories=histories)
@admin_bp.route('/setting/basic', methods=['GET'])
@ -1257,10 +642,9 @@ def setting_basic():
'login_ldap_first', 'default_record_table_size',
'default_domain_table_size', 'auto_ptr', 'record_quick_edit',
'pretty_ipv6_ptr', 'dnssec_admins_only',
'allow_user_create_domain', 'allow_user_remove_domain', 'allow_user_view_history', 'bg_domain_updates', 'site_name',
'allow_user_create_domain', 'allow_user_view_history', 'bg_domain_updates', 'site_name',
'session_timeout', 'warn_session_timeout', 'ttl_options',
'pdns_api_timeout', 'verify_ssl_connections', 'verify_user_email',
'delete_sso_accounts', 'otp_field_enabled', 'custom_css', 'enable_api_rr_history', 'max_history_records', 'otp_force'
'pdns_api_timeout', 'verify_ssl_connections', 'verify_user_email'
]
return render_template('admin_setting_basic.html', settings=settings)
@ -1602,8 +986,6 @@ def setting_authentication():
request.form.get('oidc_oauth_token_url'))
Setting().set('oidc_oauth_authorize_url',
request.form.get('oidc_oauth_authorize_url'))
Setting().set('oidc_oauth_logout_url',
request.form.get('oidc_oauth_logout_url'))
Setting().set('oidc_oauth_username',
request.form.get('oidc_oauth_username'))
Setting().set('oidc_oauth_firstname',

View file

@ -21,18 +21,16 @@ from ..lib.errors import (
DomainNotExists, DomainAlreadyExists, DomainAccessForbidden,
RequestIsNotJSON, ApiKeyCreateFail, ApiKeyNotUsable, NotEnoughPrivileges,
AccountCreateFail, AccountUpdateFail, AccountDeleteFail,
AccountCreateDuplicate, AccountNotExists,
AccountCreateDuplicate,
UserCreateFail, UserCreateDuplicate, UserUpdateFail, UserDeleteFail,
UserUpdateFailEmail,
)
from ..decorators import (
api_basic_auth, api_can_create_domain, is_json, apikey_auth,
apikey_can_create_domain, apikey_can_remove_domain,
apikey_is_admin, apikey_can_access_domain, apikey_can_configure_dnssec,
api_role_can, apikey_or_basic_auth,
callback_if_request_body_contains_key,
apikey_is_admin, apikey_can_access_domain, api_role_can,
apikey_or_basic_auth,
)
import secrets
import random
import string
api_bp = Blueprint('api', __name__, url_prefix='/api/v1')
@ -309,7 +307,6 @@ def api_generate_apikey():
role_name = None
apikey = None
domain_obj_list = []
account_obj_list = []
abort(400) if 'role' not in data else None
@ -320,13 +317,6 @@ def api_generate_apikey():
else:
domains = [d['name'] if isinstance(d, dict) else d for d in data['domains']]
if 'accounts' not in data:
accounts = []
elif not isinstance(data['accounts'], (list, )):
abort(400)
else:
accounts = [a['name'] if isinstance(a, dict) else a for a in data['accounts']]
description = data['description'] if 'description' in data else None
if isinstance(data['role'], str):
@ -336,24 +326,16 @@ def api_generate_apikey():
else:
abort(400)
if role_name == 'User' and len(domains) == 0 and len(accounts) == 0:
current_app.logger.error("Apikey with User role must have domains or accounts")
if role_name == 'User' and len(domains) == 0:
current_app.logger.error("Apikey with User role must have domains")
raise ApiKeyNotUsable()
if role_name == 'User' and len(domains) > 0:
elif role_name == 'User':
domain_obj_list = Domain.query.filter(Domain.name.in_(domains)).all()
if len(domain_obj_list) == 0:
msg = "One of supplied domains does not exist"
current_app.logger.error(msg)
raise DomainNotExists(message=msg)
if role_name == 'User' and len(accounts) > 0:
account_obj_list = Account.query.filter(Account.name.in_(accounts)).all()
if len(account_obj_list) == 0:
msg = "One of supplied accounts does not exist"
current_app.logger.error(msg)
raise AccountNotExists(message=msg)
if current_user.role.name not in ['Administrator', 'Operator']:
# domain list of domain api key should be valid for
# if not any domain error
@ -363,11 +345,6 @@ def api_generate_apikey():
current_app.logger.error(msg)
raise NotEnoughPrivileges(message=msg)
if len(accounts) > 0:
msg = "User cannot assign accounts"
current_app.logger.error(msg)
raise NotEnoughPrivileges(message=msg)
user_domain_obj_list = get_user_domains()
domain_list = [item.name for item in domain_obj_list]
@ -386,8 +363,7 @@ def api_generate_apikey():
apikey = ApiKey(desc=description,
role_name=role_name,
domains=domain_obj_list,
accounts=account_obj_list)
domains=domain_obj_list)
try:
apikey.create()
@ -500,16 +476,9 @@ def api_update_apikey(apikey_id):
# if role different and user is allowed to change it, update
# if apikey domains are different and user is allowed to handle
# that domains update domains
domain_obj_list = None
account_obj_list = None
apikey = ApiKey.query.get(apikey_id)
if not apikey:
abort(404)
data = request.get_json()
description = data['description'] if 'description' in data else None
domain_obj_list = None
if 'role' in data:
if isinstance(data['role'], str):
@ -518,11 +487,8 @@ def api_update_apikey(apikey_id):
role_name = data['role']['name']
else:
abort(400)
target_role = role_name
else:
role_name = None
target_role = apikey.role.name
if 'domains' not in data:
domains = None
@ -531,54 +497,22 @@ def api_update_apikey(apikey_id):
else:
domains = [d['name'] if isinstance(d, dict) else d for d in data['domains']]
if 'accounts' not in data:
accounts = None
elif not isinstance(data['accounts'], (list, )):
abort(400)
else:
accounts = [a['name'] if isinstance(a, dict) else a for a in data['accounts']]
apikey = ApiKey.query.get(apikey_id)
if not apikey:
abort(404)
current_app.logger.debug('Updating apikey with id {0}'.format(apikey_id))
if target_role == 'User':
current_domains = [item.name for item in apikey.domains]
current_accounts = [item.name for item in apikey.accounts]
if domains is not None:
domain_obj_list = Domain.query.filter(Domain.name.in_(domains)).all()
if len(domain_obj_list) != len(domains):
msg = "One of supplied domains does not exist"
current_app.logger.error(msg)
raise DomainNotExists(message=msg)
target_domains = domains
else:
target_domains = current_domains
if accounts is not None:
account_obj_list = Account.query.filter(Account.name.in_(accounts)).all()
if len(account_obj_list) != len(accounts):
msg = "One of supplied accounts does not exist"
current_app.logger.error(msg)
raise AccountNotExists(message=msg)
target_accounts = accounts
else:
target_accounts = current_accounts
if len(target_domains) == 0 and len(target_accounts) == 0:
current_app.logger.error("Apikey with User role must have domains or accounts")
raise ApiKeyNotUsable()
if domains is not None and set(domains) == set(current_domains):
current_app.logger.debug(
"Domains are the same, apikey domains won't be updated")
domains = None
if accounts is not None and set(accounts) == set(current_accounts):
current_app.logger.debug(
"Accounts are the same, apikey accounts won't be updated")
accounts = None
if role_name == 'User' and len(domains) == 0:
current_app.logger.error("Apikey with User role must have domains")
raise ApiKeyNotUsable()
elif role_name == 'User':
domain_obj_list = Domain.query.filter(Domain.name.in_(domains)).all()
if len(domain_obj_list) == 0:
msg = "One of supplied domains does not exist"
current_app.logger.error(msg)
raise DomainNotExists(message=msg)
if current_user.role.name not in ['Administrator', 'Operator']:
if role_name != 'User':
@ -586,12 +520,8 @@ def api_update_apikey(apikey_id):
current_app.logger.error(msg)
raise NotEnoughPrivileges(message=msg)
if len(accounts) > 0:
msg = "User cannot assign accounts"
current_app.logger.error(msg)
raise NotEnoughPrivileges(message=msg)
apikeys = get_user_apikeys()
apikey_domains = [item.name for item in apikey.domains]
apikeys_ids = [apikey_item.id for apikey_item in apikeys]
user_domain_obj_list = current_user.get_domain().all()
@ -615,7 +545,12 @@ def api_update_apikey(apikey_id):
current_app.logger.error(msg)
raise DomainAccessForbidden()
if role_name == apikey.role.name:
if set(domains) == set(apikey_domains):
current_app.logger.debug(
"Domains are same, apikey domains won't be updated")
domains = None
if role_name == apikey.role:
current_app.logger.debug("Role is same, apikey role won't be updated")
role_name = None
@ -624,13 +559,10 @@ def api_update_apikey(apikey_id):
current_app.logger.debug(msg)
description = None
if target_role != "User":
domains, accounts = [], []
try:
apikey = ApiKey.query.get(apikey_id)
apikey.update(role_name=role_name,
domains=domains,
accounts=accounts,
description=description)
except Exception as e:
current_app.logger.error('Error: {0}'.format(e))
@ -689,7 +621,7 @@ def api_create_user():
if not plain_text_password and not password:
plain_text_password = ''.join(
secrets.choice(string.ascii_letters + string.digits)
random.choice(string.ascii_letters + string.digits)
for _ in range(15))
if not role_name and not role_id:
role_name = 'User'
@ -924,7 +856,7 @@ def api_update_account(account_id):
"Updating account {} ({})".format(account_id, account.name))
result = account.update_account()
if not result['status']:
raise AccountUpdateFail(message=result['msg'])
raise AccountDeleteFail(message=result['msg'])
history = History(msg='Update account {0}'.format(account.name),
created_by=current_user.username)
history.add()
@ -944,7 +876,7 @@ def api_delete_account(account_id):
"Deleting account {} ({})".format(account_id, account.name))
result = account.delete_account()
if not result:
raise AccountDeleteFail(message=result['msg'])
raise AccountUpdateFail(message=result['msg'])
history = History(msg='Delete account {0}'.format(account.name),
created_by=current_user.username)
@ -1025,28 +957,6 @@ def api_remove_account_user(account_id, user_id):
return '', 204
@api_bp.route(
'/servers/<string:server_id>/zones/<string:zone_id>/cryptokeys',
methods=['GET', 'POST'])
@apikey_auth
@apikey_can_access_domain
@apikey_can_configure_dnssec(http_methods=['POST'])
def api_zone_cryptokeys(server_id, zone_id):
resp = helper.forward_request()
return resp.content, resp.status_code, resp.headers.items()
@api_bp.route(
'/servers/<string:server_id>/zones/<string:zone_id>/cryptokeys/<string:cryptokey_id>',
methods=['GET', 'PUT', 'DELETE'])
@apikey_auth
@apikey_can_access_domain
@apikey_can_configure_dnssec()
def api_zone_cryptokey(server_id, zone_id, cryptokey_id):
resp = helper.forward_request()
return resp.content, resp.status_code, resp.headers.items()
@api_bp.route(
'/servers/<string:server_id>/zones/<string:zone_id>/<path:subpath>',
methods=['GET', 'POST', 'PUT', 'PATCH', 'DELETE'])
@ -1061,10 +971,6 @@ def api_zone_subpath_forward(server_id, zone_id, subpath):
methods=['GET', 'PUT', 'PATCH', 'DELETE'])
@apikey_auth
@apikey_can_access_domain
@apikey_can_remove_domain(http_methods=['DELETE'])
@callback_if_request_body_contains_key(apikey_can_configure_dnssec()(),
http_methods=['PUT'],
keys=['dnssec', 'nsec3param'])
def api_zone_forward(server_id, zone_id):
resp = helper.forward_request()
if not Setting().get('bg_domain_updates'):
@ -1073,32 +979,30 @@ def api_zone_forward(server_id, zone_id):
status = resp.status_code
if 200 <= status < 300:
current_app.logger.debug("Request to powerdns API successful")
if Setting().get('enable_api_rr_history'):
if request.method in ['POST', 'PATCH'] :
data = request.get_json(force=True)
for rrset_data in data['rrsets']:
history = History(msg='{0} zone {1} record of {2}'.format(
rrset_data['changetype'].lower(), rrset_data['type'],
rrset_data['name'].rstrip('.')),
detail=json.dumps(data),
created_by=g.apikey.description,
domain_id=Domain().get_id_by_name(zone_id.rstrip('.')))
history.add()
elif request.method == 'DELETE':
history = History(msg='Deleted zone {0}'.format(zone_id.rstrip('.')),
detail='',
created_by=g.apikey.description,
domain_id=Domain().get_id_by_name(zone_id.rstrip('.')))
history.add()
elif request.method != 'GET':
history = History(msg='Updated zone {0}'.format(zone_id.rstrip('.')),
detail='',
created_by=g.apikey.description,
domain_id=Domain().get_id_by_name(zone_id.rstrip('.')))
if request.method in ['POST', 'PATCH'] :
data = request.get_json(force=True)
for rrset_data in data['rrsets']:
history = History(msg='{0} zone {1} record of {2}'.format(
rrset_data['changetype'].lower(), rrset_data['type'],
rrset_data['name'].rstrip('.')),
detail=json.dumps(data),
created_by=g.apikey.description,
domain_id=Domain().get_id_by_name(zone_id.rstrip('.')))
history.add()
elif request.method == 'DELETE':
history = History(msg='Deleted zone {0}'.format(zone_id.rstrip('.')),
detail='',
created_by=g.apikey.description,
domain_id=Domain().get_id_by_name(zone_id.rstrip('.')))
history.add()
elif request.method != 'GET':
history = History(msg='Updated zone {0}'.format(zone_id.rstrip('.')),
detail='',
created_by=g.apikey.description,
domain_id=Domain().get_id_by_name(zone_id.rstrip('.')))
history.add()
return resp.content, resp.status_code, resp.headers.items()
@api_bp.route('/servers/<path:subpath>', methods=['GET', 'PUT'])
@apikey_auth
@apikey_is_admin
@ -1109,7 +1013,6 @@ def api_server_sub_forward(subpath):
@api_bp.route('/servers/<string:server_id>/zones', methods=['POST'])
@apikey_auth
@apikey_can_create_domain
def api_create_zone(server_id):
resp = helper.forward_request()
@ -1151,13 +1054,8 @@ def api_get_zones(server_id):
and resp.status_code == 200):
domain_list = [d['name']
for d in domain_schema.dump(g.apikey.domains)]
accounts_domains = [d.name for a in g.apikey.accounts for d in a.domains]
allowed_domains = set(domain_list + accounts_domains)
current_app.logger.debug("Account domains: {}".format(
'/'.join(accounts_domains)))
content = json.dumps([i for i in json.loads(resp.content)
if i['name'].rstrip('.') in allowed_domains])
if i['name'].rstrip('.') in domain_list])
return content, resp.status_code, resp.headers.items()
else:
return resp.content, resp.status_code, resp.headers.items()

View file

@ -3,7 +3,6 @@ from flask import Blueprint, render_template, url_for, current_app, request, jso
from flask_login import login_required, current_user, login_manager
from sqlalchemy import not_
from ..decorators import operator_role_required
from ..lib.utils import customBoxes
from ..models.user import User, Anonymous
from ..models.account import Account
@ -61,7 +60,7 @@ def domains_custom(boxId):
))
template = current_app.jinja_env.get_template("dashboard_domain.html")
render = template.make_module(vars={"current_user": current_user, "allow_user_view_history": Setting().get('allow_user_view_history')})
render = template.make_module(vars={"current_user": current_user})
columns = [
Domain.name, Domain.dnssec, Domain.type, Domain.serial, Domain.master,
@ -151,10 +150,6 @@ def dashboard():
else:
current_app.logger.info('Updating domains in background...')
show_bg_domain_button = BG_DOMAIN_UPDATE
if BG_DOMAIN_UPDATE and current_user.role.name not in ['Administrator', 'Operator']:
show_bg_domain_button = False
# Stats for dashboard
domain_count = 0
history_number = 0
@ -163,20 +158,19 @@ def dashboard():
if current_user.role.name in ['Administrator', 'Operator']:
domain_count = Domain.query.count()
history_number = History.query.count()
history = History.query.order_by(History.created_on.desc()).limit(4).all()
history = History.query.order_by(History.created_on.desc()).limit(4)
elif Setting().get('allow_user_view_history'):
history = db.session.query(History) \
.join(Domain, History.domain_id == Domain.id) \
.outerjoin(DomainUser, Domain.id == DomainUser.domain_id) \
.outerjoin(Account, Domain.account_id == Account.id) \
.outerjoin(AccountUser, Account.id == AccountUser.account_id) \
.order_by(History.created_on.desc()) \
.filter(
db.or_(
DomainUser.user_id == current_user.id,
AccountUser.user_id == current_user.id
)).all()
history_number = len(history) # history.count()
)).order_by(History.created_on.desc())
history_number = history.count()
history = history[:4]
domain_count = db.session.query(Domain) \
.outerjoin(DomainUser, Domain.id == DomainUser.domain_id) \
@ -187,10 +181,6 @@ def dashboard():
DomainUser.user_id == current_user.id,
AccountUser.user_id == current_user.id
)).count()
from .admin import convert_histories, DetailedHistory
detailedHistories = convert_histories(history)
server = Server(server_id='localhost')
statistics = server.get_statistic()
if statistics:
@ -207,14 +197,13 @@ def dashboard():
user_num=user_num,
history_number=history_number,
uptime=uptime,
histories=detailedHistories,
show_bg_domain_button=show_bg_domain_button,
histories=history,
show_bg_domain_button=BG_DOMAIN_UPDATE,
pdns_version=Setting().get('pdns_version'))
@dashboard_bp.route('/domains-updater', methods=['GET', 'POST'])
@login_required
@operator_role_required
def domains_updater():
current_app.logger.debug('Update domains in background')
d = Domain().update()

View file

@ -10,7 +10,7 @@ from flask_login import login_required, current_user, login_manager
from ..lib.utils import pretty_domain_name
from ..lib.utils import pretty_json
from ..decorators import can_create_domain, operator_role_required, can_access_domain, can_configure_dnssec, can_remove_domain
from ..decorators import can_create_domain, operator_role_required, can_access_domain, can_configure_dnssec
from ..models.user import User, Anonymous
from ..models.account import Account
from ..models.setting import Setting
@ -21,11 +21,7 @@ from ..models.record_entry import RecordEntry
from ..models.domain_template import DomainTemplate
from ..models.domain_template_record import DomainTemplateRecord
from ..models.domain_setting import DomainSetting
from ..models.base import db
from ..models.domain_user import DomainUser
from ..models.account_user import AccountUser
from .admin import extract_changelogs_from_a_history_entry
from ..decorators import history_access_required
domain_bp = Blueprint('domain',
__name__,
template_folder='templates',
@ -132,217 +128,7 @@ def domain(domain_name):
records=records,
editable_records=editable_records,
quick_edit=quick_edit,
ttl_options=ttl_options,
current_user=current_user)
@domain_bp.route('/remove', methods=['GET', 'POST'])
@login_required
@can_remove_domain
def remove():
# domains is a list of all the domains a User may access
# Admins may access all
# Regular users only if they are associated with the domain
if current_user.role.name in ['Administrator', 'Operator']:
domains = Domain.query.order_by(Domain.name).all()
else:
# Get query for domain to which the user has access permission.
# This includes direct domain permission AND permission through
# account membership
domains = db.session.query(Domain) \
.outerjoin(DomainUser, Domain.id == DomainUser.domain_id) \
.outerjoin(Account, Domain.account_id == Account.id) \
.outerjoin(AccountUser, Account.id == AccountUser.account_id) \
.filter(
db.or_(
DomainUser.user_id == current_user.id,
AccountUser.user_id == current_user.id
)).order_by(Domain.name)
if request.method == 'POST':
# TODO Change name from 'domainid' to something else, its confusing
domain_name = request.form['domainid']
# Get domain from Database, might be None
domain = Domain.query.filter(Domain.name == domain_name).first()
# Check if the domain is in domains before removal
if domain not in domains:
abort(403)
# Delete
d = Domain()
result = d.delete(domain_name)
if result['status'] == 'error':
abort(500)
history = History(msg='Delete domain {0}'.format(
pretty_domain_name(domain_name)),
created_by=current_user.username)
history.add()
return redirect(url_for('dashboard.dashboard'))
else:
# On GET return the domains we got earlier
return render_template('domain_remove.html',
domainss=domains)
@domain_bp.route('/<path:domain_name>/changelog', methods=['GET'])
@login_required
@can_access_domain
@history_access_required
def changelog(domain_name):
g.user = current_user
login_manager.anonymous_user = Anonymous
domain = Domain.query.filter(Domain.name == domain_name).first()
if not domain:
abort(404)
# Query domain's rrsets from PowerDNS API
rrsets = Record().get_rrsets(domain.name)
current_app.logger.debug("Fetched rrests: \n{}".format(pretty_json(rrsets)))
# API server might be down, misconfigured
if not rrsets and domain.type != 'Slave':
abort(500)
records_allow_to_edit = Setting().get_records_allow_to_edit()
records = []
# get all changelogs for this domain, in descening order
if current_user.role.name in [ 'Administrator', 'Operator' ]:
histories = History.query.filter(History.domain_id == domain.id).order_by(History.created_on.desc()).all()
else:
# if the user isn't an administrator or operator,
# allow_user_view_history must be enabled to get here,
# so include history for the domains for the user
histories = db.session.query(History) \
.join(Domain, History.domain_id == Domain.id) \
.outerjoin(DomainUser, Domain.id == DomainUser.domain_id) \
.outerjoin(Account, Domain.account_id == Account.id) \
.outerjoin(AccountUser, Account.id == AccountUser.account_id) \
.order_by(History.created_on.desc()) \
.filter(
db.and_(db.or_(
DomainUser.user_id == current_user.id,
AccountUser.user_id == current_user.id
),
History.domain_id == domain.id
)
).all()
if StrictVersion(Setting().get('pdns_version')) >= StrictVersion('4.0.0'):
for r in rrsets:
if r['type'] in records_allow_to_edit:
r_name = r['name'].rstrip('.')
# If it is reverse zone and pretty_ipv6_ptr setting
# is enabled, we reformat the name for ipv6 records.
if Setting().get('pretty_ipv6_ptr') and r[
'type'] == 'PTR' and 'ip6.arpa' in r_name and '*' not in r_name:
r_name = dns.reversename.to_address(
dns.name.from_text(r_name))
# Create the list of records in format that
# PDA jinja2 template can understand.
index = 0
for record in r['records']:
if (len(r['comments'])>index):
c=r['comments'][index]['content']
else:
c=''
record_entry = RecordEntry(
name=r_name,
type=r['type'],
status='Disabled' if record['disabled'] else 'Active',
ttl=r['ttl'],
data=record['content'],
comment=c,
is_allowed_edit=True)
index += 1
records.append(record_entry)
else:
# Unsupported version
abort(500)
changes_set = dict()
for i in range(len(histories)):
extract_changelogs_from_a_history_entry(changes_set, histories[i], i)
if i in changes_set and len(changes_set[i]) == 0: # if empty, then remove the key
changes_set.pop(i)
return render_template('domain_changelog.html', domain=domain, allHistoryChanges=changes_set)
"""
Returns a changelog for a specific pair of (record_name, record_type)
"""
@domain_bp.route('/<path:domain_name>/changelog/<path:record_name>-<path:record_type>', methods=['GET'])
@login_required
@can_access_domain
@history_access_required
def record_changelog(domain_name, record_name, record_type):
g.user = current_user
login_manager.anonymous_user = Anonymous
domain = Domain.query.filter(Domain.name == domain_name).first()
if not domain:
abort(404)
# Query domain's rrsets from PowerDNS API
rrsets = Record().get_rrsets(domain.name)
current_app.logger.debug("Fetched rrests: \n{}".format(pretty_json(rrsets)))
# API server might be down, misconfigured
if not rrsets and domain.type != 'Slave':
abort(500)
# get all changelogs for this domain, in descening order
if current_user.role.name in [ 'Administrator', 'Operator' ]:
histories = History.query.filter(History.domain_id == domain.id).order_by(History.created_on.desc()).all()
else:
# if the user isn't an administrator or operator,
# allow_user_view_history must be enabled to get here,
# so include history for the domains for the user
histories = db.session.query(History) \
.join(Domain, History.domain_id == Domain.id) \
.outerjoin(DomainUser, Domain.id == DomainUser.domain_id) \
.outerjoin(Account, Domain.account_id == Account.id) \
.outerjoin(AccountUser, Account.id == AccountUser.account_id) \
.order_by(History.created_on.desc()) \
.filter(
db.and_(db.or_(
DomainUser.user_id == current_user.id,
AccountUser.user_id == current_user.id
),
History.domain_id == domain.id
)
).all()
changes_set_of_record = dict()
for i in range(len(histories)):
extract_changelogs_from_a_history_entry(changes_set_of_record, histories[i], i, record_name, record_type)
if i in changes_set_of_record and len(changes_set_of_record[i]) == 0: # if empty, then remove the key
changes_set_of_record.pop(i)
indexes_to_pop = []
for change_num in changes_set_of_record:
changes_i = changes_set_of_record[change_num]
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:
changes_set_of_record[change_num].remove(hre)
if change_num in changes_set_of_record and len(changes_set_of_record[change_num]) == 0: # if empty, then remove the key
indexes_to_pop.append(change_num)
for i in indexes_to_pop:
changes_set_of_record.pop(i)
return render_template('domain_changelog.html', domain=domain, allHistoryChanges=changes_set_of_record,
record_name = record_name, record_type = record_type)
ttl_options=ttl_options)
@domain_bp.route('/add', methods=['GET', 'POST'])

View file

@ -4,7 +4,6 @@ import json
import traceback
import datetime
import ipaddress
import base64
from distutils.util import strtobool
from yaml import Loader, load
from onelogin.saml2.utils import OneLogin_Saml2_Utils
@ -44,6 +43,7 @@ index_bp = Blueprint('index',
template_folder='templates',
url_prefix='/')
@index_bp.before_app_first_request
def register_modules():
global google
@ -168,8 +168,10 @@ def login():
return redirect(url_for('index.login'))
session['user_id'] = user.id
login_user(user, remember=False)
session['authentication_type'] = 'OAuth'
return authenticate_user(user, 'Google OAuth')
signin_history(user.username, 'Google OAuth', True)
return redirect(url_for('index.index'))
if 'github_token' in session:
me = json.loads(github.get('user').text)
@ -194,7 +196,9 @@ def login():
session['user_id'] = user.id
session['authentication_type'] = 'OAuth'
return authenticate_user(user, 'Github OAuth')
login_user(user, remember=False)
signin_history(user.username, 'Github OAuth', True)
return redirect(url_for('index.index'))
if 'azure_token' in session:
azure_info = azure.get('me?$select=displayName,givenName,id,mail,surname,userPrincipalName').text
@ -363,7 +367,10 @@ def login():
history.add()
current_app.logger.warning('group info: {} '.format(account_id))
return authenticate_user(user, 'Azure OAuth')
login_user(user, remember=False)
signin_history(user.username, 'Azure OAuth', True)
return redirect(url_for('index.index'))
if 'oidc_token' in session:
me = json.loads(oidc.get('userinfo').text)
@ -391,43 +398,22 @@ def login():
session.pop('oidc_token', None)
return redirect(url_for('index.login'))
#This checks if the account_name_property and account_description property were included in settings.
if Setting().get('oidc_oauth_account_name_property') and Setting().get('oidc_oauth_account_description_property'):
#Gets the name_property and description_property.
name_prop = Setting().get('oidc_oauth_account_name_property')
desc_prop = Setting().get('oidc_oauth_account_description_property')
account_to_add = []
#If the name_property and desc_property exist in me (A variable that contains all the userinfo from the IdP).
if name_prop in me and desc_prop in me:
accounts_name_prop = [me[name_prop]] if type(me[name_prop]) is not list else me[name_prop]
accounts_desc_prop = [me[desc_prop]] if type(me[desc_prop]) is not list else me[desc_prop]
#Run on all groups the user is in by the index num.
for i in range(len(accounts_name_prop)):
description = ''
if i < len(accounts_desc_prop):
description = accounts_desc_prop[i]
account = handle_account(accounts_name_prop[i], description)
account_to_add.append(account)
account = handle_account(me[name_prop], me[desc_prop])
account.add_user(user)
user_accounts = user.get_accounts()
# Add accounts
for account in account_to_add:
if account not in user_accounts:
account.add_user(user)
# Remove accounts if the setting is enabled
if Setting().get('delete_sso_accounts'):
for account in user_accounts:
if account not in account_to_add:
account.remove_user(user)
for ua in user_accounts:
if ua.name != account.name:
ua.remove_user(user)
session['user_id'] = user.id
session['authentication_type'] = 'OAuth'
return authenticate_user(user, 'OIDC OAuth')
login_user(user, remember=False)
signin_history(user.username, 'OIDC OAuth', True)
return redirect(url_for('index.index'))
if request.method == 'GET':
return render_template('login.html', saml_enabled=SAML_ENABLED)
@ -504,7 +490,9 @@ def login():
user.revoke_privilege(True)
current_app.logger.warning('Procceding to revoke every privilige from ' + user.username + '.' )
return authenticate_user(user, 'LOCAL', remember_me)
login_user(user, remember=remember_me)
signin_history(user.username, 'LOCAL', True)
return redirect(session.get('next', url_for('index.index')))
def checkForPDAEntries(Entitlements, urn_value):
"""
@ -574,23 +562,6 @@ def get_azure_groups(uri):
mygroups = []
return mygroups
# Handle user login, write history and, if set, handle showing the register_otp QR code.
# if Setting for OTP on first login is enabled, and OTP field is also enabled,
# but user isn't using it yet, enable OTP, get QR code and display it, logging the user out.
def authenticate_user(user, authenticator, remember=False):
login_user(user, remember=remember)
signin_history(user.username, authenticator, True)
if Setting().get('otp_force') and Setting().get('otp_field_enabled') and not user.otp_secret:
user.update_profile(enable_otp=True)
user_id = current_user.id
prepare_welcome_user(user_id)
return redirect(url_for('index.welcome'))
return redirect(url_for('index.login'))
# Prepare user to enter /welcome screen, otherwise they won't have permission to do so
def prepare_welcome_user(user_id):
logout_user()
session['welcome_user_id'] = user_id
@index_bp.route('/logout')
def logout():
@ -654,12 +625,12 @@ def register():
if request.method == 'GET':
return render_template('register.html')
elif request.method == 'POST':
username = request.form.get('username', '').strip()
password = request.form.get('password', '')
firstname = request.form.get('firstname', '').strip()
lastname = request.form.get('lastname', '').strip()
email = request.form.get('email', '').strip()
rpassword = request.form.get('rpassword', '')
username = request.form['username']
password = request.form['password']
firstname = request.form.get('firstname')
lastname = request.form.get('lastname')
email = request.form.get('email')
rpassword = request.form.get('rpassword')
if not username or not password or not email:
return render_template(
@ -681,12 +652,7 @@ def register():
if result and result['status']:
if Setting().get('verify_user_email'):
send_account_verification(email)
if Setting().get('otp_force') and Setting().get('otp_field_enabled'):
user.update_profile(enable_otp=True)
prepare_welcome_user(user.id)
return redirect(url_for('index.welcome'))
else:
return redirect(url_for('index.login'))
return redirect(url_for('index.login'))
else:
return render_template('register.html',
error=result['msg'])
@ -696,28 +662,6 @@ def register():
return render_template('errors/404.html'), 404
# Show welcome page on first login if otp_force is enabled
@index_bp.route('/welcome', methods=['GET', 'POST'])
def welcome():
if 'welcome_user_id' not in session:
return redirect(url_for('index.index'))
user = User(id=session['welcome_user_id'])
encoded_img_data = base64.b64encode(user.get_qrcode_value())
if request.method == 'GET':
return render_template('register_otp.html', qrcode_image=encoded_img_data.decode(), user=user)
elif request.method == 'POST':
otp_token = request.form.get('otptoken', '')
if otp_token and otp_token.isdigit():
good_token = user.verify_totp(otp_token)
if not good_token:
return render_template('register_otp.html', qrcode_image=encoded_img_data.decode(), user=user, error="Invalid token")
else:
return render_template('register_otp.html', qrcode_image=encoded_img_data.decode(), user=user, error="Token required")
session.pop('welcome_user_id')
return redirect(url_for('index.index'))
@index_bp.route('/confirm/<token>', methods=['GET'])
def confirm_email(token):
email = confirm_token(token)
@ -1071,7 +1015,9 @@ def saml_authorized():
user.plain_text_password = None
user.update_profile()
session['authentication_type'] = 'SAML'
return authenticate_user(user, 'SAML')
login_user(user, remember=False)
signin_history(user.username, 'SAML', True)
return redirect(url_for('index.login'))
else:
return render_template('errors/SAML.html', errors=errors)

View file

@ -1,4 +1,7 @@
import datetime
import qrcode as qrc
import qrcode.image.svg as qrc_svg
from io import BytesIO
from flask import Blueprint, request, render_template, make_response, jsonify, redirect, url_for, g, session, current_app
from flask_login import current_user, login_required, login_manager
@ -38,10 +41,13 @@ def profile():
return render_template('user_profile.html')
if request.method == 'POST':
if session['authentication_type'] == 'LOCAL':
firstname = request.form.get('firstname', '').strip()
lastname = request.form.get('lastname', '').strip()
email = request.form.get('email', '').strip()
new_password = request.form.get('password', '')
firstname = request.form[
'firstname'] if 'firstname' in request.form else ''
lastname = request.form[
'lastname'] if 'lastname' in request.form else ''
email = request.form['email'] if 'email' in request.form else ''
new_password = request.form[
'password'] if 'password' in request.form else ''
else:
firstname = lastname = email = new_password = ''
current_app.logger.warning(
@ -91,9 +97,13 @@ def qrcode():
if not current_user:
return redirect(url_for('index'))
return current_user.get_qrcode_value(), 200, {
img = qrc.make(current_user.get_totp_uri(),
image_factory=qrc_svg.SvgPathImage)
stream = BytesIO()
img.save(stream)
return stream.getvalue(), 200, {
'Content-Type': 'image/svg+xml',
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
}
}

View file

@ -1,102 +0,0 @@
/* Customize the label (the container) */
.container {
display: block;
position: relative;
padding-left: 50px;
margin-bottom: 12px;
left:100px;
cursor: pointer;
font-size: 22px;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
/* Hide the browser's default checkbox */
.container input {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
/* Create a custom checkbox */
.checkmark {
position: absolute;
top: 0;
left: 0;
height: 25px;
width: 25px;
background-color: #eee;
}
/* On mouse-over, add a grey background color */
.container:hover input ~ .checkmark {
background-color: #ccc;
}
/* When the checkbox is checked, add a blue background */
.container input:checked ~ .checkmark {
background-color: #2196F3;
}
/* Create the checkmark/indicator (hidden when not checked) */
.checkmark:after {
content: "";
position: absolute;
display: none;
}
/* Show the checkmark when checked */
.container input:checked ~ .checkmark:after {
display: block;
}
/* Style the checkmark/indicator */
.container .checkmark:after {
left: 9px;
top: 5px;
width: 5px;
height: 10px;
border: solid white;
border-width: 0 3px 3px 0;
-webkit-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.autocomplete {
/*the container must be positioned relative:*/
position: relative;
/* display: inline-block; */
}
.autocomplete-items {
position: absolute;
border: 1px solid #d4d4d4;
border-bottom: none;
border-top: none;
z-index: 99;
/*position the autocomplete items to be the same width as the container:*/
top: 100%;
left: 0;
right: 0;
}
.autocomplete-items div {
padding: 10px;
cursor: pointer;
background-color: #fff;
border-bottom: 1px solid #d4d4d4;
}
.autocomplete-items div:hover {
/*when hovering an item:*/
background-color: #e9e9e9;
}
.autocomplete-active {
/*when navigating through the items using the arrow keys:*/
background-color: DodgerBlue !important;
color: #ffffff;
}

View file

@ -53,7 +53,6 @@ function applyRecordChanges(data, domain) {
var modal = $("#modal_success");
modal.find('.modal-body p').text("Applied changes successfully");
modal.modal('show');
setTimeout(() => {window.location.reload()}, 2000);
},
error : function(jqXHR, status) {
@ -285,14 +284,4 @@ function timer(elToUpdate, maxTime) {
}, 1000);
return interval;
}
// copy otp secret code to clipboard
function copy_otp_secret_to_clipboard() {
var copyBox = document.getElementById("otp_secret");
copyBox.select();
copyBox.setSelectionRange(0, 99999); /* For mobile devices */
navigator.clipboard.writeText(copyBox.value);
$("#copy_tooltip").css("visibility", "visible");
setTimeout(function(){ $("#copy_tooltip").css("visibility", "collapse"); }, 2000);
}
}

View file

@ -797,11 +797,6 @@ paths:
type: array
items:
$ref: '#/definitions/PDNSAdminZones'
'401':
description: 'Unauthorized'
schema:
$ref: '#/definitions/Error'
post:
security:
- basicAuth: []
@ -821,23 +816,6 @@ paths:
description: A zone
schema:
$ref: '#/definitions/Zone'
'400':
description: 'Request is not JSON'
schema:
$ref: '#/definitions/Error'
'401':
description: 'Unauthorized'
schema:
$ref: '#/definitions/Error'
'409':
description: 'Domain already exists (conflict)'
schema:
$ref: '#/definitions/Error'
'500':
description: 'Internal Server Error'
schema:
$ref: '#/definitions/Error'
'/pdnsadmin/zones/{zone_id}':
parameters:
- name: zone_id
@ -861,23 +839,6 @@ paths:
responses:
'204':
description: 'Returns 204 No Content on success.'
'401':
description: 'Unauthorized'
schema:
$ref: '#/definitions/Error'
'403':
description: 'Forbidden'
schema:
$ref: '#/definitions/Error'
'404':
description: 'Not found'
schema:
$ref: '#/definitions/Error'
'500':
description: 'Internal Server Error'
schema:
$ref: '#/definitions/Error'
'/pdnsadmin/apikeys':
get:
security:
@ -893,23 +854,15 @@ paths:
type: array
items:
$ref: '#/definitions/ApiKey'
'401':
description: 'Unauthorized'
schema:
$ref: '#/definitions/Error'
'403':
description: 'Domain Access Forbidden'
schema:
$ref: '#/definitions/Error'
'500':
description: 'Internal Server Error. There was a problem creating the key'
description: 'Internal Server Error, keys could not be retrieved. Contains error message'
schema:
$ref: '#/definitions/Error'
post:
security:
- basicAuth: []
summary: 'Add a ApiKey key'
description: 'This methods add a new ApiKey. The actual key is generated by the server'
description: 'This methods add a new ApiKey. The actual key can be generated by the server or be provided by the client'
operationId: api_generate_apikey
tags:
- apikey
@ -925,27 +878,14 @@ paths:
description: Created
schema:
$ref: '#/definitions/ApiKey'
'400':
description: 'Request is not JSON or does not respect required format'
schema:
$ref: '#/definitions/Error'
'401':
description: 'Unauthorized'
schema:
$ref: '#/definitions/Error'
'403':
description: 'Domain Access Forbidden'
schema:
$ref: '#/definitions/Error'
'404':
description: 'Domain or Account Not found'
'422':
description: 'Unprocessable Entry, the ApiKey provided has issues.'
schema:
$ref: '#/definitions/Error'
'500':
description: 'Internal Server Error. There was a problem creating the key'
schema:
$ref: '#/definitions/Error'
'/pdnsadmin/apikeys/{apikey_id}':
parameters:
- name: apikey_id
@ -965,18 +905,16 @@ paths:
description: OK.
schema:
$ref: '#/definitions/ApiKey'
'401':
description: 'Unauthorized'
schema:
$ref: '#/definitions/Error'
'403':
description: 'The authenticated user has User role and is not allowed on any of the domains assigned to the key'
schema:
$ref: '#/definitions/Error'
'404':
description: 'Not found. The ApiKey with the specified apikey_id does not exist'
schema:
$ref: '#/definitions/Error'
'500':
description: 'Internal Server Error, keys could not be retrieved. Contains error message'
schema:
$ref: '#/definitions/Error'
delete:
security:
- basicAuth: []
@ -987,14 +925,6 @@ paths:
responses:
'204':
description: 'OK, key was deleted'
'401':
description: 'Unauthorized'
schema:
$ref: '#/definitions/Error'
'403':
description: 'The authenticated user has User role and is not allowed on any of the domains assigned to the key'
schema:
$ref: '#/definitions/Error'
'404':
description: 'Not found. The ApiKey with the specified apikey_id does not exist'
schema:
@ -1008,11 +938,9 @@ paths:
- basicAuth: []
description: |
The ApiKey at apikey_id can be changed in multiple ways:
* Role, description, accounts and domains can be updated
* Role, description, domains can be updated
* Role can be changed to Administrator only if user has Operator or Administrator privileges
* Domains will be updated only if user has access to them
* Accounts can be updated only by a privileged user
* With a User role, an ApiKey needs at least one account or one domain
Only the relevant fields have to be provided in the request body.
operationId: api_update_apikey
tags:
@ -1029,27 +957,14 @@ paths:
description: OK. ApiKey is changed.
schema:
$ref: '#/definitions/ApiKey'
'400':
description: 'Request is not JSON'
schema:
$ref: '#/definitions/Error'
'401':
description: 'Unauthorized'
schema:
$ref: '#/definitions/Error'
'403':
description: 'Domain Access Forbidden'
schema:
$ref: '#/definitions/Error'
'404':
description: 'Not found (ApiKey, Domain or Account)'
description: 'Not found. The TSIGKey with the specified tsigkey_id does not exist'
schema:
$ref: '#/definitions/Error'
'500':
description: 'Internal Server Error. Contains error message'
schema:
$ref: '#/definitions/Error'
'/pdnsadmin/users':
get:
security:
@ -1065,10 +980,6 @@ paths:
type: array
items:
$ref: '#/definitions/User'
'401':
description: 'Unauthorized'
schema:
$ref: '#/definitions/Error'
'500':
description: Internal Server Error, users could not be retrieved. Contains error message
schema:
@ -1127,11 +1038,7 @@ paths:
schema:
$ref: '#/definitions/User'
'400':
description: 'Request is not JSON'
schema:
$ref: '#/definitions/Error'
'401':
description: 'Unauthorized'
description: Unprocessable Entry, the User data provided has issues
schema:
$ref: '#/definitions/Error'
'409':
@ -1142,7 +1049,6 @@ paths:
description: Internal Server Error. There was a problem creating the user
schema:
$ref: '#/definitions/Error'
'/pdnsadmin/users/{username}':
parameters:
- name: username
@ -1162,10 +1068,6 @@ paths:
description: Retrieve a specific User
schema:
$ref: '#/definitions/UserDetailed'
'401':
description: 'Unauthorized'
schema:
$ref: '#/definitions/Error'
'404':
description: Not found. The User with the specified username does not exist
schema:
@ -1174,7 +1076,6 @@ paths:
description: Internal Server Error, user could not be retrieved. Contains error message
schema:
$ref: '#/definitions/Error'
'/pdnsadmin/users/{user_id}':
parameters:
- name: user_id
@ -1228,22 +1129,10 @@ paths:
responses:
'204':
description: OK. User is modified (empty response body)
'400':
description: 'Request is not JSON'
schema:
$ref: '#/definitions/Error'
'401':
description: 'Unauthorized'
schema:
$ref: '#/definitions/Error'
'404':
description: Not found. The User with the specified user_id does not exist
schema:
$ref: '#/definitions/Error'
'409':
description: Duplicate (Email already assigned to another user)
schema:
$ref: '#/definitions/Error'
'500':
description: Internal Server Error. Contains error message
schema:
@ -1258,10 +1147,6 @@ paths:
responses:
'204':
description: OK. User is deleted (empty response body)
'401':
description: 'Unauthorized'
schema:
$ref: '#/definitions/Error'
'404':
description: Not found. The User with the specified user_id does not exist
schema:
@ -1270,7 +1155,6 @@ paths:
description: Internal Server Error. Contains error message
schema:
$ref: '#/definitions/Error'
'/pdnsadmin/accounts':
get:
security:
@ -1286,8 +1170,8 @@ paths:
type: array
items:
$ref: '#/definitions/Account'
'401':
description: 'Unauthorized'
'500':
description: Internal Server Error, accounts could not be retrieved. Contains error message
schema:
$ref: '#/definitions/Error'
post:
@ -1323,11 +1207,7 @@ paths:
schema:
$ref: '#/definitions/Account'
'400':
description: 'Request is not JSON'
schema:
$ref: '#/definitions/Error'
'401':
description: 'Unauthorized'
description: Unprocessable Entry, the Account data provided has issues.
schema:
$ref: '#/definitions/Error'
'409':
@ -1338,7 +1218,6 @@ paths:
description: Internal Server Error. There was a problem creating the account
schema:
$ref: '#/definitions/Error'
'/pdnsadmin/accounts/{account_name}':
parameters:
- name: account_name
@ -1358,15 +1237,14 @@ paths:
description: Retrieve a specific account
schema:
$ref: '#/definitions/Account'
'401':
description: 'Unauthorized'
schema:
$ref: '#/definitions/Error'
'404':
description: Not found. The Account with the specified name does not exist
schema:
$ref: '#/definitions/Error'
'500':
description: Internal Server Error, account could not be retrieved. Contains error message
schema:
$ref: '#/definitions/Error'
'/pdnsadmin/accounts/{account_id}':
parameters:
- name: account_id
@ -1403,14 +1281,6 @@ paths:
responses:
'204':
description: OK. Account is modified (empty response body)
'400':
description: 'Request is not JSON'
schema:
$ref: '#/definitions/Error'
'401':
description: 'Unauthorized'
schema:
$ref: '#/definitions/Error'
'404':
description: Not found. The Account with the specified account_id does not exist
schema:
@ -1429,10 +1299,6 @@ paths:
responses:
'204':
description: OK. Account is deleted (empty response body)
'401':
description: 'Unauthorized'
schema:
$ref: '#/definitions/Error'
'404':
description: Not found. The Account with the specified account_id does not exist
schema:
@ -1441,7 +1307,6 @@ paths:
description: Internal Server Error. Contains error message
schema:
$ref: '#/definitions/Error'
'/pdnsadmin/accounts/{account_id}/users':
parameters:
- name: account_id
@ -1464,46 +1329,14 @@ paths:
type: array
items:
$ref: '#/definitions/User'
'401':
description: 'Unauthorized'
schema:
$ref: '#/definitions/Error'
'404':
description: Not found. The Account with the specified account_id does not exist
schema:
$ref: '#/definitions/Error'
'/pdnsadmin/accounts/users/{account_id}':
parameters:
- name: account_id
type: integer
in: path
required: true
description: The id of the account to list users linked to account
get:
security:
- basicAuth: []
summary: List users linked to a specific account
operationId: api_list_users_account
tags:
- account
- user
responses:
'200':
description: List of Summarized User objects
schema:
type: array
items:
$ref: '#/definitions/User'
'401':
description: 'Unauthorized'
'500':
description: Internal Server Error, accounts could not be retrieved. Contains error message
schema:
$ref: '#/definitions/Error'
'404':
description: Not found. The Account with the specified account_id does not exist
schema:
$ref: '#/definitions/Error'
'/pdnsadmin/accounts/{account_id}/users/{user_id}':
parameters:
- name: account_id
@ -1527,14 +1360,6 @@ paths:
responses:
'204':
description: OK. User is linked (empty response body)
'400':
description: 'Request is not JSON'
schema:
$ref: '#/definitions/Error'
'401':
description: 'Unauthorized'
schema:
$ref: '#/definitions/Error'
'404':
description: Not found. The Account or User with the specified id does not exist
schema:
@ -1554,73 +1379,6 @@ paths:
responses:
'204':
description: OK. User is unlinked (empty response body)
'401':
description: 'Unauthorized'
schema:
$ref: '#/definitions/Error'
'404':
description: Not found. The Account or User with the specified id does not exist or user was not linked to account
schema:
$ref: '#/definitions/Error'
'500':
description: Internal Server Error. Contains error message
schema:
$ref: '#/definitions/Error'
'/pdnsadmin/accounts/users/{account_id}/{user_id}':
parameters:
- name: account_id
type: integer
in: path
required: true
description: The id of the account to link/unlink users to account
- name: user_id
type: integer
in: path
required: true
description: The id of the user to (un)link to/from account
put:
security:
- basicAuth: []
summary: Link user to account
operationId: api_add_user_account
tags:
- account
- user
responses:
'204':
description: OK. User is linked (empty response body)
'400':
description: 'Request is not JSON'
schema:
$ref: '#/definitions/Error'
'401':
description: 'Unauthorized'
schema:
$ref: '#/definitions/Error'
'404':
description: Not found. The Account or User with the specified id does not exist
schema:
$ref: '#/definitions/Error'
'500':
description: Internal Server Error. Contains error message
schema:
$ref: '#/definitions/Error'
delete:
security:
- basicAuth: []
summary: Unlink user from account
operationId: api_remove_user_account
tags:
- account
- user
responses:
'204':
description: OK. User is unlinked (empty response body)
'401':
description: 'Unauthorized'
schema:
$ref: '#/definitions/Error'
'404':
description: Not found. The Account or User with the specified id does not exist or user was not linked to account
schema:
@ -1840,9 +1598,8 @@ definitions:
PDNSAdminZones:
title: PDNSAdminZones
description: 'A list of domains'
description: A ApiKey that can be used to manage domains through API
type: array
x-omitempty: false
items:
properties:
id:
@ -1867,7 +1624,7 @@ definitions:
ApiKey:
title: ApiKey
description: 'An ApiKey that can be used to manage domains through API'
description: A ApiKey that can be used to manage domains through API
properties:
id:
type: integer
@ -1887,23 +1644,6 @@ definitions:
description:
type: string
description: 'Some user defined description'
accounts:
type: array
description: 'A list of accounts bound to this ApiKey'
items:
$ref: '#/definitions/AccountSummary'
ApiKeySummary:
title: ApiKeySummary
description: Summary of an ApiKey
properties:
id:
type: integer
description: 'The ID for this key, used in the ApiKey URL endpoint.'
readOnly: true
description:
type: string
description: 'Some user defined description'
User:
title: User
@ -2011,12 +1751,6 @@ definitions:
type: string
description: The email address of the contact for this account
readOnly: false
apikeys:
type: array
description: A list of API Keys bound to this account
readOnly: true
items:
$ref: '#/definitions/ApiKeySummary'
AccountSummary:
title: AccountSummry
@ -2030,9 +1764,6 @@ definitions:
type: string
description: The name for this account (unique, immutable)
readOnly: false
domains:
description: The list of domains owned by this account
$ref: '#/definitions/PDNSAdminZones'
ConfigSetting:
title: ConfigSetting

View file

@ -162,4 +162,4 @@
}
});
</script>
{% endblock %}
{% endblock %}

View file

@ -1,6 +1,5 @@
{% extends "base.html" %}
{% set active_page = "admin_keys" %}
{% if create or (key is not none and key.role.name != "User") %}{% set hide_opts = True %}{%else %}{% set hide_opts = False %}{% endif %}
{% block title %}
<title>Edit Key - {{ SITE_NAME }}</title>
{% endblock %}
@ -50,40 +49,24 @@
class="glyphicon glyphicon-pencil form-control-feedback"></span>
</div>
</div>
<div class="box-header with-border key-opts"{% if hide_opts %} style="display: none;"{% endif %}>
<h3 class="box-title">Accounts Access Control</h3>
<div class="box-header with-border">
<h3 class="box-title">Access Control</h3>
</div>
<div class="box-body key-opts"{% if hide_opts %} style="display: none;"{% endif %}>
<p>This key will be linked to the accounts on the right,</p>
<p>thus granting access to domains owned by the selected accounts.</p>
<p>Click on accounts to move between the columns.</p>
<div class="form-group col-xs-2">
<select multiple="multiple" class="form-control" id="key_multi_account"
name="key_multi_account">
{% for account in accounts %}
<option {% if key and account in key.accounts %}selected{% endif %} value="{{ account.name }}">{{ account.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="box-header with-border key-opts"{% if hide_opts %} style="display: none;"{% endif %}>
<h3 class="box-title">Domain Access Control</h3>
</div>
<div class="box-body key-opts"{% if hide_opts %} style="display: none;"{% endif %}>
<div class="box-body">
<p>This key will have acess to the domains on the right.</p>
<p>Click on domains to move between the columns.</p>
<div class="form-group col-xs-2">
<select multiple="multiple" class="form-control" id="key_multi_domain"
name="key_multi_domain">
{% for domain in domains %}
<option {% if key and domain in key.domains %}selected{% endif %} value="{{ domain.name }}">{{ domain.name }}</option>
<option {% if domain in key.domains %}selected{% endif %} value="{{ domain.name }}">{{ domain.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="box-footer">
<button type="submit"
class="btn btn-flat btn-primary" id="key_submit">{% if create %}Create{% else %}Update{% endif %}
class="btn btn-flat btn-primary">{% if create %}Create{% else %}Update{% endif %}
Key</button>
</div>
</form>
@ -99,7 +82,7 @@
<p>Fill in all the fields in the form to the left.</p>
<p><strong>Role</strong> The role of the key.</p>
<p><strong>Description</strong> The key description.</p>
<p><strong>Access Control</strong> The domains or accounts which the key has access to.</p>
<p><strong>Access Control</strong> The domains which the key has access to.</p>
</div>
</div>
</div>
@ -108,48 +91,6 @@
{% endblock %}
{% block extrascripts %}
<script>
$('form').submit(function (e) {
var selectedRole = $("#key_role").val();
var selectedDomains = $("#key_multi_domain option:selected").length;
var selectedAccounts = $("#key_multi_account option:selected").length;
var warn_modal = $("#modal_warning");
if (selectedRole != "User" && selectedDomains > 0 && selectedAccounts > 0){
var warning = "Administrator and Operators have access to all domains. Your domain an account selection won't be saved.";
e.preventDefault(e);
warn_modal.modal('show');
}
if (selectedRole == "User" && selectedDomains == 0 && selectedAccounts == 0){
var warning = "User role must have at least one account or one domain bound. None selected.";
e.preventDefault(e);
warn_modal.modal('show');
}
warn_modal.find('.modal-body p').text(warning);
warn_modal.find('#button_key_confirm_warn').click(clearModal);
});
function clearModal(){
$("#modal_warning").modal('hide');
}
$('#key_role').on('change', function (e) {
var optionSelected = $("option:selected", this);
if (this.value != "User") {
// Clear the visible list
$('#ms-key_multi_domain .ms-selection li').each(function(){ $(this).css('display', 'none');})
$('#ms-key_multi_domain .ms-selectable li').each(function(){ $(this).css('display', '');})
$('#ms-key_multi_account .ms-selection li').each(function(){ $(this).css('display', 'none');})
$('#ms-key_multi_account .ms-selectable li').each(function(){ $(this).css('display', '');})
// Deselect invisible selectbox
$('#key_multi_domain option:selected').each(function(){ $(this).prop('selected', false);})
$('#key_multi_account option:selected').each(function(){ $(this).prop('selected', false);})
// Hide the lists
$(".key-opts").hide();
}
else {
$(".key-opts").show();
}
});
$("#key_multi_domain").multiSelect({
selectableHeader: "<input type='text' class='search-input' autocomplete='off' placeholder='Domain Name'>",
selectionHeader: "<input type='text' class='search-input' autocomplete='off' placeholder='Domain Name'>",
@ -185,41 +126,6 @@
this.qs2.cache();
}
});
$("#key_multi_account").multiSelect({
selectableHeader: "<input type='text' class='search-input' autocomplete='off' placeholder='Account Name'>",
selectionHeader: "<input type='text' class='search-input' autocomplete='off' placeholder='Account Name'>",
afterInit: function (ms) {
var that = this,
$selectableSearch = that.$selectableUl.prev(),
$selectionSearch = that.$selectionUl.prev(),
selectableSearchString = '#' + that.$container.attr('id') + ' .ms-elem-selectable:not(.ms-selected)',
selectionSearchString = '#' + that.$container.attr('id') + ' .ms-elem-selection.ms-selected';
that.qs1 = $selectableSearch.quicksearch(selectableSearchString)
.on('keydown', function (e) {
if (e.which === 40) {
that.$selectableUl.focus();
return false;
}
});
that.qs2 = $selectionSearch.quicksearch(selectionSearchString)
.on('keydown', function (e) {
if (e.which == 40) {
that.$selectionUl.focus();
return false;
}
});
},
afterSelect: function () {
this.qs1.cache();
this.qs2.cache();
},
afterDeselect: function () {
this.qs1.cache();
this.qs2.cache();
}
});
{% if plain_key %}
$(document.body).ready(function () {
var modal = $("#modal_show_key");
@ -259,25 +165,4 @@
</div>
<!-- /.modal-dialog -->
</div>
<div class="modal fade" id="modal_warning">
<div class="modal-dialog">
<div class="modal-content modal-sm">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close" id="button_close_warn_modal">
<span aria-hidden="true">&times;</span>
</button>
<h4 class="modal-title">WARNING</h4>
</div>
<div class="modal-body">
<p></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-flat btn-primary center-block" id="button_key_confirm_warn">
OK</button>
</div>
</div>
<!-- /.modal-content -->
</div>
<!-- /.modal-dialog -->
</div>
{% endblock %}
{% endblock %}

View file

@ -13,12 +13,7 @@
<li class="active">History</li>
</ol>
</section>
{% endblock %}
{% block content %}
{% import 'applied_change_macro.html' as applied_change_macro %}
{% endblock %} {% block content %}
<section class="content">
<div class="row">
<div class="col-xs-12">
@ -33,134 +28,32 @@
Clear History&nbsp;<i class="fa fa-trash"></i>
</button>
</div>
<div class="box-body clearfix">
<form id="history-search-form" autocomplete="off">
<!-- Custom Tabs -->
<div class="nav-tabs-custom" id="tabs">
<ul class="nav nav-tabs" id="nav_nav_tabs" name="nav_nav_tabs">
<li id="activity_tab" class="active"><a href="#tabs-act" data-toggle="tab">Search for All Activity</a></li>
<li id="domain_tab"><a href="#tabs-domain" data-toggle="tab">Search By Domain</a></li>
<li id="account_tab"><a href="#tabs-account" data-toggle="tab">Search By Account</a></li>
{% if current_user.role.name != 'User' %}
<li id="user_auth_tab"><a href="#tabs-auth" data-toggle="tab">Search for User Authentication</a></li>
{% endif %}
</ul>
<div class="tab-content">
<div class="tab-pane" id="tabs-act">
</div>
<div class="tab-pane" id="tabs-domain">
<td><label>Domain Name</label></td>
<td>
<div class="autocomplete" style="width:250px;">
<input type="text" class="form-control" id="domain_name_filter" name="domain_name_filter" placeholder="Enter * to search for any domain" value="">
</div>
</td>
<td>
<div style="position: relative; top:10px;">
<td>Record Changelog only &nbsp</td>
<td>
<input type="checkbox" id="domain_changelog_only_checkbox" name="domain_changelog_only_checkbox"
class="checkbox" style="border:2px dotted #00f;display:block;background:#ff0000;">
</td>
</div>
</td>
</div>
<div class="tab-pane" id="tabs-account">
<td><label>Account Name</label></td>
<td>
<div class="autocomplete" style="width:250px;">
<input type="text" class="form-control" id="account_name_filter" name="account_name_filter" placeholder="Enter * to search for any account" value="">
</div>
</td>
</div>
<div class="tab-pane" id="tabs-auth">
<td><label>Username</label></td>
<td>
<div class="autocomplete" style="width:250px;">
<input type="text" class="form-control" id="auth_name_filter" name="auth_name_filter" placeholder="Enter * to search for any username" value="">
</div>
</td>
<td>
<div style="position: relative; top:10px;">
<td>Authenticator Types: &nbsp</td>
<td>&nbsp All</td>
<td>
<input type="checkbox" checked id="auth_all_checkbox" name="auth_all_checkbox"
class="checkbox" style="border:2px dotted #00f;display:block;background:#ff0000;">
</td>
<td>&nbsp LOCAL</td>
<td>
<input type="checkbox" checked id="auth_local_only_checkbox" name="auth_local_only_checkbox"
class="checkbox" style="border:2px dotted #00f;display:block;background:#ff0000;">
</td>
<td>&nbsp OAuth</td>
<td>
<input type="checkbox" checked id="auth_oauth_only_checkbox" name="auth_oauth_only_checkbox"
class="checkbox" style="border:2px dotted #00f;display:block;background:#ff0000;">
</td>
<td>&nbsp SAML</td>
<td>
<input type="checkbox" checked id="auth_saml_only_checkbox" name="auth_saml_only_checkbox"
class="checkbox" style="border:2px dotted #00f;display:block;background:#ff0000;">
</td>
</div>
</td>
</div>
</div>
<!-- End Custom Tabs -->
<div class="box-body">
<table id="Filters-Table">
<thead>
<th>Filters</th>
</thead>
<tbody>
<tr>
<td><label>Changed by: &nbsp</label></td>
<td>
<div class="autocomplete" style="width:250px;">
<input type="text" style=" border:1px solid #d2d6de; width:250px; height: 34px;" id="user_name_filter" name="user_name_filter" value="">
</div>
</td>
</tr>
<tr>
<td style="position: relative; top:10px;">
<label>Minimum date: &nbsp</label>
</td>
<td style="position: relative; top:10px;">
<input type="text" id="min" name="min" class="datepicker" autocomplete="off" style=" border:1px solid #d2d6de; width:250px; height: 34px;">
</td>
</tr>
<tr>
<td style="position: relative; top:20px;">
<label>Maximum date: &nbsp</label>
</td>
<td style="position: relative; top:20px;">
<input type="text" id="max" name="max" class="datepicker" autocomplete="off" style=" border:1px solid #d2d6de; width:250px; height: 34px;">
</td>
</tr>
<tr><td>&nbsp</td></tr>
<tr><td>&nbsp</td></tr>
<tr>
<td>
<button type="submit" id="search-submit" name="search-submit" class="btn btn-flat btn-primary button-filter">Search&nbsp;<i class="fa fa-search"></i></button>
</td>
<td>
<!-- &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; -->
<button id="clear-filters" name="clear-filters" class="btn btn-flat btn-warning button-clearf">Clear Filters&nbsp;<i class="fa fa-trash"></i></button>
</td>
</tr>
</tbody>
</table>
</div>
</form>
<div class="box-body">
<table id="tbl_history" class="table table-bordered table-striped">
<thead>
<tr>
<th>Changed by</th>
<th>Content</th>
<th>Time</th>
<th>Detail</th>
</tr>
</thead>
<tbody>
{% for history in histories %}
<tr class="odd gradeX">
<td>{{ history.created_by }}</td>
<td>{{ history.msg }}</td>
<td>{{ history.created_on }}</td>
<td width="6%">
<button type="button" class="btn btn-flat btn-primary history-info-button"
value='{{ history.detail }}'>Info&nbsp;<i class="fa fa-info"></i>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div id="table_from_ajax"></div>
<!-- /.box-body -->
</div>
<!-- /.box -->
@ -172,304 +65,31 @@
{% endblock %}
{% block extrascripts %}
<script>
/* Don't let user search with a blank main field */
var canSearch=true;
$(document).ready(function () {
$.ajax({
url: "/admin/history_table",
type: "get",
success: function(response) {
console.log('Submission was successful.');
$("#table_from_ajax").html(response);
// set up history data table
$("#tbl_history").DataTable({
"paging": true,
"lengthChange": false,
"searching": true,
"ordering": true,
"info": true,
"autoWidth": false,
"order": [
[2, "desc"]
],
"columnDefs": [{
"type": "time",
"render": function (data, type, row) {
return moment.utc(data).local().format('YYYY-MM-DD HH:mm:ss');
},
error: function(xhr) {
console.log("Sending data: ", data, " failed")
}
});
var minDate = $('#min');
var maxDate = $('#max');
domain_changelog = $('domain_changelog_only_checkbox');
// Show/hide filters
$('#domain_name_filter, #account_name_filter, #auth_name_filter').on('keyup change', function (e) {
if ( $('#domain_name_filter').val() == "" && $('#account_name_filter').val() == "" && $('#auth_name_filter').val() == "")
canSearch=false;
else
canSearch=true;
});
// Handle giving later mindate than current max
$('#min').on('change', function () {
if (minDate.val() > maxDate.val())
$('#max').datepicker('setDate', minDate.val() );
});
// Handle giving earlier maxdate than current min
$('#max').on('keyup change', function () {
if (maxDate.val() < minDate.val())
$('#min').datepicker('setDate', maxDate.val() );
});
$(function() {
$( ".datepicker" ).datepicker({
changeMonth: true,
changeYear: true,
format: "yyyy-mm-dd",
endDate: '+0'
});
// $(".datepicker").datepicker("option", "format", "yy-mm-dd")
});
"targets": 2
}]
});
$('.checkbox,.radio').iCheck({
checkboxClass: 'icheckbox_square-blue',
radioClass: 'iradio_square-blue',
increaseArea: '20%'
$(document.body).on('click', '.history-info-button', function () {
var modal = $("#modal_history_info");
var info = $(this).val();
$('#modal-code-content').html(json_library.prettyPrint(info));
modal.modal('show');
});
//Handle "ALL" Checkbox
$('#auth_all_checkbox').on('ifChecked',function() {
$('#auth_local_only_checkbox').iCheck('check');
$('#auth_oauth_only_checkbox').iCheck('check');
$('#auth_saml_only_checkbox').iCheck('check');
});
$('#auth_all_checkbox').on('ifUnchecked',function() {
//check if all were checked
if($('#auth_local_only_checkbox').is(':checked') && $('#auth_oauth_only_checkbox').is(':checked') && $('#auth_saml_only_checkbox').is(':checked'))
{
$('#auth_local_only_checkbox').iCheck('uncheck');
$('#auth_oauth_only_checkbox').iCheck('uncheck');
$('#auth_saml_only_checkbox').iCheck('uncheck');
}
});
//Handle other auth checkboxes
$('#auth_local_only_checkbox').on('ifChecked',function() {
//check if all others were checked
if($('#auth_oauth_only_checkbox').is(':checked') && $('#auth_saml_only_checkbox').is(':checked'))
$('#auth_all_checkbox').iCheck('check');
});
$('#auth_local_only_checkbox').on('ifUnchecked',function() {
$('#auth_all_checkbox').iCheck('uncheck');
});
$('#auth_oauth_only_checkbox').on('ifChecked',function() {
if($('#auth_local_only_checkbox').is(':checked') && $('#auth_saml_only_checkbox').is(':checked'))
$('#auth_all_checkbox').iCheck('check');
});
$('#auth_oauth_only_checkbox').on('ifUnchecked',function() {
$('#auth_all_checkbox').iCheck('uncheck');
});
$('#auth_saml_only_checkbox').on('ifChecked',function() {
if($('#auth_local_only_checkbox').is(':checked') && $('#auth_oauth_only_checkbox').is(':checked'))
$('#auth_all_checkbox').iCheck('check');
});
$('#auth_saml_only_checkbox').on('ifUnchecked',function() {
$('#auth_all_checkbox').iCheck('uncheck');
});
$(document.body).on("click", ".button-clearf", function (e) {
e.preventDefault();
$('#user_name_filter').val('');
$('#min').val('');
$('#max').val('');
$('#domain_name_filter').val('');
$('#account_name_filter').val('');
$('#auth_name_filter').val('');
$('#auth_all_checkbox').iCheck('check');
$('#domain_changelog_only_checkbox').iCheck('uncheck');
});
var all_doms = "{{all_domain_names}}".split(" ");
var all_accounts = "{{all_account_names}}".split(" ");
var all_usernames = "{{all_usernames}}".split(" ");
all_doms.pop(); // remove last element which is " "
all_accounts.pop();
all_usernames.pop();
function autocomplete(inp, arr) {
/*the autocomplete function takes two arguments,
the text field element and an array of possible autocompleted values:*/
var currentFocus;
/*execute a function when someone writes in the text field:*/
inp.addEventListener("input", function(e) {
var a, b, i, val = this.value;
/*close any already open lists of autocompleted values*/
closeAllLists();
if (!val) { return false;}
currentFocus = -1;
/*create a DIV element that will contain the items (values):*/
a = document.createElement("DIV");
a.setAttribute("id", this.id + "autocomplete-list");
a.setAttribute("class", "autocomplete-items");
/*append the DIV element as a child of the autocomplete container:*/
this.parentNode.appendChild(a);
/*for each item in the array...*/
for (i = 0; i < arr.length; i++) {
/*check if the item starts with the same letters as the text field value:*/
if (arr[i].substr(0, val.length).toUpperCase() == val.toUpperCase()) {
/*create a DIV element for each matching element:*/
b = document.createElement("DIV");
/*make the matching letters bold:*/
b.innerHTML = "<strong>" + arr[i].substr(0, val.length) + "</strong>";
b.innerHTML += arr[i].substr(val.length);
/*insert a input field that will hold the current array item's value:*/
b.innerHTML += "<input type='hidden' value='" + arr[i] + "'>";
/*execute a function when someone clicks on the item value (DIV element):*/
b.addEventListener("click", function(e) {
/*insert the value for the autocomplete text field:*/
inp.value = this.getElementsByTagName("input")[0].value;
/*close the list of autocompleted values,
(or any other open lists of autocompleted values:*/
closeAllLists();
});
a.appendChild(b);
}
}
});
/*execute a function presses a key on the keyboard:*/
inp.addEventListener("keydown", function(e) {
var x = document.getElementById(this.id + "autocomplete-list");
if (x) x = x.getElementsByTagName("div");
if (e.keyCode == 40) {
/*If the arrow DOWN key is pressed,
increase the currentFocus variable:*/
currentFocus++;
/*and and make the current item more visible:*/
addActive(x);
} else if (e.keyCode == 38) { //up
/*If the arrow UP key is pressed,
decrease the currentFocus variable:*/
currentFocus--;
/*and and make the current item more visible:*/
addActive(x);
} else if (e.keyCode == 13) {
/*If the ENTER key is pressed, prevent the form from being submitted,*/
e.preventDefault();
if (currentFocus > -1) {
/*and simulate a click on the "active" item:*/
if (x) x[currentFocus].click();
}
}
});
function addActive(x) {
/*a function to classify an item as "active":*/
if (!x) return false;
/*start by removing the "active" class on all items:*/
removeActive(x);
if (currentFocus >= x.length) currentFocus = 0;
if (currentFocus < 0) currentFocus = (x.length - 1);
/*add class "autocomplete-active":*/
x[currentFocus].classList.add("autocomplete-active");
}
function removeActive(x) {
/*a function to remove the "active" class from all autocomplete items:*/
for (var i = 0; i < x.length; i++) {
x[i].classList.remove("autocomplete-active");
}
}
function closeAllLists(elmnt) {
/*close all autocomplete lists in the document,
except the one passed as an argument:*/
var x = document.getElementsByClassName("autocomplete-items");
for (var i = 0; i < x.length; i++) {
if (elmnt != x[i] && elmnt != inp) {
x[i].parentNode.removeChild(x[i]);
}
}
}
/*execute a function when someone clicks in the document:*/
document.addEventListener("click", function (e) {
closeAllLists(e.target);
});
}
/*initiate the autocomplete function on the "myInput" element, and pass along the countries array as possible autocomplete values:*/
autocomplete(document.getElementById("domain_name_filter"), all_doms);
autocomplete(document.getElementById("account_name_filter"), all_accounts);
autocomplete(document.getElementById("auth_name_filter"), all_usernames);
autocomplete(document.getElementById("user_name_filter"), all_usernames);
// prevent multiple filter field at the same time
$('#domain_tab').click(function() {
$('#account_name_filter').val('');
$('#auth_name_filter').val('');
$('#user_name_filter').removeAttr('disabled');
canSearch=false;
main_field="Domain Name"
});
$('#account_tab').click(function() {
$('#domain_name_filter').val('');
$('#auth_name_filter').val('');
$('#user_name_filter').removeAttr('disabled');
canSearch=false;
main_field="Account Name"
});
$('#user_auth_tab').click( function() {
$('#domain_name_filter').val('');
$('#account_name_filter').val('');
$('#user_name_filter').val('');
$('#user_name_filter').attr('disabled','disabled');
canSearch=false;
main_field="Username"
});
$('#activity_tab').click( function() {
$('#domain_name_filter').val('');
$('#account_name_filter').val('');
$('#auth_name_filter').val('');
$('#user_name_filter').removeAttr('disabled');
$('#search-submit').removeAttr('disabled','disabled');
canSearch=true;
main_field=""
});
// if search submit is pressed, and max date not initialized
// then initialize it
$('#search-submit').on('click', function() {
if ($('#max').val() === "" || $('#max').val() === undefined)
$('#max').datepicker('setDate', 'now');
});
$("#history-search-form").submit(function(e){ // ajax call to load results on submition
e.preventDefault(); // prevent page reloading
if(!canSearch)
{
var modal = $("#modal_error");
modal.find('.modal-body p').text("Please fill out the " + main_field + " field.");
modal.modal('show');
}
else
{
var form = $(this);
var tzoffset = (new Date()).getTimezoneOffset();
$.ajax({
url: "/admin/history_table",
type: "get",
data: form.serialize() + "&tzoffset=" + tzoffset,
success: function(response) {
console.log('Submission was successful.');
$("#table_from_ajax").html(response);
},
error: function(xhr) {
console.log("Sending data: ", data, " failed")
}
});
}
});
</script>
{% endblock %}
{% block modals %}
@ -507,7 +127,7 @@
<h4 class="modal-title">History Details</h4>
</div>
<div class="modal-body">
<div id="modal-info-content"></div>
<pre><code id="modal-code-content"></code></pre>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-flat btn-default pull-right" data-dismiss="modal">Close</button>
@ -518,4 +138,4 @@
<!-- /.modal-dialog -->
</div>
<!-- /.modal -->
{% endblock %}
{% endblock %}

View file

@ -1,90 +0,0 @@
{% import 'applied_change_macro.html' as applied_change_macro %}
{% if len_histories >= lim %}
<p style="color: rgb(224, 3, 3);"><b>Limit of loaded history records has been reached! Only {{lim}} history records are shown. </b></p>
{% endif %}
<div class="box-body"></div>
<table id="tbl_history" class="table table-bordered table-striped">
<thead>
<tr>
<th>Changed by</th>
<th>Content</th>
<th>Time</th>
<th>Detail</th>
</tr>
</thead>
<tbody>
{% for history in histories %}
<tr class="odd gradeX">
<td>{{ history.history.created_by }}</td>
<td>{{ history.history.msg }}</td>
<td>{{ history.history.created_on }}</td>
<td width="6%">
<div id="history-info-div-{{ loop.index0 }}" style="display: none;">
{{ history.detailed_msg | safe }}
{% if history.change_set %}
<div class="content">
<div id="change_index_definition"></div>
{% call applied_change_macro.applied_change_template(history.change_set) %}
{% endcall %}
</div>
{% endif %}
</div>
<button type="button" class="btn btn-flat btn-primary history-info-button"
{% if history.detailed_msg == "" and history.change_set is none %}
style="visibility: hidden;"
{% endif %} value="{{ loop.index0 }}">Info&nbsp;<i class="fa fa-info"></i>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<script>
var table;
$(document).ready(function () {
table = $('#tbl_history').DataTable({
"order": [
[2, "desc"]
],
"searching": true,
"columnDefs": [{
"type": "time",
"render": function (data, type, row) {
return moment.utc(data).local().format('YYYY-MM-DD HH:mm:ss');
},
"targets": 2
}],
"info": true,
"autoWidth": false,
orderCellsTop: true,
fixedHeader: true
});
$(document.body).on('click', '.history-info-button', function () {
var modal = $("#modal_history_info");
var history_id = $(this).val();
var info = $("#history-info-div-" + history_id).html();
$('#modal-info-content').html(info);
modal.modal('show');
});
$(document.body).on("click", ".button-filter", function (e) {
e.stopPropagation();
var nextRow = $("#filter-table")
if (nextRow.css("visibility") == "visible")
nextRow.css("visibility", "collapse")
else
nextRow.css("visibility", "visible")
});
});
</script>

View file

@ -35,7 +35,6 @@
<th>Role</th>
<th>Description</th>
<th>Domains</th>
<th>Accounts</th>
<th>Action</th>
</tr>
</thead>
@ -46,7 +45,6 @@
<td>{{ key.role.name }}</td>
<td>{{ key.description }}</td>
<td>{% for domain in key.domains %}{{ domain.name }}{% if not loop.last %}, {% endif %}{% endfor %}</td>
<td>{% for account in key.accounts %}{{ account.name }}{% if not loop.last %}, {% endif %}{% endfor %}</td>
<td width="15%">
<button type="button" class="btn btn-flat btn-success button_edit"
onclick="window.location.href='{{ url_for('admin.edit_key', key_id=key.id) }}'">

View file

@ -51,7 +51,7 @@
<div class="nav-tabs-custom" id="tabs">
<ul class="nav nav-tabs">
<li class="active"><a href="#tabs-general" data-toggle="tab">General</a></li>
<li><a href="#tabs-ldap" data-toggle="tab">LDAP</a></li>
<li class="active"><a href="#tabs-ldap" data-toggle="tab">LDAP</a></li>
<li><a href="#tabs-google" data-toggle="tab">Google OAuth</a></li>
<li><a href="#tabs-github" data-toggle="tab">Github OAuth</a></li>
<li><a href="#tabs-azure" data-toggle="tab">Microsoft OAuth</a></li>
@ -76,7 +76,7 @@
</form>
</div>
<div class="tab-pane" id="tabs-ldap">
<div class="tab-pane active" id="tabs-ldap">
<div class="row">
<div class="col-md-4">
{% if error %}
@ -626,7 +626,7 @@
</div>
<div class="form-group">
<label for="oidc_oauth_logout_url">Logout URL</label>
<input type="text" class="form-control" name="oidc_oauth_logout_url" id="oidc_oauth_logout_url" placeholder="e.g. https://oidc.com/login/oauth/logout" data-error="Please input Logout URL" value="{{ SETTING.get('oidc_oauth_logout_url') }}">
<input type="text" class="form-control" name="oidc_oauth_logout_url" id="oidc_oauth_authorize_url" placeholder="e.g. https://oidc.com/login/oauth/logout" data-error="Please input Logout URL" value="{{ SETTING.get('oidc_oauth_logout_url') }}">
<span class="help-block with-errors"></span>
</div>
</fieldset>

View file

@ -1,133 +0,0 @@
{% macro applied_change_template(change_set) -%}
{{ caller() }}
{% for hist_rec_entry in change_set %}
<table id="tbl_records" class="table table-bordered">
<thead>
<tr>
<th colspan="3">
{% if hist_rec_entry.change_type == "+" %}
<span
style="background-color: lightgreen">{{hist_rec_entry.add_rrest['name']}}
{{hist_rec_entry.add_rrest['type']}}</span>
{% elif hist_rec_entry.change_type == "-" %}
<s
style="text-decoration-color: rgba(194, 10,10, 0.6); text-decoration-thickness: 2px;">
{{hist_rec_entry.del_rrest['name']}}
{{hist_rec_entry.del_rrest['type']}}
</s>
{% else %}
{{hist_rec_entry.add_rrest['name']}}
{{hist_rec_entry.add_rrest['type']}}
{% endif %}
, TTL:
{% if "ttl" in hist_rec_entry.changed_fields %}
<s
style="text-decoration-color: rgba(194, 10,10, 0.6); text-decoration-thickness: 2px;">
{{hist_rec_entry.del_rrest['ttl']}}</s>
<span
style="background-color: lightgreen">{{hist_rec_entry.add_rrest['ttl']}}</span>
{% else %}
{{hist_rec_entry.add_rrest['ttl']}}
{% endif %}
</th>
</tr>
<tr>
<th style="width: 150px;">Status</th>
<th style="width: 400px;">Data</th>
<th style="width: 400px;">Comment</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<table>
<tbody>
{% for changes in hist_rec_entry.changeSet %}
<tr>
{% if changes[2] == "unchanged" %}
<td>{{ "Activated" if changes[0]['disabled'] ==
False else
"Disabled"}} </td>
{% elif changes[2] == "addition" %}
<td>
<span style="background-color: lightgreen">
{{ "Activated" if changes[1]['disabled'] ==
False else
"Disabled"}}
</span>
</td>
{% elif changes[2] == "status" %}
<td>
<s
style="text-decoration-color: rgba(194, 10,10, 0.6); text-decoration-thickness: 2px;">
{{ "Activated" if changes[0]['disabled'] ==
False else
"Disabled"}}</s>
<span style="background-color: lightgreen">{{
"Activated" if changes[1]['disabled'] ==
False else
"Disabled"}}</span>
</td>
{% elif changes[2] == "deletion" %}
<td>
<s
style="text-decoration-color: rgba(194, 10,10, 0.6); text-decoration-thickness: 2px;">
{{ "Activated" if changes[0]['disabled'] ==
False else
"Disabled"}}</s>
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</td>
<td>
<table>
<tbody>
{% for changes in hist_rec_entry.changeSet %}
<tr>
{% if changes[2] == "unchanged" %}
<td>
{{changes[0]['content']}}
</td>
{% elif changes[2] == "addition" %}
<td>
<span style="background-color: lightgreen">
{{changes[1]['content']}}
</span>
</td>
{% elif changes[2] == "deletion" %}
<td>
<s
style="text-decoration-color: rgba(194, 10, 10, 0.6); text-decoration-thickness: 2px;">
{{changes[0]['content']}}
</s>
</td>
{% elif changes[2] == "status" %}
<td>
{{changes[0]['content']}}
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</td>
<td>
{% for comments in hist_rec_entry.add_rrest['comments'] %}
{{comments['content'] }}
<br/>
{% endfor %}
</td>
</tr>
</tbody>
</table>
{% endfor %}
{%- endmacro %}

View file

@ -6,7 +6,6 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<link rel="icon" href="{{ url_for('static', filename='img/favicon.png') }}">
{% block title %}<title>{{ SITE_NAME }}</title>{% endblock %}
<link rel="stylesheet" href="{{ url_for('static', filename='assets/css/style.css') }}">
<!-- Get Google Fonts we like -->
{% if OFFLINE_MODE %}
<link rel="stylesheet" href="{{ url_for('static', filename='assets/css/source_sans_pro.css') }}">
@ -22,9 +21,6 @@
{% assets "css_main" -%}
<link rel="stylesheet" href="{{ ASSET_URL }}">
{%- endassets %}
{% if SETTING.get('custom_css') %}
<link rel="stylesheet" href="/static/custom/{{ SETTING.get('custom_css') }}">
{% endif %}
<!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
<!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
<!--[if lt IE 9]>
@ -118,11 +114,6 @@
<a href="{{ url_for('domain.add') }}"><i class="fa fa-plus"></i> <span>New Domain</span></a>
</li>
{% endif %}
{% if SETTING.get('allow_user_remove_domain') or current_user.role.name in ['Administrator', 'Operator'] %}
<li class="{{ 'active' if active_page == 'remove_domain' else '' }}">
<a href="{{ url_for('domain.remove') }}"><i class="fa fa-trash-o"></i> <span>Remove Domain</span></a>
</li>
{% endif %}
{% if current_user.role.name in ['Administrator', 'Operator'] %}
<li class="header">ADMINISTRATION</li>
<li class="{{ 'active' if active_page == 'admin_console' else '' }}">

View file

@ -2,7 +2,6 @@
{% set active_page = "dashboard" %}
{% block title %}<title>Dashboard - {{ SITE_NAME }}</title>{% endblock %}
{% block dashboard_stat %}
<!-- Content Header (Page header) -->
<section class="content-header">
@ -17,8 +16,6 @@
</section>
{% endblock %}
{% import 'applied_change_macro.html' as applied_change_macro %}
{% block content %}
<!-- Main content -->
<section class="content">
@ -110,25 +107,13 @@
<tbody>
{% for history in histories %}
<tr class="odd">
<td>{{ history.history.created_by }}</td>
<td>{{ history.history.msg }}</td>
<td>{{ history.history.created_on }}</td>
<td>{{ history.created_by }}</td>
<td>{{ history.msg }}</td>
<td>{{ history.created_on }}</td>
<td width="6%">
<div id="history-info-div-{{ loop.index0 }}" style="display: none;">
{{ history.detailed_msg | safe }}
{% if history.change_set %}
<div class="content">
<div id="change_index_definition"></div>
{% call applied_change_macro.applied_change_template(history.change_set) %}
{% endcall %}
</div>
{% endif %}
</div>
<button type="button" class="btn btn-flat btn-primary history-info-button"
{% if history.detailed_msg == "" and history.change_set is none %}
style="visibility: hidden;"
{% endif %} value="{{ loop.index0 }}">Info&nbsp;<i class="fa fa-info"></i>
</button>
<button type="button" class="btn btn-flat btn-primary history-info-button" value='{{ history.detail }}'>
Info&nbsp;<i class="fa fa-info"></i>
</button>
</td>
</tr>
{% endfor %}
@ -241,11 +226,11 @@
]
});
$(document.body).on('click', '.history-info-button', function () {
$(document.body).on('click', '.history-info-button', function()
{
var modal = $("#modal_history_info");
var history_id = $(this).val();
var info = $("#history-info-div-" + history_id).html();
$('#modal-info-content').html(info);
var info = $(this).val();
$('#modal-code-content').html(json_library.prettyPrint(info));
modal.modal('show');
});
@ -312,7 +297,7 @@
<h4 class="modal-title">History Details</h4>
</div>
<div class="modal-body">
<div id="modal-info-content"></div>
<pre><code id="modal-code-content"></code></pre>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-flat btn-default pull-right"

View file

@ -15,7 +15,7 @@
{% endmacro %}
{% macro serial(domain) %}
{% if domain.serial == '0' %}{{ domain.notified_serial }}{% else %}{{ domain.serial }}{% endif %}
{% if domain.serial == 0 %}{{ domain.notified_serial }}{% else %}{{ domain.serial }}{% endif %}
{% endmacro %}
{% macro master(domain) %}
@ -40,20 +40,12 @@
<button type="button" class="btn btn-flat btn-danger" onclick="window.location.href='{{ url_for('domain.setting', domain_name=domain.name) }}'">
Admin&nbsp;<i class="fa fa-cog"></i>
</button>
<button type="button" class="btn btn-flat btn-primary" onclick="window.location.href='{{ url_for('domain.changelog', domain_name=domain.name) }}'">
Changelog&nbsp;<i class="fa fa-history" aria-hidden="true"></i>
</button>
</td>
{% else %}
<td width="6%">
<button type="button" class="btn btn-flat btn-success" onclick="window.location.href='{{ url_for('domain.domain', domain_name=domain.name) }}'">
Manage&nbsp;<i class="fa fa-cog"></i>
</button>
{% if allow_user_view_history %}
<button type="button" class="btn btn-flat btn-primary" onclick="window.location.href='{{ url_for('domain.changelog', domain_name=domain.name) }}'">
Changelog&nbsp;<i class="fa fa-history" aria-hidden="true"></i>
</button>
{% endif %}
</td>
{% endif %}
{% endif %}
{% endmacro %}

46
powerdnsadmin/templates/domain.html Executable file → Normal file
View file

@ -33,17 +33,6 @@
Update from Master&nbsp;<i class="fa fa-download"></i>
</button>
{% endif %}
{% if current_user.role.name in ['Administrator', 'Operator'] %}
<button type="button" style="position: relative; margin-left: 20px" class="btn btn-flat btn-primary pull-left btn-danger" onclick="window.location.href='{{ url_for('domain.setting', domain_name=domain.name) }}'">
Admin&nbsp;<i class="fa fa-cog"></i>
</button>
{% endif %}
{% if current_user.role.name in ['Administrator', 'Operator'] or SETTING.get('allow_user_view_history') %}
<button type="button" style="position: relative; margin-left: 20px" class="btn btn-flat btn-primary button_changelog" id="{{ domain.name }}">
Changelog&nbsp;<i class="fa fa-history" aria-hidden="true"></i>
</i>
</button>
{% endif %}
</div>
<div class="box-body">
<table id="tbl_records" class="table table-bordered table-striped">
@ -57,9 +46,6 @@
<th>Comment</th>
<th>Edit</th>
<th>Delete</th>
{% if current_user.role.name in ['Administrator', 'Operator'] or SETTING.get('allow_user_view_history') %}
<th >Changelog</th>
{% endif %}
</tr>
</thead>
<tbody>
@ -105,13 +91,6 @@
</td>
{% endif %}
</td>
{% if current_user.role.name in ['Administrator', 'Operator'] or SETTING.get('allow_user_view_history') %}
<td width="6%">
<button type="button" onclick="show_record_changelog('{{record.name}}','{{record.type}}',event)" class="btn btn-flat btn-primary">&nbsp;&nbsp;
<i class="fa fa-history" aria-hidden="true"></i>&nbsp;&nbsp;&nbsp;
</button>
</td>
{% endif %}
<!-- hidden column that we can sort on -->
<td>1</td>
</tr>
@ -165,33 +144,14 @@
// hidden column so that we can add new records on top
// regardless of whatever sorting is done. See orderFixed
visible: false,
{% if current_user.role.name in ['Administrator', 'Operator'] or SETTING.get('allow_user_view_history') %}
targets: [ 9 ]
{% else %}
targets: [ 8 ]
{% endif %}
},
{
className: "length-break",
targets: [ 4, 5 ]
}
],
{% if current_user.role.name in ['Administrator', 'Operator'] or SETTING.get('allow_user_view_history') %}
"orderFixed": [[9, 'asc']]
{% else %}
"orderFixed": [[8, 'asc']]
{% endif %}
});
function show_record_changelog(record_name, record_type, e) {
e.stopPropagation();
window.location.href = "/domain/{{domain.name}}/changelog/" + record_name + ".-" + record_type;
}
// handle changelog button
$(document.body).on("click", ".button_changelog", function(e) {
e.stopPropagation();
window.location.href = "/domain/{{domain.name}}/changelog";
});
// handle delete button
@ -283,11 +243,7 @@
// add new row
var default_type = records_allow_edit[0]
{% if current_user.role.name in ['Administrator', 'Operator'] or SETTING.get('allow_user_view_history') %}
var nRow = jQuery('#tbl_records').dataTable().fnAddData(['', default_type, 'Active', window.ttl_options[0][0], '', '', '', '', '', '0']);
{% else %}
var nRow = jQuery('#tbl_records').dataTable().fnAddData(['', default_type, 'Active', window.ttl_options[0][0], '', '', '', '', '0']);
{% endif %}
var nRow = jQuery('#tbl_records').dataTable().fnAddData(['', default_type, 'Active', window.ttl_options[0][0], '', '', '', '', '0']);
editRow($("#tbl_records").DataTable(), nRow);
document.getElementById("edit-row-focus").focus();
nEditing = nRow;

View file

@ -1,116 +0,0 @@
{% extends "base.html" %}
{% block title %}<title>{{ domain.name | pretty_domain_name }} - {{ SITE_NAME }}</title>{% endblock %}
{% block dashboard_stat %}
<section class="content-header">
<h1>
{% if record_name and record_type %}
Record changelog: <b>{{ record_name}} &nbsp {{ record_type }}</b>
{% else %}
Domain changelog: <b>{{ domain.name | pretty_domain_name }}</b>
{% endif %}
</h1>
<ol class="breadcrumb">
<li><a href="{{ url_for('dashboard.dashboard') }}"><i class="fa fa-dashboard"></i> Home</a></li>
<li>Domain</li>
<li class="active">{{ domain.name | pretty_domain_name }}</li>
</ol>
</section>
{% endblock %}
{% import 'applied_change_macro.html' as applied_change_macro %}
{% block content %}
<section class="content">
<div class="row">
<div class="col-xs-12">
<div class="box">
<div class="box-body">
<button type="button" class="btn btn-flat btn-primary pull-left button_show_records"
id="{{ domain.name }}">
Manage &nbsp;<i class="fa fa-arrow-left"></i>
</button>
</div>
<div class="box-body">
<table id="tbl_changelog" class="table table-bordered table-striped">
<thead>
<tr>
<th>Changed on</th>
<th>Changed by</th>
</tr>
</thead>
<tbody>
{% for applied_change in allHistoryChanges %}
<tr class="odd row_record" id="{{ domain.name }}">
<td id="changed_on" class="changed_on">
{{ allHistoryChanges[applied_change][0].history_entry.created_on }}
</td>
<td>
{{allHistoryChanges[applied_change][0].history_entry.created_by }}
</td>
</tr>
<!-- Nested Table -->
<tr style='visibility:collapse'>
<td colspan="2">
<div class="content">
{% call applied_change_macro.applied_change_template(allHistoryChanges[applied_change]) %}
{% endcall %}
</div>
</td>
</tr>
<!-- end nested table -->
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</section>
{% endblock %}
{% block extrascripts %}
<script>
// handle "show records" button
$(document.body).on("click", ".button_show_records", function (e) {
e.stopPropagation();
window.location.href = "/domain/{{domain.name}}";
});
var coll = document.getElementsByClassName("collapsible");
var i;
for (i = 0; i < coll.length; i++) {
coll[i].addEventListener("click", function () {
this.classList.toggle("active");
var content = this.nextElementSibling;
if (content.style.maxHeight) {
content.style.maxHeight = null;
} else {
content.style.maxHeight = content.scrollHeight + "px";
}
});
}
// handle click on history record
$(document.body).on("click", ".row_record", function (e) {
e.stopPropagation();
var nextRow = $(this).next('tr')
if (nextRow.css("visibility") == "visible")
nextRow.css("visibility", "collapse")
else
nextRow.css("visibility", "visible")
});
var els = document.getElementsByClassName("changed_on");
for (var i = 0; i < els.length; i++) {
// els[i].innerHTML = moment.utc(els[i].innerHTML).local().format('YYYY-MM-DD HH:mm:ss');
els[i].innerHTML = moment.utc(els[i].innerHTML,'YYYY-MM-DD HH:mm:ss').local().format('YYYY-MM-DD HH:mm:ss');
}
</script>
{% endblock %}

View file

@ -1,129 +0,0 @@
{% extends "base.html" %}
{% set active_page = "remove_domain" %}
{% block title %}<title>Remove Domain - {{ SITE_NAME }}</title>{% endblock %}
{% block dashboard_stat %}
<!-- Content Header (Page header) -->
<section class="content-header">
<h1>
Domain
<small>Remove existing</small>
</h1>
<ol class="breadcrumb">
<li><a href="{{ url_for('dashboard.dashboard') }}"><i class="fa fa-dashboard"></i>Home</a></li>
<li><a href="{{ url_for('dashboard.dashboard') }}">Domain</a></li>
<li class="active">Remove Domain</li>
</ol>
</section>
{% endblock %}
{% block content %}
<section class="content">
<div class="row">
<div class="col-md-4">
<div class="box box-primary">
<div class="box-header with-border">
<h3 class="box-title">Remove domain</h3>
</div>
<!-- /.box-header -->
<!-- form start -->
<form role="form" method="post" action="{{ url_for('domain.remove') }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="box-body">
<select id=domainid class="form-control" style="width:15em;">
<option value="0">- Select Domain -</option>
{% for domain in domainss %}
<option value="{{ domain.id }}">{{ domain.name }}</option>
{% endfor %}
</select><br />
</div>
<!-- /.box-body -->
<div class="box-footer">
<button type="button" class="btn btn-flat btn-danger button_delete">Remove</button>
<button type="button" class="btn btn-flat btn-default"
onclick="window.location.href='{{ url_for('dashboard.dashboard') }}'">Cancel</button>
</div>
</form>
</div>
<!-- /.box -->
</div>
<div class="col-md-8">
<div class="box box-primary">
<div class="box-header with-border">
<h3 class="box-title">Help with removing a new domain</h3>
</div>
<div class="box-body">
<dl class="dl-horizontal">
<dt>Domain name</dt>
<dd>Select domain you wish to remove from DNS.</dd>
</dl>
<p>Find more details at <a href="https://docs.powerdns.com/md/">https://docs.powerdns.com/md/</a>
</p>
</div>
</div>
</div>
</div>
</section>
{% endblock %}
{% block extrascripts %}
<script>
// handle delete button
$(document.body).on("click", ".button_delete", function(e) {
e.stopPropagation();
if ( $("#domainid").val() == 0 ){
var modal = $("#modal_error");
modal.find('.modal-body p').text("Please select domain to remove.");
modal.modal('show');
return;
}
var modal = $("#modal_delete");
var domain = $("#domainid option:selected").text();
var info = "Are you sure you want to delete " + domain + "?";
modal.find('.modal-body p').text(info);
modal.find('#button_delete_confirm').click(function () {
$.post($SCRIPT_ROOT + '/domain/remove' , {
'_csrf_token': '{{ csrf_token() }}',
'domainid': domain,
}, function () {
window.location.href = '{{ url_for('dashboard.dashboard') }}';
});
modal.modal('hide');
})
modal.modal('show');
$("#button_delete_cancel").unbind().one('click', function(e) {
modal.modal('hide');
});
});
</script>
{% endblock %}
{% block modals %}
<div class="modal fade modal-warning" id="modal_delete">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal"
aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
<h4 class="modal-title">Confirmation</h4>
</div>
<div class="modal-body">
<p></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-flat btn-default pull-left" id="button_delete_cancel"
data-dismiss="modal">Close</button>
<button type="button" class="btn btn-flat btn-danger" id="button_delete_confirm">Delete</button>
</div>
</div>
<!-- /.modal-content -->
</div>
<!-- /.modal-dialog -->
</div>
{% endblock %}

View file

@ -11,9 +11,7 @@
{% assets "css_login" -%}
<link rel="stylesheet" href="{{ ASSET_URL }}">
{%- endassets %}
{% if SETTING.get('custom_css') %}
<link rel="stylesheet" href="/static/custom/{{ SETTING.get('custom_css') }}">
{% endif %}
<!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
<!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
<!--[if lt IE 9]>
@ -48,11 +46,9 @@
data-error="Please input your password" required {% if password %}value="{{ password }}" {% endif %}>
<span class="help-block with-errors"></span>
</div>
{% if SETTING.get('otp_field_enabled') %}
<div class="form-group">
<input type="otptoken" class="form-control" placeholder="OTP Token" name="otptoken" autocomplete="off">
<input type="otptoken" class="form-control" placeholder="OTP Token" name="otptoken">
</div>
{% endif %}
{% if SETTING.get('ldap_enabled') and SETTING.get('local_db_enabled') %}
<div class="form-group">
<select class="form-control" name="auth_method">

View file

@ -1,90 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Welcome - {{ SITE_NAME }}</title>
<link rel="icon" href="{{ url_for('static', filename='img/favicon.png') }}">
<!-- Tell the browser to be responsive to screen width -->
<meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport">
{% assets "css_login" -%}
<link rel="stylesheet" href="{{ ASSET_URL }}">
{%- endassets %}
{% if SETTING.get('custom_css') %}
<link rel="stylesheet" href="/static/custom/{{ SETTING.get('custom_css') }}">
{% endif %}
</head>
<body class="hold-transition register-page">
<div class="register-box">
<div class="register-logo">
<a><b>PowerDNS</b>-Admin</a>
</div>
<div class="register-box-body">
{% if error %}
<div class="alert alert-danger alert-dismissible">
<button type="button" class="close" data-dismiss="alert" aria-hidden="true">&times;</button>
{{ error }}
</div>
{% endif %}
Welcome, {{user.firstname}}! <br />
You will need a Token on login. <br />
Your QR code is:
<div id="token_information">
{% if qrcode_image == None %}
<p><img id="qrcode" src="{{ url_for('user.qrcode') }}"></p>
{% else %}
<p><img id="qrcode" src="data:image/svg+xml;utf8;base64, {{qrcode_image}}"></p>
{% endif %}
<p>
Your secret key is: <br />
<form>
<input type=text id="otp_secret" value={{user.otp_secret}} readonly>
<button type=button style="position:relative; right:28px" onclick="copy_otp_secret_to_clipboard()"> <i class="fa fa-clipboard"></i> </button>
<br /><font color="red" id="copy_tooltip" style="visibility:collapse">Copied.</font>
</form>
</p>
You can use Google Authenticator (<a target="_blank"
href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2">Android</a>
- <a target="_blank"
href="https://apps.apple.com/us/app/google-authenticator/id388497605">iOS</a>)
<br />
or FreeOTP (<a target="_blank"
href="https://play.google.com/store/apps/details?id=org.fedorahosted.freeotp&hl=en">Android</a>
- <a target="_blank"
href="https://itunes.apple.com/en/app/freeotp-authenticator/id872559395?mt=8">iOS</a>)
on your smartphone <br /> to scan the QR code or type the secret key.
<br /> <br />
<font color="red"><strong><i>Make sure only you can see this QR Code <br />
and secret key, and nobody can capture them.</i></strong></font>
</div>
</br>
Please input your OTP token to continue, to ensure the seed has been scanned correctly.
<form action="" method="post" data-toggle="validator">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<input type="text" class="form-control" placeholder="OTP Token" name="otptoken"
data-error="Please input your OTP token" required>
</div>
<div class="row">
<div class="col-xs-4">
<button type="submit" class="btn btn-flat btn-primary btn-block">Continue</button>
</div>
</div>
</form>
</div>
<div class="login-box-footer">
<center>
<p>Powered by <a href="https://github.com/ngoduykhanh/PowerDNS-Admin">PowerDNS-Admin</a></p>
</center>
</div>
</div>
</body>
{% assets "js_login" -%}
<script type="text/javascript" src="{{ ASSET_URL }}"></script>
{%- endassets %}
{% assets "js_validation" -%}
<script type="text/javascript" src="{{ ASSET_URL }}"></script>
{%- endassets %}
</html>

View file

@ -51,7 +51,7 @@
{% if session['authentication_type'] != 'LOCAL' %}disabled{% endif %}>
</div>
<div class="form-group">
<label for="email">E-mail</label> <input type="email" class="form-control"
<label for="email">E-mail</label> <input type="text" class="form-control"
name="email" id="email" placeholder="{{ current_user.email }}"
{% if session['authentication_type'] != 'LOCAL' %}disabled{% endif %}>
</div>{% if session['authentication_type'] == 'LOCAL' %}
@ -93,14 +93,6 @@
{% if current_user.otp_secret %}
<div id="token_information">
<p><img id="qrcode" src="{{ url_for('user.qrcode') }}"></p>
<div style="position: relative; left: 15px">
Your secret key is: <br />
<form>
<input type=text id="otp_secret" value={{current_user.otp_secret}} readonly>
<button type=button style="position:relative; right:28px" onclick="copy_otp_secret_to_clipboard()"> <i class="fa fa-clipboard"></i> </button>
<br /><font color="red" id="copy_tooltip" style="visibility:collapse">Copied.</font>
</form>
</div>
You can use Google Authenticator (<a target="_blank"
href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2">Android</a>
- <a target="_blank"
@ -111,8 +103,8 @@
href="https://itunes.apple.com/en/app/freeotp-authenticator/id872559395?mt=8">iOS</a>)
on your smartphone to scan the QR code.
<br />
<font color="red"><strong><i>Make sure only you can see this QR Code and secret key and
nobody can capture them.</i></strong></font>
<font color="red"><strong><i>Make sure only you can see this QR Code and
nobody can capture it.</i></strong></font>
</div>
{% endif %}
</div>

View file

@ -8,7 +8,7 @@ mysqlclient==2.0.1
configobj==5.0.6
bcrypt>=3.1.7
requests==2.24.0
python-ldap==3.4.0
python-ldap==3.3.1
pyotp==2.4.0
qrcode==6.1
dnspython>=1.16.0
@ -17,7 +17,7 @@ python3-saml
pyOpenSSL==19.1.0
pytz==2020.1
cssmin==0.2.0
jsmin==3.0.0
jsmin==2.2.2
Authlib==0.15
Flask-SeaSurf==0.2.2
bravado-core==5.17.0
@ -27,4 +27,4 @@ pytimeparse==1.1.8
PyYAML==5.4
Flask-SSLify==0.1.5
Flask-Mail==0.9.1
flask-session==0.3.2
flask-session==0.3.2

1990
swagger-specv2.yaml Normal file

File diff suppressed because it is too large Load diff

View file

@ -690,8 +690,11 @@ jquery-ui-dist@^1.12.1:
resolved "https://registry.yarnpkg.com/jquery-ui-dist/-/jquery-ui-dist-1.12.1.tgz#5c0815d3cc6f90ff5faaf5b268a6e23b4ca904fa"
jquery-ui@^1.12.1:
version "1.12.1"
resolved "https://registry.yarnpkg.com/jquery-ui/-/jquery-ui-1.12.1.tgz#bcb4045c8dd0539c134bc1488cdd3e768a7a9e51"
version "1.13.0"
resolved "https://registry.yarnpkg.com/jquery-ui/-/jquery-ui-1.13.0.tgz#ab5ac65f37ca093c51b3478c4097f55bbc008f36"
integrity sha512-Osf7ECXNTYHtKBkn9xzbIf9kifNrBhfywFEKxOeB/OVctVmLlouV9mfc2qXCp6uyO4Pn72PXKOnj09qXetopCw==
dependencies:
jquery ">=1.8.0 <4.0.0"
jquery.quicksearch@^2.4.0:
version "2.4.0"
@ -700,9 +703,10 @@ jquery.quicksearch@^2.4.0:
dependencies:
jquery ">=1.8"
"jquery@>= 1.7", "jquery@>= 1.7.1", jquery@>=1.10, jquery@>=1.5, jquery@>=1.7, "jquery@>=1.7.1 <4.0.0", jquery@>=1.8, jquery@^3.2.1:
version "3.3.1"
resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.3.1.tgz#958ce29e81c9790f31be7792df5d4d95fc57fbca"
"jquery@>= 1.7", "jquery@>= 1.7.1", jquery@>=1.10, jquery@>=1.5, jquery@>=1.7, "jquery@>=1.7.1 <4.0.0", jquery@>=1.8, "jquery@>=1.8.0 <4.0.0", jquery@^3.2.1:
version "3.6.0"
resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.6.0.tgz#c72a09f15c1bdce142f49dbf1170bdf8adac2470"
integrity sha512-JVzAR/AjBvVt2BmYhxRCSYysDsPcssdmTFnzyLEts9qNwmjmu4JTAMYubEfwVOSwpQ1I1sKKFcxhZCI2buerfw==
json-stable-stringify@~0.0.0:
version "0.0.1"