Compare commits
105 commits
Author | SHA1 | Date | |
---|---|---|---|
cd94b5c0ac | |||
0b2ad520b7 | |||
302e793665 | |||
328780e2d4 | |||
ca4c145a18 | |||
7808febad8 | |||
9ef0f2b8d6 | |||
94a923a965 | |||
0da9b2185e | |||
07f0d215a7 | |||
fc8367535b | |||
d2f35a4059 | |||
737e1fb93b | |||
f0008ce401 | |||
6f12b783a8 | |||
51a7f636b0 | |||
9f46188c7e | |||
caa48b7fe5 | |||
591055d4aa | |||
940551e99e | |||
f45ff2ce03 | |||
6c1dfd2408 | |||
701a442d12 | |||
a3b70a8f47 | |||
1332c8d29d | |||
b3f9b4a2b0 | |||
bfaf5655ae | |||
dd04a837bb | |||
5bb1a7ee29 | |||
c85a5dac24 | |||
3081036c2c | |||
c7b4aa3434 | |||
e7d5a3aba0 | |||
20b866a784 | |||
1662a812ba | |||
c49df09ac8 | |||
924537b468 | |||
4f8a547d47 | |||
ee9f568a8d | |||
d7ae34ed53 | |||
1c9ca60508 | |||
0e655c1357 | |||
ba2423d6f5 | |||
46e51f16cb | |||
b8ee91ab9a | |||
c246775ffe | |||
f96103db79 | |||
bf83662108 | |||
1f34dbf810 | |||
b7197948c1 | |||
ddf2d4788b | |||
1ec6b76f89 | |||
4ce1b71c57 | |||
79457bdc85 | |||
10dc2b0273 | |||
993e02b635 | |||
07c71fb0bf | |||
c4a9498898 | |||
6e04d0419b | |||
d6e64dce8e | |||
b069cea8d1 | |||
fd933f8dbc | |||
0505b934a1 | |||
083a023e57 | |||
054e0e6eba | |||
c13dd2d835 | |||
567f66fbde | |||
ff5270fbad | |||
92bad7b11c | |||
43a6e46e66 | |||
ee72fdf9c2 | |||
8f73512d2e | |||
700fa0d9ce | |||
00dc23f21b | |||
36fdb3733f | |||
ce60ca0b9d | |||
b197491a86 | |||
d23a57da50 | |||
4180882fb7 | |||
bbbcf271fe | |||
32983635c6 | |||
f3a98eb692 | |||
39cddd3b34 | |||
b66b37ecfd | |||
5f10f739ea | |||
98db953820 | |||
44c4531f02 | |||
86700f8fd7 | |||
46993e08c0 | |||
4c19f95928 | |||
3a4efebf95 | |||
7f86730909 | |||
8f6a800836 | |||
3cd98251b3 | |||
54b257768f | |||
718b41e3d1 | |||
dd0a5f6326 | |||
c3d438842f | |||
33e7ffb747 | |||
2c18e5c88f | |||
2917c47fd1 | |||
c6e0293177 | |||
942482b706 | |||
4d1db72699 | |||
680e4cf431 |
1
.github/FUNDING.yml
vendored
1
.github/FUNDING.yml
vendored
|
@ -1 +0,0 @@
|
|||
github: [ngoduykhanh]
|
56
.github/workflows/build-and-publish.yml
vendored
Normal file
56
.github/workflows/build-and-publish.yml
vendored
Normal file
|
@ -0,0 +1,56 @@
|
|||
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 }}
|
|
@ -1,5 +0,0 @@
|
|||
language: minimal
|
||||
script:
|
||||
- docker-compose -f docker-compose-test.yml up --exit-code-from powerdns-admin --abort-on-container-exit
|
||||
services:
|
||||
- docker
|
12
README.md
12
README.md
|
@ -1,7 +1,6 @@
|
|||
# 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)
|
||||
|
||||
|
@ -18,6 +17,7 @@ A PowerDNS web interface with advanced features.
|
|||
- DynDNS 2 protocol support
|
||||
- Edit IPv6 PTRs using IPv6 addresses directly (no more editing of literal addresses!)
|
||||
- Limited API for manipulating zones and records
|
||||
- Full IDN/Punycode support
|
||||
|
||||
## Running PowerDNS-Admin
|
||||
There are several ways to run PowerDNS-Admin. The easiest way is to use Docker.
|
||||
|
@ -31,6 +31,7 @@ To get started as quickly as possible try option 1. If you want to make modifica
|
|||
The easiest is to just run the latest Docker image from Docker Hub:
|
||||
```
|
||||
$ docker run -d \
|
||||
-e SECRET_KEY='a-very-secret-key' \
|
||||
-v pda-data:/data \
|
||||
-p 9191:80 \
|
||||
ngoduykhanh/powerdns-admin:latest
|
||||
|
@ -38,10 +39,11 @@ $ docker run -d \
|
|||
This creates a volume called `pda-data` to persist the SQLite database with the configuration.
|
||||
|
||||
#### Option 2: Using docker-compose
|
||||
1. Update the configuration
|
||||
1. Update the configuration
|
||||
Edit the `docker-compose.yml` file to update the database connection string in `SQLALCHEMY_DATABASE_URI`.
|
||||
Other environment variables are mentioned in the [legal_envvars](https://github.com/ngoduykhanh/PowerDNS-Admin/blob/master/configs/docker_config.py#L5-L46).
|
||||
To use the Docker secrets feature it is possible to append `_FILE` to the environment variables and point to a file with the values stored in it.
|
||||
To use the Docker secrets feature it is possible to append `_FILE` to the environment variables and point to a file with the values stored in it.
|
||||
Make sure to set the environment variable `SECRET_KEY` to a long random string (https://flask.palletsprojects.com/en/1.1.x/config/#SECRET_KEY)
|
||||
|
||||
2. Start docker container
|
||||
```
|
||||
|
@ -56,7 +58,3 @@ You can then access PowerDNS-Admin by pointing your browser to http://localhost:
|
|||
## LICENSE
|
||||
MIT. See [LICENSE](https://github.com/ngoduykhanh/PowerDNS-Admin/blob/master/LICENSE)
|
||||
|
||||
## Support
|
||||
If you like the project and want to support it, you can *buy me a coffee* ☕
|
||||
|
||||
<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>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import os
|
||||
basedir = os.path.abspath(os.path.abspath(os.path.dirname(__file__)))
|
||||
#import urllib.parse
|
||||
basedir = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
### BASIC APP CONFIG
|
||||
SALT = '$2b$12$yLUMTIfl21FKJQpTkRQXCu'
|
||||
|
@ -16,7 +17,12 @@ SQLA_DB_NAME = 'pda'
|
|||
SQLALCHEMY_TRACK_MODIFICATIONS = True
|
||||
|
||||
### DATABASE - MySQL
|
||||
# SQLALCHEMY_DATABASE_URI = 'mysql://' + SQLA_DB_USER + ':' + SQLA_DB_PASSWORD + '@' + SQLA_DB_HOST + '/' + SQLA_DB_NAME
|
||||
#SQLALCHEMY_DATABASE_URI = 'mysql://{}:{}@{}/{}'.format(
|
||||
# urllib.parse.quote_plus(SQLA_DB_USER),
|
||||
# urllib.parse.quote_plus(SQLA_DB_PASSWORD),
|
||||
# SQLA_DB_HOST,
|
||||
# SQLA_DB_NAME
|
||||
#)
|
||||
|
||||
### DATABASE - SQLite
|
||||
SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'pdns.db')
|
||||
|
|
|
@ -5,6 +5,9 @@ SQLALCHEMY_DATABASE_URI = 'sqlite:////data/powerdns-admin.db'
|
|||
|
||||
legal_envvars = (
|
||||
'SECRET_KEY',
|
||||
'OIDC_OAUTH_API_URL',
|
||||
'OIDC_OAUTH_TOKEN_URL',
|
||||
'OIDC_OAUTH_AUTHORIZE_URL',
|
||||
'BIND_ADDRESS',
|
||||
'PORT',
|
||||
'LOG_LEVEL',
|
||||
|
@ -47,7 +50,13 @@ legal_envvars = (
|
|||
'SAML_ASSERTION_ENCRYPTED',
|
||||
'OFFLINE_MODE',
|
||||
'REMOTE_USER_LOGOUT_URL',
|
||||
'REMOTE_USER_COOKIES'
|
||||
'REMOTE_USER_COOKIES',
|
||||
'SIGNUP_ENABLED',
|
||||
'LOCAL_DB_ENABLED',
|
||||
'LDAP_ENABLED',
|
||||
'SAML_CERT',
|
||||
'SAML_KEY',
|
||||
'FILESYSTEM_SESSIONS_ENABLED'
|
||||
)
|
||||
|
||||
legal_envvars_int = ('PORT', 'MAIL_PORT', 'SAML_METADATA_CACHE_LIFETIME')
|
||||
|
@ -65,7 +74,11 @@ legal_envvars_bool = (
|
|||
'SAML_LOGOUT',
|
||||
'SAML_ASSERTION_ENCRYPTED',
|
||||
'OFFLINE_MODE',
|
||||
'REMOTE_USER_ENABLED'
|
||||
'REMOTE_USER_ENABLED',
|
||||
'SIGNUP_ENABLED',
|
||||
'LOCAL_DB_ENABLED',
|
||||
'LDAP_ENABLED',
|
||||
'FILESYSTEM_SESSIONS_ENABLED'
|
||||
)
|
||||
|
||||
# import everything from environment variables
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import os
|
||||
basedir = os.path.abspath(os.path.abspath(os.path.dirname(__file__)))
|
||||
basedir = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
### BASIC APP CONFIG
|
||||
SALT = '$2b$12$yLUMTIfl21FKJQpTkRQXCu'
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
FROM alpine:3.12 AS builder
|
||||
FROM alpine:3.13 AS builder
|
||||
LABEL maintainer="k@ndk.name"
|
||||
|
||||
ARG BUILD_DEPENDENCIES="build-base \
|
||||
|
@ -8,7 +8,8 @@ ARG BUILD_DEPENDENCIES="build-base \
|
|||
openldap-dev \
|
||||
python3-dev \
|
||||
xmlsec-dev \
|
||||
yarn"
|
||||
yarn \
|
||||
cargo"
|
||||
|
||||
ENV LC_ALL=en_US.UTF-8 \
|
||||
LANG=en_US.UTF-8 \
|
||||
|
@ -64,16 +65,8 @@ 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 && \
|
||||
pip uninstall -y pip-autoremove && \
|
||||
apk del ${BUILD_DEPENDENCIES}
|
||||
|
||||
# Build image
|
||||
FROM alpine:3.12
|
||||
FROM alpine:3.13
|
||||
|
||||
ENV FLASK_APP=/app/powerdnsadmin/__init__.py \
|
||||
USER=pda
|
||||
|
@ -92,7 +85,7 @@ COPY --from=builder --chown=root:${USER} /app /app/
|
|||
COPY ./docker/entrypoint.sh /usr/bin/
|
||||
|
||||
WORKDIR /app
|
||||
RUN chown ${USER}:${USER} ./configs && \
|
||||
RUN chown ${USER}:${USER} ./configs /app && \
|
||||
cat ./powerdnsadmin/default_config.py ./configs/docker_config.py > ./powerdnsadmin/docker_config.py
|
||||
|
||||
EXPOSE 80/tcp
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
set -euo pipefail
|
||||
cd /app
|
||||
|
||||
GUNICORN_TIMEOUT="${GUINCORN_TIMEOUT:-120}"
|
||||
GUNICORN_TIMEOUT="${GUNICORN_TIMEOUT:-120}"
|
||||
GUNICORN_WORKERS="${GUNICORN_WORKERS:-4}"
|
||||
GUNICORN_LOGLEVEL="${GUNICORN_LOGLEVEL:-info}"
|
||||
BIND_ADDRESS="${BIND_ADDRESS:-0.0.0.0:80}"
|
||||
|
|
123
docs/API.md
123
docs/API.md
|
@ -1,105 +1,134 @@
|
|||
### API Usage
|
||||
|
||||
#### Getting started with docker
|
||||
|
||||
1. Run docker image docker-compose up, go to UI http://localhost:9191, at http://localhost:9191/swagger is swagger API specification
|
||||
2. Click to register user, type e.g. user: admin and password: admin
|
||||
3. Login to UI in settings enable allow domain creation for users, now you can create and manage domains with admin account and also ordinary users
|
||||
4. Encode your user and password to base64, in our example we have user admin and password admin so in linux cmd line we type:
|
||||
4. Click on the API Keys menu then click on teh "Add Key" button to add a new Administrator Key
|
||||
5. Keep the base64 encoded apikey somewhere safe as it won't be available in clear anymore
|
||||
|
||||
```
|
||||
|
||||
#### Accessing the API
|
||||
|
||||
The PDA API consists of two distinct parts:
|
||||
|
||||
- The /powerdnsadmin endpoints manages PDA content (accounts, users, apikeys) and also allow domain creation/deletion
|
||||
- The /server endpoints are proxying queries to the backend PowerDNS instance's API. PDA acts as a proxy managing several API Keys and permissions to the PowerDNS content.
|
||||
|
||||
The requests to the API needs two headers:
|
||||
|
||||
- The classic 'Content-Type: application/json' is required to all POST and PUT requests, though it's 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>
|
||||
```
|
||||
|
||||
we use generated output in basic authentication, we authenticate as user,
|
||||
with basic authentication, we can create/delete/get zone and create/delete/get/update apikeys
|
||||
|
||||
creating domain:
|
||||
When you access the `/server` endpoint, you must use the ApiKey
|
||||
|
||||
```bash
|
||||
# Use the already base64 encoded key in your header
|
||||
curl -H 'X-API-Key: YUdDdGhQM0tMQWV5alpJ' -X <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 apikey which has Administrator role, apikey can have also User role, when creating such apikey you have to specify also domain for which apikey is valid:
|
||||
Creating an apikey which has the Administrator role:
|
||||
|
||||
```
|
||||
```bash
|
||||
# Create the key
|
||||
curl -L -vvv -H 'Content-Type: application/json' -H 'Authorization: Basic YWRtaW46YWRtaW4=' -X POST http://localhost:9191/api/v1/pdnsadmin/apikeys --data '{"description": "masterkey","domains":[], "role": "Administrator"}'
|
||||
```
|
||||
Example response (don't forget to save the plain key from the output)
|
||||
|
||||
call above will return response like this:
|
||||
|
||||
```
|
||||
[{"description": "samekey", "domains": [], "role": {"name": "Administrator", "id": 1}, "id": 2, "plain_key": "aGCthP3KLAeyjZI"}]
|
||||
```json
|
||||
[
|
||||
{
|
||||
"accounts": [],
|
||||
"description": "masterkey",
|
||||
"domains": [],
|
||||
"role": {
|
||||
"name": "Administrator",
|
||||
"id": 1
|
||||
},
|
||||
"id": 2,
|
||||
"plain_key": "aGCthP3KLAeyjZI"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
we take plain_key and base64 encode it, this is the only time we can get API key in plain text and save it somewhere:
|
||||
We can use the apikey for all calls to PowerDNS (don't forget to specify Content-Type):
|
||||
|
||||
```
|
||||
$ echo -n 'aGCthP3KLAeyjZI'|base64
|
||||
YUdDdGhQM0tMQWV5alpJ
|
||||
```
|
||||
Getting powerdns configuration (Administrator Key is needed):
|
||||
|
||||
We can use apikey for all calls specified in our API specification (it tries to follow powerdns API 1:1, only tsigkeys endpoints are not yet implemented), don't forget to specify Content-Type!
|
||||
|
||||
getting powerdns configuration:
|
||||
|
||||
```
|
||||
```bash
|
||||
curl -L -vvv -H 'Content-Type: application/json' -H 'X-API-KEY: YUdDdGhQM0tMQWV5alpJ' -X GET http://localhost:9191/api/v1/servers/localhost/config
|
||||
```
|
||||
|
||||
creating and updating records:
|
||||
Creating and updating records:
|
||||
|
||||
```
|
||||
```bash
|
||||
curl -X PATCH -H 'Content-Type: application/json' --data '{"rrsets": [{"name": "test1.yourdomain.com.","type": "A","ttl": 86400,"changetype": "REPLACE","records": [ {"content": "192.0.2.5", "disabled": false} ]},{"name": "test2.yourdomain.com.","type": "AAAA","ttl": 86400,"changetype": "REPLACE","records": [ {"content": "2001:db8::6", "disabled": false} ]}]}' -H 'X-API-Key: YUdDdGhQM0tMQWV5alpJ' http://127.0.0.1:9191/api/v1/servers/localhost/zones/yourdomain.com.
|
||||
```
|
||||
|
||||
getting domain:
|
||||
Getting a domain:
|
||||
|
||||
```
|
||||
```bash
|
||||
curl -L -vvv -H 'Content-Type: application/json' -H 'X-API-KEY: YUdDdGhQM0tMQWV5alpJ' -X GET http://localhost:9191/api/v1/servers/localhost/zones/yourdomain.com
|
||||
```
|
||||
|
||||
list zone records:
|
||||
List a zone's records:
|
||||
|
||||
```
|
||||
```bash
|
||||
curl -H 'Content-Type: application/json' -H 'X-API-Key: YUdDdGhQM0tMQWV5alpJ' http://localhost:9191/api/v1/servers/localhost/zones/yourdomain.com
|
||||
```
|
||||
|
||||
add new record:
|
||||
Add a new record:
|
||||
|
||||
```
|
||||
```bash
|
||||
curl -H 'Content-Type: application/json' -X PATCH --data '{"rrsets": [ {"name": "test.yourdomain.com.", "type": "A", "ttl": 86400, "changetype": "REPLACE", "records": [ {"content": "192.0.5.4", "disabled": false } ] } ] }' -H 'X-API-Key: YUdDdGhQM0tMQWV5alpJ' http://localhost:9191/api/v1/servers/localhost/zones/yourdomain.com | jq .
|
||||
```
|
||||
|
||||
update record:
|
||||
Update a record:
|
||||
|
||||
```
|
||||
```bash
|
||||
curl -H 'Content-Type: application/json' -X PATCH --data '{"rrsets": [ {"name": "test.yourdomain.com.", "type": "A", "ttl": 86400, "changetype": "REPLACE", "records": [ {"content": "192.0.2.5", "disabled": false, "name": "test.yourdomain.com.", "ttl": 86400, "type": "A"}]}]}' -H 'X-API-Key: YUdDdGhQM0tMQWV5alpJ' http://localhost:9191/api/v1/servers/localhost/zones/yourdomain.com | jq .
|
||||
```
|
||||
|
||||
delete record:
|
||||
Delete a record:
|
||||
|
||||
```
|
||||
```bash
|
||||
curl -H 'Content-Type: application/json' -X PATCH --data '{"rrsets": [ {"name": "test.yourdomain.com.", "type": "A", "ttl": 86400, "changetype": "DELETE"}]}' -H 'X-API-Key: YUdDdGhQM0tMQWV5alpJ' http://localhost:9191/api/v1/servers/localhost/zones/yourdomain.com | jq
|
||||
```
|
||||
|
||||
### Generate ER diagram
|
||||
|
||||
```
|
||||
With docker
|
||||
|
||||
```bash
|
||||
# Install build packages
|
||||
apt-get install python-dev graphviz libgraphviz-dev pkg-config
|
||||
```
|
||||
|
||||
```
|
||||
# Get the required python libraries
|
||||
pip install graphviz mysqlclient ERAlchemy
|
||||
```
|
||||
|
||||
```
|
||||
# Start the docker container
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
```
|
||||
# Set environment variables
|
||||
source .env
|
||||
```
|
||||
|
||||
```
|
||||
# Generate the diagrams
|
||||
eralchemy -i 'mysql://${PDA_DB_USER}:${PDA_DB_PASSWORD}@'$(docker inspect powerdns-admin-mysql|jq -jr '.[0].NetworkSettings.Networks.powerdnsadmin_default.IPAddress')':3306/powerdns_admin' -o /tmp/output.pdf
|
||||
```
|
||||
|
|
|
@ -17,4 +17,83 @@ Now you can enable the OAuth in PowerDNS-Admin.
|
|||
* Replace the [tenantID] in the default URLs for authorize and token with your Tenant ID.
|
||||
* Restart PowerDNS-Admin
|
||||
|
||||
This should allow you to log in using OAuth.
|
||||
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.
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
"""add apikey account mapping table
|
||||
|
||||
Revision ID: 0967658d9c0d
|
||||
Revises: 0d3d93f1c2e0
|
||||
Create Date: 2021-11-13 22:28:46.133474
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '0967658d9c0d'
|
||||
down_revision = '0d3d93f1c2e0'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('apikey_account',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('apikey_id', sa.Integer(), nullable=False),
|
||||
sa.Column('account_id', sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['account_id'], ['account.id'], ),
|
||||
sa.ForeignKeyConstraint(['apikey_id'], ['apikey.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
with op.batch_alter_table('history', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_history_created_on'), ['created_on'], unique=False)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('history', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_history_created_on'))
|
||||
|
||||
op.drop_table('apikey_account')
|
||||
# ### end Alembic commands ###
|
|
@ -0,0 +1,34 @@
|
|||
"""Add domain_id to history table
|
||||
|
||||
Revision ID: 0d3d93f1c2e0
|
||||
Revises: 3f76448bb6de
|
||||
Create Date: 2021-02-15 17:23:05.688241
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '0d3d93f1c2e0'
|
||||
down_revision = '3f76448bb6de'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('history', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('domain_id', sa.Integer(), nullable=True))
|
||||
batch_op.create_foreign_key('fk_domain_id', 'domain', ['domain_id'], ['id'])
|
||||
|
||||
# ### 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_constraint('fk_domain_id', type_='foreignkey')
|
||||
batch_op.drop_column('domain_id')
|
||||
|
||||
# ### end Alembic commands ###
|
|
@ -2,6 +2,7 @@
|
|||
"dependencies": {
|
||||
"admin-lte": "2.4.9",
|
||||
"bootstrap": "^3.4.1",
|
||||
"bootstrap-datepicker": "^1.8.0",
|
||||
"bootstrap-validator": "^0.11.9",
|
||||
"datatables.net-plugins": "^1.10.19",
|
||||
"icheck": "^1.0.2",
|
||||
|
|
|
@ -4,6 +4,7 @@ from flask import Flask
|
|||
from flask_seasurf import SeaSurf
|
||||
from flask_mail import Mail
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
from flask_session import Session
|
||||
|
||||
from .lib import utils
|
||||
|
||||
|
@ -54,6 +55,8 @@ def create_app(config=None):
|
|||
csrf.exempt(routes.api.api_list_account_users)
|
||||
csrf.exempt(routes.api.api_add_account_user)
|
||||
csrf.exempt(routes.api.api_remove_account_user)
|
||||
csrf.exempt(routes.api.api_zone_cryptokeys)
|
||||
csrf.exempt(routes.api.api_zone_cryptokey)
|
||||
|
||||
# Load config from env variables if using docker
|
||||
if os.path.exists(os.path.join(app.root_path, 'docker_config.py')):
|
||||
|
@ -78,6 +81,12 @@ def create_app(config=None):
|
|||
from flask_sslify import SSLify
|
||||
_sslify = SSLify(app) # lgtm [py/unused-local-variable]
|
||||
|
||||
# Load Flask-Session
|
||||
if app.config.get('FILESYSTEM_SESSIONS_ENABLED'):
|
||||
app.config['SESSION_TYPE'] = 'filesystem'
|
||||
sess = Session()
|
||||
sess.init_app(app)
|
||||
|
||||
# SMTP
|
||||
app.mail = Mail(app)
|
||||
|
||||
|
@ -95,6 +104,7 @@ def create_app(config=None):
|
|||
'email_to_gravatar_url'] = utils.email_to_gravatar_url
|
||||
app.jinja_env.filters[
|
||||
'display_setting_state'] = utils.display_setting_state
|
||||
app.jinja_env.filters['pretty_domain_name'] = utils.pretty_domain_name
|
||||
|
||||
# Register context proccessors
|
||||
from .models.setting import Setting
|
||||
|
|
|
@ -23,6 +23,7 @@ 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')
|
||||
|
||||
|
@ -39,6 +40,7 @@ css_main = Bundle(
|
|||
'node_modules/admin-lte/dist/css/AdminLTE.css',
|
||||
'node_modules/admin-lte/dist/css/skins/_all-skins.css',
|
||||
'custom/css/custom.css',
|
||||
'node_modules/bootstrap-datepicker/dist/css/bootstrap-datepicker.css',
|
||||
filters=('cssmin', 'cssrewrite'),
|
||||
output='generated/main.css')
|
||||
|
||||
|
@ -58,6 +60,7 @@ js_main = Bundle('node_modules/jquery/dist/jquery.js',
|
|||
'node_modules/jtimeout/src/jTimeout.js',
|
||||
'node_modules/jquery.quicksearch/src/jquery.quicksearch.js',
|
||||
'custom/js/custom.js',
|
||||
'node_modules/bootstrap-datepicker/dist/js/bootstrap-datepicker.js',
|
||||
filters=(ConcatFilter, 'jsmin'),
|
||||
output='generated/main.js')
|
||||
|
||||
|
|
|
@ -8,7 +8,6 @@ from .models import User, ApiKey, Setting, Domain, Setting
|
|||
from .lib.errors import RequestIsNotJSON, NotEnoughPrivileges
|
||||
from .lib.errors import DomainAccessForbidden
|
||||
|
||||
|
||||
def admin_role_required(f):
|
||||
"""
|
||||
Grant access if user is in Administrator role
|
||||
|
@ -35,6 +34,21 @@ def operator_role_required(f):
|
|||
return decorated_function
|
||||
|
||||
|
||||
def history_access_required(f):
|
||||
"""
|
||||
Grant access if user is in Operator role or higher, or Users can view history
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if current_user.role.name not in [
|
||||
'Administrator', 'Operator'
|
||||
] and not Setting().get('allow_user_view_history'):
|
||||
abort(403)
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def can_access_domain(f):
|
||||
"""
|
||||
Grant access if:
|
||||
|
@ -79,6 +93,23 @@ 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):
|
||||
"""
|
||||
|
@ -161,6 +192,24 @@ def is_json(f):
|
|||
return decorated_function
|
||||
|
||||
|
||||
def callback_if_request_body_contains_key(callback, http_methods=[], keys=[]):
|
||||
"""
|
||||
If request body contains one or more of specified keys, call
|
||||
:param callback
|
||||
"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
check_current_http_method = not http_methods or request.method in http_methods
|
||||
if (check_current_http_method and
|
||||
set(request.get_json(force=True).keys()).intersection(set(keys))
|
||||
):
|
||||
callback(*args, **kwargs)
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
return decorator
|
||||
|
||||
|
||||
def api_role_can(action, roles=None, allow_self=False):
|
||||
"""
|
||||
Grant access if:
|
||||
|
@ -215,6 +264,48 @@ def api_can_create_domain(f):
|
|||
return decorated_function
|
||||
|
||||
|
||||
def apikey_can_create_domain(f):
|
||||
"""
|
||||
Grant access if:
|
||||
- user is in Operator role or higher, or
|
||||
- allow_user_create_domain is on
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if g.apikey.role.name not in [
|
||||
'Administrator', 'Operator'
|
||||
] and not Setting().get('allow_user_create_domain'):
|
||||
msg = "ApiKey #{0} does not have enough privileges to create domain"
|
||||
current_app.logger.error(msg.format(g.apikey.id))
|
||||
raise NotEnoughPrivileges()
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def apikey_can_remove_domain(http_methods=[]):
|
||||
"""
|
||||
Grant access if:
|
||||
- user is in Operator role or higher, or
|
||||
- allow_user_remove_domain is on
|
||||
"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
check_current_http_method = not http_methods or request.method in http_methods
|
||||
|
||||
if (check_current_http_method and
|
||||
g.apikey.role.name not in ['Administrator', 'Operator'] and
|
||||
not Setting().get('allow_user_remove_domain')
|
||||
):
|
||||
msg = "ApiKey #{0} does not have enough privileges to remove domain"
|
||||
current_app.logger.error(msg.format(g.apikey.id))
|
||||
raise NotEnoughPrivileges()
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
return decorator
|
||||
|
||||
|
||||
def apikey_is_admin(f):
|
||||
"""
|
||||
Grant access if user is in Administrator role
|
||||
|
@ -231,21 +322,52 @@ def apikey_is_admin(f):
|
|||
|
||||
|
||||
def apikey_can_access_domain(f):
|
||||
"""
|
||||
Grant access if:
|
||||
- user has Operator role or higher, or
|
||||
- user has explicitly been granted access to domain
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
apikey = g.apikey
|
||||
if g.apikey.role.name not in ['Administrator', 'Operator']:
|
||||
domains = apikey.domains
|
||||
zone_id = kwargs.get('zone_id')
|
||||
domain_names = [item.name for item in domains]
|
||||
zone_id = kwargs.get('zone_id').rstrip(".")
|
||||
domain_names = [item.name for item in g.apikey.domains]
|
||||
|
||||
if zone_id not in domain_names:
|
||||
accounts = g.apikey.accounts
|
||||
accounts_domains = [domain.name for a in accounts for domain in a.domains]
|
||||
|
||||
allowed_domains = set(domain_names + accounts_domains)
|
||||
|
||||
if zone_id not in allowed_domains:
|
||||
raise DomainAccessForbidden()
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def apikey_can_configure_dnssec(http_methods=[]):
|
||||
"""
|
||||
Grant access if:
|
||||
- user is in Operator role or higher, or
|
||||
- dnssec_admins_only is off
|
||||
"""
|
||||
def decorator(f=None):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
check_current_http_method = not http_methods or request.method in http_methods
|
||||
|
||||
if (check_current_http_method and
|
||||
g.apikey.role.name not in ['Administrator', 'Operator'] and
|
||||
Setting().get('dnssec_admins_only')
|
||||
):
|
||||
msg = "ApiKey #{0} does not have enough privileges to configure dnssec"
|
||||
current_app.logger.error(msg.format(g.apikey.id))
|
||||
raise DomainAccessForbidden(message=msg)
|
||||
return f(*args, **kwargs) if f else None
|
||||
return decorated_function
|
||||
return decorator
|
||||
|
||||
|
||||
def apikey_auth(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
|
@ -291,3 +413,13 @@ def dyndns_login_required(f):
|
|||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
def apikey_or_basic_auth(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
api_auth_header = request.headers.get('X-API-KEY')
|
||||
if api_auth_header:
|
||||
return apikey_auth(f)(*args, **kwargs)
|
||||
else:
|
||||
return api_basic_auth(f)(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import os
|
||||
basedir = os.path.abspath(os.path.abspath(os.path.dirname(__file__)))
|
||||
import urllib.parse
|
||||
basedir = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
### BASIC APP CONFIG
|
||||
SALT = '$2b$12$yLUMTIfl21FKJQpTkRQXCu'
|
||||
|
@ -8,6 +9,7 @@ BIND_ADDRESS = '0.0.0.0'
|
|||
PORT = 9191
|
||||
HSTS_ENABLED = False
|
||||
OFFLINE_MODE = False
|
||||
FILESYSTEM_SESSIONS_ENABLED = False
|
||||
|
||||
### DATABASE CONFIG
|
||||
SQLA_DB_USER = 'pda'
|
||||
|
@ -17,7 +19,12 @@ SQLA_DB_NAME = 'pda'
|
|||
SQLALCHEMY_TRACK_MODIFICATIONS = True
|
||||
|
||||
### DATABASE - MySQL
|
||||
SQLALCHEMY_DATABASE_URI = 'mysql://'+SQLA_DB_USER+':'+SQLA_DB_PASSWORD+'@'+SQLA_DB_HOST+'/'+SQLA_DB_NAME
|
||||
SQLALCHEMY_DATABASE_URI = 'mysql://{}:{}@{}/{}'.format(
|
||||
urllib.parse.quote_plus(SQLA_DB_USER),
|
||||
urllib.parse.quote_plus(SQLA_DB_PASSWORD),
|
||||
SQLA_DB_HOST,
|
||||
SQLA_DB_NAME
|
||||
)
|
||||
|
||||
### DATABASE - SQLite
|
||||
# SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'pdns.db')
|
||||
|
|
|
@ -60,7 +60,8 @@ class ApiKeyNotUsable(StructuredException):
|
|||
def __init__(
|
||||
self,
|
||||
name=None,
|
||||
message="Api key must have domains or have administrative role"):
|
||||
message=("Api key must have domains or accounts"
|
||||
" or an administrative role")):
|
||||
StructuredException.__init__(self)
|
||||
self.message = message
|
||||
self.name = name
|
||||
|
@ -93,6 +94,15 @@ class AccountCreateFail(StructuredException):
|
|||
self.name = name
|
||||
|
||||
|
||||
class AccountCreateDuplicate(StructuredException):
|
||||
status_code = 409
|
||||
|
||||
def __init__(self, name=None, message="Creation of account failed"):
|
||||
StructuredException.__init__(self)
|
||||
self.message = message
|
||||
self.name = name
|
||||
|
||||
|
||||
class AccountUpdateFail(StructuredException):
|
||||
status_code = 500
|
||||
|
||||
|
@ -111,6 +121,15 @@ class AccountDeleteFail(StructuredException):
|
|||
self.name = name
|
||||
|
||||
|
||||
class AccountNotExists(StructuredException):
|
||||
status_code = 404
|
||||
|
||||
def __init__(self, name=None, message="Account does not exist"):
|
||||
StructuredException.__init__(self)
|
||||
self.message = message
|
||||
self.name = name
|
||||
|
||||
|
||||
class UserCreateFail(StructuredException):
|
||||
status_code = 500
|
||||
|
||||
|
@ -120,6 +139,14 @@ class UserCreateFail(StructuredException):
|
|||
self.name = name
|
||||
|
||||
|
||||
class UserCreateDuplicate(StructuredException):
|
||||
status_code = 409
|
||||
|
||||
def __init__(self, name=None, message="Creation of user failed"):
|
||||
StructuredException.__init__(self)
|
||||
self.message = message
|
||||
self.name = name
|
||||
|
||||
class UserUpdateFail(StructuredException):
|
||||
status_code = 500
|
||||
|
||||
|
@ -128,6 +155,14 @@ class UserUpdateFail(StructuredException):
|
|||
self.message = message
|
||||
self.name = name
|
||||
|
||||
class UserUpdateFailEmail(StructuredException):
|
||||
status_code = 409
|
||||
|
||||
def __init__(self, name=None, message="Update of user failed"):
|
||||
StructuredException.__init__(self)
|
||||
self.message = message
|
||||
self.name = name
|
||||
|
||||
|
||||
class UserDeleteFail(StructuredException):
|
||||
status_code = 500
|
||||
|
|
|
@ -11,10 +11,21 @@ class RoleSchema(Schema):
|
|||
name = fields.String()
|
||||
|
||||
|
||||
class AccountSummarySchema(Schema):
|
||||
id = fields.Integer()
|
||||
name = fields.String()
|
||||
domains = fields.Embed(schema=DomainSchema, many=True)
|
||||
|
||||
class ApiKeySummarySchema(Schema):
|
||||
id = fields.Integer()
|
||||
description = fields.String()
|
||||
|
||||
|
||||
class ApiKeySchema(Schema):
|
||||
id = fields.Integer()
|
||||
role = fields.Embed(schema=RoleSchema)
|
||||
domains = fields.Embed(schema=DomainSchema, many=True)
|
||||
accounts = fields.Embed(schema=AccountSummarySchema, many=True)
|
||||
description = fields.String()
|
||||
key = fields.String()
|
||||
|
||||
|
@ -23,6 +34,7 @@ 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()
|
||||
|
||||
|
@ -35,6 +47,14 @@ class UserSchema(Schema):
|
|||
email = fields.String()
|
||||
role = fields.Embed(schema=RoleSchema)
|
||||
|
||||
class UserDetailedSchema(Schema):
|
||||
id = fields.Integer()
|
||||
username = fields.String()
|
||||
firstname = fields.String()
|
||||
lastname = fields.String()
|
||||
email = fields.String()
|
||||
role = fields.Embed(schema=RoleSchema)
|
||||
accounts = fields.Embed(schema=AccountSummarySchema, many=True)
|
||||
|
||||
class AccountSchema(Schema):
|
||||
id = fields.Integer()
|
||||
|
@ -43,3 +63,4 @@ class AccountSchema(Schema):
|
|||
contact = fields.String()
|
||||
mail = fields.String()
|
||||
domains = fields.Embed(schema=DomainSchema, many=True)
|
||||
apikeys = fields.Embed(schema=ApiKeySummarySchema, many=True)
|
||||
|
|
|
@ -8,6 +8,7 @@ import ipaddress
|
|||
from collections.abc import Iterable
|
||||
from distutils.version import StrictVersion
|
||||
from urllib.parse import urlparse
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
def auth_from_url(url):
|
||||
|
@ -103,6 +104,13 @@ 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
|
||||
|
@ -228,3 +236,22 @@ class customBoxes:
|
|||
"inaddrarpa": ("in-addr", "%.in-addr.arpa")
|
||||
}
|
||||
order = ["reverse", "ip6arpa", "inaddrarpa"]
|
||||
|
||||
def pretty_domain_name(value):
|
||||
"""
|
||||
Display domain name in original format.
|
||||
If it is IDN domain (Punycode starts with xn--), do the
|
||||
idna decoding.
|
||||
Note that any part of the domain name can be individually punycoded
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
if value.startswith('xn--') \
|
||||
or value.find('.xn--') != -1:
|
||||
try:
|
||||
return value.encode().decode('idna')
|
||||
except:
|
||||
raise Exception("Cannot decode IDN domain")
|
||||
else:
|
||||
return value
|
||||
else:
|
||||
raise Exception("Require the Punycode in string format")
|
||||
|
|
|
@ -8,6 +8,7 @@ from .account_user import AccountUser
|
|||
from .server import Server
|
||||
from .history import History
|
||||
from .api_key import ApiKey
|
||||
from .api_key_account import ApiKeyAccount
|
||||
from .setting import Setting
|
||||
from .domain import Domain
|
||||
from .domain_setting import DomainSetting
|
||||
|
|
|
@ -17,6 +17,9 @@ class Account(db.Model):
|
|||
contact = db.Column(db.String(128))
|
||||
mail = db.Column(db.String(128))
|
||||
domains = db.relationship("Domain", back_populates="account")
|
||||
apikeys = db.relationship("ApiKey",
|
||||
secondary="apikey_account",
|
||||
back_populates="accounts")
|
||||
|
||||
def __init__(self, name=None, description=None, contact=None, mail=None):
|
||||
self.name = name
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import random
|
||||
import secrets
|
||||
import string
|
||||
import bcrypt
|
||||
from flask import current_app
|
||||
|
||||
from .base import db, domain_apikey
|
||||
from .base import db
|
||||
from ..models.role import Role
|
||||
from ..models.domain import Domain
|
||||
|
||||
from ..models.account import Account
|
||||
|
||||
class ApiKey(db.Model):
|
||||
__tablename__ = "apikey"
|
||||
|
@ -16,17 +16,21 @@ class ApiKey(db.Model):
|
|||
role_id = db.Column(db.Integer, db.ForeignKey('role.id'))
|
||||
role = db.relationship('Role', back_populates="apikeys", lazy=True)
|
||||
domains = db.relationship("Domain",
|
||||
secondary=domain_apikey,
|
||||
secondary="domain_apikey",
|
||||
back_populates="apikeys")
|
||||
accounts = db.relationship("Account",
|
||||
secondary="apikey_account",
|
||||
back_populates="apikeys")
|
||||
|
||||
def __init__(self, key=None, desc=None, role_name=None, domains=[]):
|
||||
def __init__(self, key=None, desc=None, role_name=None, domains=[], accounts=[]):
|
||||
self.id = None
|
||||
self.description = desc
|
||||
self.role_name = role_name
|
||||
self.domains[:] = domains
|
||||
self.accounts[:] = accounts
|
||||
if not key:
|
||||
rand_key = ''.join(
|
||||
random.choice(string.ascii_letters + string.digits)
|
||||
secrets.choice(string.ascii_letters + string.digits)
|
||||
for _ in range(15))
|
||||
self.plain_key = rand_key
|
||||
self.key = self.get_hashed_password(rand_key).decode('utf-8')
|
||||
|
@ -54,7 +58,7 @@ class ApiKey(db.Model):
|
|||
db.session.rollback()
|
||||
raise e
|
||||
|
||||
def update(self, role_name=None, description=None, domains=None):
|
||||
def update(self, role_name=None, description=None, domains=None, accounts=None):
|
||||
try:
|
||||
if role_name:
|
||||
role = Role.query.filter(Role.name == role_name).first()
|
||||
|
@ -63,12 +67,18 @@ class ApiKey(db.Model):
|
|||
if description:
|
||||
self.description = description
|
||||
|
||||
if domains:
|
||||
if domains is not None:
|
||||
domain_object_list = Domain.query \
|
||||
.filter(Domain.name.in_(domains)) \
|
||||
.all()
|
||||
self.domains[:] = domain_object_list
|
||||
|
||||
if accounts is not None:
|
||||
account_object_list = Account.query \
|
||||
.filter(Account.name.in_(accounts)) \
|
||||
.all()
|
||||
self.accounts[:] = account_object_list
|
||||
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
msg_str = 'Update of apikey failed. Error: {0}'
|
||||
|
@ -87,6 +97,15 @@ class ApiKey(db.Model):
|
|||
else:
|
||||
pw = self.plain_text_password
|
||||
|
||||
# The salt value is currently re-used here intentionally because
|
||||
# the implementation relies on just the API key's value itself
|
||||
# for database lookup: ApiKey.is_validate() would have no way of
|
||||
# discerning whether any given key is valid if bcrypt.gensalt()
|
||||
# was used. As far as is known, this is fine as long as the
|
||||
# value of new API keys is randomly generated in a
|
||||
# cryptographically secure fashion, as this then makes
|
||||
# expendable as an exception the otherwise vital protection of
|
||||
# proper salting as provided by bcrypt.gensalt().
|
||||
return bcrypt.hashpw(pw.encode('utf-8'),
|
||||
current_app.config.get('SALT').encode('utf-8'))
|
||||
|
||||
|
@ -112,3 +131,12 @@ class ApiKey(db.Model):
|
|||
raise Exception("Unauthorized")
|
||||
|
||||
return apikey
|
||||
|
||||
def associate_account(self, account):
|
||||
return True
|
||||
|
||||
def dissociate_account(self, account):
|
||||
return True
|
||||
|
||||
def get_accounts(self):
|
||||
return True
|
||||
|
|
20
powerdnsadmin/models/api_key_account.py
Normal file
20
powerdnsadmin/models/api_key_account.py
Normal file
|
@ -0,0 +1,20 @@
|
|||
from .base import db
|
||||
|
||||
|
||||
class ApiKeyAccount(db.Model):
|
||||
__tablename__ = 'apikey_account'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
apikey_id = db.Column(db.Integer,
|
||||
db.ForeignKey('apikey.id'),
|
||||
nullable=False)
|
||||
account_id = db.Column(db.Integer,
|
||||
db.ForeignKey('account.id'),
|
||||
nullable=False)
|
||||
db.UniqueConstraint('apikey_id', 'account_id', name='uniq_apikey_account')
|
||||
|
||||
def __init__(self, apikey_id, account_id):
|
||||
self.apikey_id = apikey_id
|
||||
self.account_id = account_id
|
||||
|
||||
def __repr__(self):
|
||||
return '<ApiKey_Account {0} {1}>'.format(self.apikey_id, self.account_id)
|
|
@ -519,6 +519,13 @@ class Domain(db.Model):
|
|||
domain_setting.delete()
|
||||
domain.apikeys[:] = []
|
||||
|
||||
# Remove history for domain
|
||||
domain_history = History.query.filter(
|
||||
History.domain_id == domain.id
|
||||
)
|
||||
if domain_history:
|
||||
domain_history.delete()
|
||||
|
||||
# then remove domain
|
||||
Domain.query.filter(Domain.name == domain_name).delete()
|
||||
if do_commit:
|
||||
|
@ -575,6 +582,33 @@ class Domain(db.Model):
|
|||
format(self.name, e))
|
||||
current_app.logger.debug(print(traceback.format_exc()))
|
||||
|
||||
def revoke_privileges_by_id(self, user_id):
|
||||
"""
|
||||
Remove a single user from privilege list based on user_id
|
||||
"""
|
||||
new_uids = [u for u in self.get_user() if u != user_id]
|
||||
users = []
|
||||
for uid in new_uids:
|
||||
users.append(User(id=uid).get_user_info_by_id().username)
|
||||
|
||||
self.grant_privileges(users)
|
||||
|
||||
def add_user(self, user):
|
||||
"""
|
||||
Add a single user to Domain by User
|
||||
"""
|
||||
try:
|
||||
du = DomainUser(self.id, user.id)
|
||||
db.session.add(du)
|
||||
db.session.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(
|
||||
'Cannot add user privileges on domain {0}. DETAIL: {1}'.
|
||||
format(self.name, e))
|
||||
return False
|
||||
|
||||
def update_from_master(self, domain_name):
|
||||
"""
|
||||
Update records from Master DNS server
|
||||
|
|
|
@ -8,17 +8,22 @@ 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())
|
||||
created_by = db.Column(db.String(128))
|
||||
created_on = db.Column(db.DateTime, index=True, default=datetime.utcnow)
|
||||
domain_id = db.Column(db.Integer,
|
||||
db.ForeignKey('domain.id'),
|
||||
nullable=True)
|
||||
|
||||
def __init__(self, id=None, msg=None, detail=None, created_by=None):
|
||||
def __init__(self, id=None, msg=None, detail=None, created_by=None, domain_id=None):
|
||||
self.id = id
|
||||
self.msg = msg
|
||||
self.detail = detail
|
||||
self.created_by = created_by
|
||||
self.domain_id = domain_id
|
||||
|
||||
def __repr__(self):
|
||||
return '<History {0}>'.format(self.msg)
|
||||
|
@ -31,6 +36,7 @@ class History(db.Model):
|
|||
h.msg = self.msg
|
||||
h.detail = self.detail
|
||||
h.created_by = self.created_by
|
||||
h.domain_id = self.domain_id
|
||||
db.session.add(h)
|
||||
db.session.commit()
|
||||
|
||||
|
|
|
@ -65,6 +65,9 @@ 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)))
|
||||
|
@ -162,6 +165,17 @@ 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()
|
||||
#TODO: error handling
|
||||
# If the record is an alias (CNAME), we will also make sure that
|
||||
# the target domain is properly converted to punycode (IDN)
|
||||
if record["record_type"] == 'CNAME':
|
||||
record['record_data'] = record['record_data'].encode('idna').decode()
|
||||
#TODO: error handling
|
||||
# If it is ipv6 reverse zone and PRETTY_IPV6_PTR is enabled,
|
||||
# We convert ipv6 address back to reverse record format
|
||||
# before submitting to PDNS API.
|
||||
|
@ -302,13 +316,26 @@ class Record(object):
|
|||
new_rrsets, del_rrsets = self.compare(domain_name, submitted_records)
|
||||
|
||||
# Remove blank comments from rrsets for compatibility with some backends
|
||||
def remove_blank_comments(rrset):
|
||||
if not rrset['comments']:
|
||||
del rrset['comments']
|
||||
elif isinstance(rrset['comments'], list):
|
||||
# Merge all non-blank comment values into a list
|
||||
merged_comments = [
|
||||
v
|
||||
for c in rrset['comments']
|
||||
for v in c.values()
|
||||
if v
|
||||
]
|
||||
# Delete comment if all values are blank (len(merged_comments) == 0)
|
||||
if not merged_comments:
|
||||
del rrset['comments']
|
||||
|
||||
for r in new_rrsets['rrsets']:
|
||||
if not r['comments']:
|
||||
del r['comments']
|
||||
remove_blank_comments(r)
|
||||
|
||||
for r in del_rrsets['rrsets']:
|
||||
if not r['comments']:
|
||||
del r['comments']
|
||||
remove_blank_comments(r)
|
||||
|
||||
# Submit the changes to PDNS API
|
||||
try:
|
||||
|
|
|
@ -26,7 +26,11 @@ 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,
|
||||
|
@ -38,6 +42,10 @@ class Setting(db.Model):
|
|||
'verify_ssl_connections': True,
|
||||
'local_db_enabled': True,
|
||||
'signup_enabled': True,
|
||||
'autoprovisioning': False,
|
||||
'urn_value':'',
|
||||
'autoprovisioning_attribute': '',
|
||||
'purge': False,
|
||||
'verify_user_email': False,
|
||||
'ldap_enabled': False,
|
||||
'ldap_type': 'ldap',
|
||||
|
@ -179,6 +187,10 @@ 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):
|
||||
|
@ -259,16 +271,23 @@ class Setting(db.Model):
|
|||
|
||||
def get(self, setting):
|
||||
if setting in self.defaults:
|
||||
result = self.query.filter(Setting.name == setting).first()
|
||||
|
||||
if setting.upper() in current_app.config:
|
||||
result = current_app.config[setting.upper()]
|
||||
else:
|
||||
result = self.query.filter(Setting.name == setting).first()
|
||||
|
||||
if result is not None:
|
||||
return strtobool(result.value) if result.value in [
|
||||
if hasattr(result,'value'):
|
||||
result = result.value
|
||||
return strtobool(result) if result in [
|
||||
'True', 'False'
|
||||
] else result.value
|
||||
] else result
|
||||
else:
|
||||
return self.defaults[setting]
|
||||
else:
|
||||
current_app.logger.error('Unknown setting queried: {0}'.format(setting))
|
||||
|
||||
|
||||
def get_records_allow_to_edit(self):
|
||||
return list(
|
||||
set(self.get_forward_records_allow_to_edit() +
|
||||
|
|
|
@ -7,11 +7,16 @@ import ldap
|
|||
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
|
||||
from .setting import Setting
|
||||
from .domain_user import DomainUser
|
||||
from .account_user import AccountUser
|
||||
|
||||
|
||||
class Anonymous(AnonymousUserMixin):
|
||||
|
@ -29,6 +34,7 @@ class User(db.Model):
|
|||
otp_secret = db.Column(db.String(16))
|
||||
confirmed = db.Column(db.SmallInteger, nullable=False, default=0)
|
||||
role_id = db.Column(db.Integer, db.ForeignKey('role.id'))
|
||||
accounts = None
|
||||
|
||||
def __init__(self,
|
||||
id=None,
|
||||
|
@ -128,9 +134,8 @@ class User(db.Model):
|
|||
conn.protocol_version = ldap.VERSION3
|
||||
return conn
|
||||
|
||||
def ldap_search(self, searchFilter, baseDN):
|
||||
def ldap_search(self, searchFilter, baseDN, retrieveAttributes=None):
|
||||
searchScope = ldap.SCOPE_SUBTREE
|
||||
retrieveAttributes = None
|
||||
|
||||
try:
|
||||
conn = self.ldap_init_conn()
|
||||
|
@ -431,7 +436,8 @@ class User(db.Model):
|
|||
return {'status': False, 'msg': 'Email address is already in use'}
|
||||
|
||||
# first register user will be in Administrator role
|
||||
self.role_id = Role.query.filter_by(name='User').first().id
|
||||
if self.role_id is None:
|
||||
self.role_id = Role.query.filter_by(name='User').first().id
|
||||
if User.query.count() == 0:
|
||||
self.role_id = Role.query.filter_by(
|
||||
name='Administrator').first().id
|
||||
|
@ -484,7 +490,6 @@ class User(db.Model):
|
|||
"""
|
||||
Update user profile
|
||||
"""
|
||||
|
||||
user = User.query.filter(User.username == self.username).first()
|
||||
if not user:
|
||||
return False
|
||||
|
@ -540,9 +545,26 @@ class User(db.Model):
|
|||
Note: This doesn't include the permission granting from Account
|
||||
which user belong to
|
||||
"""
|
||||
|
||||
return self.get_domain_query().all()
|
||||
|
||||
def get_user_domains(self):
|
||||
from ..models.base import db
|
||||
from .account import Account
|
||||
from .domain import Domain
|
||||
from .account_user import AccountUser
|
||||
from .domain_user import DomainUser
|
||||
|
||||
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 == self.id,
|
||||
AccountUser.user_id == self.id
|
||||
)).all()
|
||||
return domains
|
||||
|
||||
def delete(self):
|
||||
"""
|
||||
Delete a user
|
||||
|
@ -560,7 +582,7 @@ class User(db.Model):
|
|||
self.username, e))
|
||||
return False
|
||||
|
||||
def revoke_privilege(self):
|
||||
def revoke_privilege(self, update_user=False):
|
||||
"""
|
||||
Revoke all privileges from a user
|
||||
"""
|
||||
|
@ -570,6 +592,8 @@ class User(db.Model):
|
|||
user_id = user.id
|
||||
try:
|
||||
DomainUser.query.filter(DomainUser.user_id == user_id).delete()
|
||||
if (update_user)==True:
|
||||
AccountUser.query.filter(AccountUser.user_id == user_id).delete()
|
||||
db.session.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
|
@ -590,6 +614,10 @@ class User(db.Model):
|
|||
else:
|
||||
return {'status': False, 'msg': 'Role does not exist'}
|
||||
|
||||
@orm.reconstructor
|
||||
def set_account(self):
|
||||
self.accounts = self.get_accounts()
|
||||
|
||||
def get_accounts(self):
|
||||
"""
|
||||
Get accounts associated with this user
|
||||
|
@ -601,9 +629,179 @@ class User(db.Model):
|
|||
.query(
|
||||
AccountUser,
|
||||
Account)\
|
||||
.filter(User.id == AccountUser.user_id)\
|
||||
.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):
|
||||
"""
|
||||
Get entitlements from ldap server associated with this user
|
||||
"""
|
||||
LDAP_BASE_DN = Setting().get('ldap_base_dn')
|
||||
LDAP_FILTER_USERNAME = Setting().get('ldap_filter_username')
|
||||
LDAP_FILTER_BASIC = Setting().get('ldap_filter_basic')
|
||||
searchFilter = "(&({0}={1}){2})".format(LDAP_FILTER_USERNAME,
|
||||
self.username,
|
||||
LDAP_FILTER_BASIC)
|
||||
current_app.logger.debug('Ldap searchFilter {0}'.format(searchFilter))
|
||||
ldap_result = self.ldap_search(searchFilter, LDAP_BASE_DN, [key])
|
||||
current_app.logger.debug('Ldap search result: {0}'.format(ldap_result))
|
||||
entitlements=[]
|
||||
if ldap_result:
|
||||
dict=ldap_result[0][0][1]
|
||||
if len(dict)!=0:
|
||||
for entitlement in dict[key]:
|
||||
entitlements.append(entitlement.decode("utf-8"))
|
||||
else:
|
||||
e="Not found value in the autoprovisioning attribute field "
|
||||
current_app.logger.warning("Cannot apply autoprovisioning on user: {}".format(e))
|
||||
return entitlements
|
||||
|
||||
def updateUser(self, Entitlements):
|
||||
"""
|
||||
Update user associations based on ldap attribute
|
||||
"""
|
||||
entitlements= getCorrectEntitlements(Entitlements)
|
||||
if len(entitlements)!=0:
|
||||
self.revoke_privilege(True)
|
||||
for entitlement in entitlements:
|
||||
arguments=entitlement.split(':')
|
||||
entArgs=arguments[arguments.index('powerdns-admin')+1:]
|
||||
role= entArgs[0]
|
||||
self.set_role(role)
|
||||
if (role=="User") and len(entArgs)>1:
|
||||
current_domains=getUserInfo(self.get_user_domains())
|
||||
current_accounts=getUserInfo(self.get_accounts())
|
||||
domain=entArgs[1]
|
||||
self.addMissingDomain(domain, current_domains)
|
||||
if len(entArgs)>2:
|
||||
account=entArgs[2]
|
||||
self.addMissingAccount(account, current_accounts)
|
||||
|
||||
def addMissingDomain(self, autoprovision_domain, current_domains):
|
||||
"""
|
||||
Add domain gathered by autoprovisioning to the current domains list of a user
|
||||
"""
|
||||
from ..models.domain import Domain
|
||||
user = db.session.query(User).filter(User.username == self.username).first()
|
||||
if autoprovision_domain not in current_domains:
|
||||
domain= db.session.query(Domain).filter(Domain.name == autoprovision_domain).first()
|
||||
if domain!=None:
|
||||
domain.add_user(user)
|
||||
|
||||
def addMissingAccount(self, autoprovision_account, current_accounts):
|
||||
"""
|
||||
Add account gathered by autoprovisioning to the current accounts list of a user
|
||||
"""
|
||||
from ..models.account import Account
|
||||
user = db.session.query(User).filter(User.username == self.username).first()
|
||||
if autoprovision_account not in current_accounts:
|
||||
account= db.session.query(Account).filter(Account.name == autoprovision_account).first()
|
||||
if account!=None:
|
||||
account.add_user(user)
|
||||
|
||||
def getCorrectEntitlements(Entitlements):
|
||||
"""
|
||||
Gather a list of valid records from the ldap attribute given
|
||||
"""
|
||||
from ..models.role import Role
|
||||
urn_value=Setting().get('urn_value')
|
||||
urnArgs=[x.lower() for x in urn_value.split(':')]
|
||||
entitlements=[]
|
||||
for Entitlement in Entitlements:
|
||||
arguments=Entitlement.split(':')
|
||||
|
||||
if ('powerdns-admin' in arguments):
|
||||
prefix=arguments[0:arguments.index('powerdns-admin')]
|
||||
prefix=[x.lower() for x in prefix]
|
||||
if (prefix!=urnArgs):
|
||||
e= "Typo in first part of urn value"
|
||||
current_app.logger.warning("Cannot apply autoprovisioning on user: {}".format(e))
|
||||
continue
|
||||
|
||||
else:
|
||||
e="Entry not a PowerDNS-Admin record"
|
||||
current_app.logger.warning("Cannot apply autoprovisioning on user: {}".format(e))
|
||||
continue
|
||||
|
||||
if len(arguments)<=len(urnArgs)+1: #prefix:powerdns-admin
|
||||
e="No value given after the prefix"
|
||||
current_app.logger.warning("Cannot apply autoprovisioning on user: {}".format(e))
|
||||
continue
|
||||
|
||||
entArgs=arguments[arguments.index('powerdns-admin')+1:]
|
||||
role=entArgs[0]
|
||||
roles= Role.query.all()
|
||||
role_names=get_role_names(roles)
|
||||
|
||||
if role not in role_names:
|
||||
e="Role given by entry not a role availabe in PowerDNS-Admin. Check for spelling errors"
|
||||
current_app.logger.warning("Cannot apply autoprovisioning on user: {}".format(e))
|
||||
continue
|
||||
|
||||
if len(entArgs)>1:
|
||||
if (role!="User"):
|
||||
e="Too many arguments for Admin or Operator"
|
||||
current_app.logger.warning("Cannot apply autoprovisioning on user: {}".format(e))
|
||||
continue
|
||||
else:
|
||||
if len(entArgs)<=3:
|
||||
if entArgs[1] and not checkIfDomainExists(entArgs[1]):
|
||||
continue
|
||||
if len(entArgs)==3:
|
||||
if entArgs[2] and not checkIfAccountExists(entArgs[2]):
|
||||
continue
|
||||
else:
|
||||
e="Too many arguments"
|
||||
current_app.logger.warning("Cannot apply autoprovisioning on user: {}".format(e))
|
||||
continue
|
||||
|
||||
entitlements.append(Entitlement)
|
||||
|
||||
return entitlements
|
||||
|
||||
|
||||
def checkIfDomainExists(domainName):
|
||||
from ..models.domain import Domain
|
||||
domain= db.session.query(Domain).filter(Domain.name == domainName)
|
||||
if len(domain.all())==0:
|
||||
e= domainName + " is not found in the database"
|
||||
current_app.logger.warning("Cannot apply autoprovisioning on user: {}".format(e))
|
||||
return False
|
||||
return True
|
||||
|
||||
def checkIfAccountExists(accountName):
|
||||
from ..models.account import Account
|
||||
account= db.session.query(Account).filter(Account.name == accountName)
|
||||
if len(account.all())==0:
|
||||
e= accountName + " is not found in the database"
|
||||
current_app.logger.warning("Cannot apply autoprovisioning on user: {}".format(e))
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_role_names(roles):
|
||||
"""
|
||||
returns all the roles available in database in string format
|
||||
"""
|
||||
roles_list=[]
|
||||
for role in roles:
|
||||
roles_list.append(role.name)
|
||||
return roles_list
|
||||
|
||||
def getUserInfo(DomainsOrAccounts):
|
||||
current=[]
|
||||
for DomainOrAccount in DomainsOrAccounts:
|
||||
current.append(DomainOrAccount.name)
|
||||
return current
|
|
@ -1,12 +1,13 @@
|
|||
import json
|
||||
import datetime
|
||||
import traceback
|
||||
import re
|
||||
from base64 import b64encode
|
||||
from ast import literal_eval
|
||||
from flask import Blueprint, render_template, make_response, url_for, current_app, request, redirect, jsonify, abort, flash, session
|
||||
from flask import Blueprint, render_template, render_template_string, make_response, url_for, current_app, request, redirect, jsonify, abort, flash, session
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
from ..decorators import operator_role_required, admin_role_required
|
||||
from ..decorators import operator_role_required, admin_role_required, history_access_required
|
||||
from ..models.user import User
|
||||
from ..models.account import Account
|
||||
from ..models.account_user import AccountUser
|
||||
|
@ -15,10 +16,12 @@ from ..models.server import Server
|
|||
from ..models.setting import Setting
|
||||
from ..models.history import History
|
||||
from ..models.domain import Domain
|
||||
from ..models.domain_user import DomainUser
|
||||
from ..models.record import Record
|
||||
from ..models.domain_template import DomainTemplate
|
||||
from ..models.domain_template_record import DomainTemplateRecord
|
||||
from ..models.api_key import ApiKey
|
||||
from ..models.base import db
|
||||
|
||||
from ..lib.schema import ApiPlainKeySchema
|
||||
|
||||
|
@ -29,13 +32,172 @@ admin_bp = Blueprint('admin',
|
|||
template_folder='templates',
|
||||
url_prefix='/admin')
|
||||
|
||||
"""
|
||||
changeSet is a list of tuples, in the following format
|
||||
(old_state, new_state, change_type)
|
||||
|
||||
old_state: dictionary with "disabled" and "content" keys. {"disabled" : False, "content" : "1.1.1.1" }
|
||||
new_state: similarly
|
||||
change_type: "addition" or "deletion" or "status" for status change or "unchanged" for no change
|
||||
|
||||
Note: A change in "content", is considered a deletion and recreation of the same record,
|
||||
holding the new content value.
|
||||
"""
|
||||
def get_record_changes(del_rrest, add_rrest):
|
||||
changeSet = []
|
||||
delSet = del_rrest['records'] if 'records' in del_rrest else []
|
||||
addSet = add_rrest['records'] if 'records' in add_rrest else []
|
||||
for d in delSet: # get the deletions and status changes
|
||||
exists = False
|
||||
for a in addSet:
|
||||
if d['content'] == a['content']:
|
||||
exists = True
|
||||
if d['disabled'] != a['disabled']:
|
||||
changeSet.append( ({"disabled":d['disabled'],"content":d['content']},
|
||||
{"disabled":a['disabled'],"content":a['content']},
|
||||
"status") )
|
||||
break
|
||||
|
||||
if not exists: # deletion
|
||||
changeSet.append( ({"disabled":d['disabled'],"content":d['content']},
|
||||
None,
|
||||
"deletion") )
|
||||
|
||||
for a in addSet: # get the additions
|
||||
exists = False
|
||||
for d in delSet:
|
||||
if d['content'] == a['content']:
|
||||
exists = True
|
||||
# already checked for status change
|
||||
break
|
||||
if not exists:
|
||||
changeSet.append( (None, {"disabled":a['disabled'], "content":a['content']}, "addition") )
|
||||
continue
|
||||
|
||||
for a in addSet: # get the unchanged
|
||||
exists = False
|
||||
for c in changeSet:
|
||||
if c[1] != None and c[1]["content"] == a['content']:
|
||||
exists = True
|
||||
break
|
||||
if not exists:
|
||||
changeSet.append( ( {"disabled":a['disabled'], "content":a['content']}, {"disabled":a['disabled'], "content":a['content']}, "unchanged") )
|
||||
|
||||
return changeSet
|
||||
|
||||
# out_changes is a list of HistoryRecordEntry objects in which we will append the new changes
|
||||
# a HistoryRecordEntry represents a pair of add_rrest and del_rrest
|
||||
def extract_changelogs_from_a_history_entry(out_changes, history_entry, change_num, record_name=None, record_type=None):
|
||||
|
||||
if history_entry.detail is None:
|
||||
return
|
||||
|
||||
if "add_rrests" in history_entry.detail:
|
||||
detail_dict = json.loads(history_entry.detail.replace("\'", ''))
|
||||
else: # not a record entry
|
||||
return
|
||||
|
||||
add_rrests = detail_dict['add_rrests']
|
||||
del_rrests = detail_dict['del_rrests']
|
||||
|
||||
|
||||
for add_rrest in add_rrests:
|
||||
exists = False
|
||||
for del_rrest in del_rrests:
|
||||
if del_rrest['name'] == add_rrest['name'] and del_rrest['type'] == add_rrest['type']:
|
||||
exists = True
|
||||
if change_num not in out_changes:
|
||||
out_changes[change_num] = []
|
||||
out_changes[change_num].append(HistoryRecordEntry(history_entry, del_rrest, add_rrest, "*"))
|
||||
break
|
||||
if not exists: # this is a new record
|
||||
if change_num not in out_changes:
|
||||
out_changes[change_num] = []
|
||||
out_changes[change_num].append(HistoryRecordEntry(history_entry, [], add_rrest, "+")) # (add_rrest, del_rrest, change_type)
|
||||
for del_rrest in del_rrests:
|
||||
exists = False
|
||||
for add_rrest in add_rrests:
|
||||
if del_rrest['name'] == add_rrest['name'] and del_rrest['type'] == add_rrest['type']:
|
||||
exists = True # no need to add in the out_changes set
|
||||
break
|
||||
if not exists: # this is a deletion
|
||||
if change_num not in out_changes:
|
||||
out_changes[change_num] = []
|
||||
out_changes[change_num].append(HistoryRecordEntry(history_entry, del_rrest, [], "-"))
|
||||
|
||||
|
||||
# only used for changelog per record
|
||||
if record_name != None and record_type != None: # then get only the records with the specific (record_name, record_type) tuple
|
||||
if change_num in out_changes:
|
||||
changes_i = out_changes[change_num]
|
||||
else:
|
||||
return
|
||||
for hre in changes_i: # for each history record entry in changes_i
|
||||
if 'type' in hre.add_rrest and hre.add_rrest['name'] == record_name and hre.add_rrest['type'] == record_type:
|
||||
continue
|
||||
elif 'type' in hre.del_rrest and hre.del_rrest['name'] == record_name and hre.del_rrest['type'] == record_type:
|
||||
continue
|
||||
else:
|
||||
out_changes[change_num].remove(hre)
|
||||
|
||||
|
||||
|
||||
# records with same (name,type) are considered as a single HistoryRecordEntry
|
||||
# history_entry is of type History - used to extract created_by and created_on
|
||||
# add_rrest is a dictionary of replace
|
||||
# del_rrest is a dictionary of remove
|
||||
class HistoryRecordEntry:
|
||||
def __init__(self, history_entry, del_rrest, add_rrest, change_type):
|
||||
# search the add_rrest index into the add_rrest set for the key (name, type)
|
||||
|
||||
self.history_entry = history_entry
|
||||
self.add_rrest = add_rrest
|
||||
self.del_rrest = del_rrest
|
||||
self.change_type = change_type # "*": edit or unchanged, "+" new tuple(name,type), "-" deleted (name,type) tuple
|
||||
self.changed_fields = [] # contains a subset of : [ttl, name, type]
|
||||
self.changeSet = [] # all changes for the records of this add_rrest-del_rrest pair
|
||||
|
||||
|
||||
if change_type == "+": # addition
|
||||
self.changed_fields.append("name")
|
||||
self.changed_fields.append("type")
|
||||
self.changed_fields.append("ttl")
|
||||
self.changeSet = get_record_changes(del_rrest, add_rrest)
|
||||
elif change_type == "-": # removal
|
||||
self.changed_fields.append("name")
|
||||
self.changed_fields.append("type")
|
||||
self.changed_fields.append("ttl")
|
||||
self.changeSet = get_record_changes(del_rrest, add_rrest)
|
||||
|
||||
elif change_type == "*": # edit of unchanged
|
||||
if add_rrest['ttl'] != del_rrest['ttl']:
|
||||
self.changed_fields.append("ttl")
|
||||
self.changeSet = get_record_changes(del_rrest, add_rrest)
|
||||
|
||||
|
||||
|
||||
def toDict(self):
|
||||
return {
|
||||
"add_rrest" : self.add_rrest,
|
||||
"del_rrest" : self.del_rrest,
|
||||
"changed_fields" : self.changed_fields,
|
||||
"created_on" : self.history_entry.created_on,
|
||||
"created_by" : self.history_entry.created_by,
|
||||
"change_type" : self.change_type,
|
||||
"changeSet" : self.changeSet
|
||||
}
|
||||
|
||||
def __eq__(self, obj2): # used for removal of objects from a list
|
||||
return True if obj2.toDict() == self.toDict() else False
|
||||
|
||||
@admin_bp.before_request
|
||||
def before_request():
|
||||
# Manage session timeout
|
||||
session.permanent = True
|
||||
# current_app.permanent_session_lifetime = datetime.timedelta(
|
||||
# minutes=int(Setting().get('session_timeout')))
|
||||
current_app.permanent_session_lifetime = datetime.timedelta(
|
||||
minutes=int(Setting().get('session_timeout')))
|
||||
minutes=int(Setting().get('session_timeout')))
|
||||
session.modified = True
|
||||
|
||||
|
||||
|
@ -99,17 +261,17 @@ def edit_user(user_username=None):
|
|||
fdata = request.form
|
||||
|
||||
if create:
|
||||
user_username = fdata['username']
|
||||
user_username = fdata.get('username', '').strip()
|
||||
|
||||
user = User(username=user_username,
|
||||
plain_text_password=fdata['password'],
|
||||
firstname=fdata['firstname'],
|
||||
lastname=fdata['lastname'],
|
||||
email=fdata['email'],
|
||||
plain_text_password=fdata.get('password', ''),
|
||||
firstname=fdata.get('firstname', '').strip(),
|
||||
lastname=fdata.get('lastname', '').strip(),
|
||||
email=fdata.get('email', '').strip(),
|
||||
reload_info=False)
|
||||
|
||||
if create:
|
||||
if fdata['password'] == "":
|
||||
if not fdata.get('password', ''):
|
||||
return render_template('admin_edit_user.html',
|
||||
user=user,
|
||||
create=create,
|
||||
|
@ -139,6 +301,7 @@ def edit_user(user_username=None):
|
|||
@operator_role_required
|
||||
def edit_key(key_id=None):
|
||||
domains = Domain.query.all()
|
||||
accounts = Account.query.all()
|
||||
roles = Role.query.all()
|
||||
apikey = None
|
||||
create = True
|
||||
|
@ -155,6 +318,7 @@ def edit_key(key_id=None):
|
|||
return render_template('admin_edit_key.html',
|
||||
key=apikey,
|
||||
domains=domains,
|
||||
accounts=accounts,
|
||||
roles=roles,
|
||||
create=create)
|
||||
|
||||
|
@ -162,14 +326,21 @@ def edit_key(key_id=None):
|
|||
fdata = request.form
|
||||
description = fdata['description']
|
||||
role = fdata.getlist('key_role')[0]
|
||||
doamin_list = fdata.getlist('key_multi_domain')
|
||||
domain_list = fdata.getlist('key_multi_domain')
|
||||
account_list = fdata.getlist('key_multi_account')
|
||||
|
||||
# Create new apikey
|
||||
if create:
|
||||
domain_obj_list = Domain.query.filter(Domain.name.in_(doamin_list)).all()
|
||||
if role == "User":
|
||||
domain_obj_list = Domain.query.filter(Domain.name.in_(domain_list)).all()
|
||||
account_obj_list = Account.query.filter(Account.name.in_(account_list)).all()
|
||||
else:
|
||||
account_obj_list, domain_obj_list = [], []
|
||||
|
||||
apikey = ApiKey(desc=description,
|
||||
role_name=role,
|
||||
domains=domain_obj_list)
|
||||
domains=domain_obj_list,
|
||||
accounts=account_obj_list)
|
||||
try:
|
||||
apikey.create()
|
||||
except Exception as e:
|
||||
|
@ -183,7 +354,9 @@ def edit_key(key_id=None):
|
|||
# Update existing apikey
|
||||
else:
|
||||
try:
|
||||
apikey.update(role,description,doamin_list)
|
||||
if role != "User":
|
||||
domain_list, account_list = [], []
|
||||
apikey.update(role,description,domain_list, account_list)
|
||||
history_message = "Updated API key {0}".format(apikey.id)
|
||||
except Exception as e:
|
||||
current_app.logger.error('Error: {0}'.format(e))
|
||||
|
@ -193,14 +366,16 @@ def edit_key(key_id=None):
|
|||
'key': apikey.id,
|
||||
'role': apikey.role.name,
|
||||
'description': apikey.description,
|
||||
'domain_acl': [domain.name for domain in apikey.domains]
|
||||
'domains': [domain.name for domain in apikey.domains],
|
||||
'accounts': [a.name for a in apikey.accounts]
|
||||
}),
|
||||
created_by=current_user.username)
|
||||
history.add()
|
||||
|
||||
|
||||
return render_template('admin_edit_key.html',
|
||||
key=apikey,
|
||||
domains=domains,
|
||||
accounts=accounts,
|
||||
roles=roles,
|
||||
create=create,
|
||||
plain_key=plain_key)
|
||||
|
@ -229,7 +404,7 @@ def manage_keys():
|
|||
history_apikey_role = apikey.role.name
|
||||
history_apikey_description = apikey.description
|
||||
history_apikey_domains = [ domain.name for domain in apikey.domains]
|
||||
|
||||
|
||||
apikey.delete()
|
||||
except Exception as e:
|
||||
current_app.logger.error('Error: {0}'.format(e))
|
||||
|
@ -438,6 +613,7 @@ def edit_account(account_name=None):
|
|||
if request.method == 'GET':
|
||||
if account_name is None:
|
||||
return render_template('admin_edit_account.html',
|
||||
account_user_ids=[],
|
||||
users=users,
|
||||
create=1)
|
||||
|
||||
|
@ -577,39 +753,498 @@ def manage_account():
|
|||
}), 400)
|
||||
|
||||
|
||||
class DetailedHistory():
|
||||
def __init__(self, history, change_set):
|
||||
self.history = history
|
||||
self.detailed_msg = ""
|
||||
self.change_set = change_set
|
||||
|
||||
if not history.detail:
|
||||
self.detailed_msg = ""
|
||||
return
|
||||
|
||||
if 'add_rrest' in history.detail:
|
||||
detail_dict = json.loads(history.detail.replace("\'", ''))
|
||||
else:
|
||||
detail_dict = json.loads(history.detail.replace("'", '"'))
|
||||
|
||||
if 'domain_type' in detail_dict and 'account_id' in detail_dict: # this is a domain creation
|
||||
self.detailed_msg = render_template_string("""
|
||||
<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
|
||||
@operator_role_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':
|
||||
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)
|
||||
|
||||
if request.method == 'GET':
|
||||
histories = History.query.all()
|
||||
return render_template('admin_history.html', histories=histories)
|
||||
|
||||
|
||||
@admin_bp.route('/setting/basic', methods=['GET'])
|
||||
|
@ -622,9 +1257,10 @@ 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', 'bg_domain_updates', 'site_name',
|
||||
'allow_user_create_domain', 'allow_user_remove_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'
|
||||
'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'
|
||||
]
|
||||
|
||||
return render_template('admin_setting_basic.html', settings=settings)
|
||||
|
@ -811,6 +1447,27 @@ def setting_authentication():
|
|||
Setting().set('ldap_user_group',
|
||||
request.form.get('ldap_user_group'))
|
||||
Setting().set('ldap_domain', request.form.get('ldap_domain'))
|
||||
Setting().set(
|
||||
'autoprovisioning', True
|
||||
if request.form.get('autoprovisioning') == 'ON' else False)
|
||||
Setting().set('autoprovisioning_attribute',
|
||||
request.form.get('autoprovisioning_attribute'))
|
||||
|
||||
if request.form.get('autoprovisioning')=='ON':
|
||||
if validateURN(request.form.get('urn_value')):
|
||||
Setting().set('urn_value',
|
||||
request.form.get('urn_value'))
|
||||
else:
|
||||
return render_template('admin_setting_authentication.html',
|
||||
error="Invalid urn")
|
||||
else:
|
||||
Setting().set('urn_value',
|
||||
request.form.get('urn_value'))
|
||||
|
||||
Setting().set('purge', True
|
||||
if request.form.get('purge') == 'ON' else False)
|
||||
|
||||
|
||||
result = {'status': True, 'msg': 'Saved successfully'}
|
||||
elif conf_type == 'google':
|
||||
google_oauth_enabled = True if request.form.get(
|
||||
|
@ -945,6 +1602,8 @@ 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',
|
||||
|
@ -1268,3 +1927,29 @@ def global_search():
|
|||
pass
|
||||
|
||||
return render_template('admin_global_search.html', domains=domains, records=records, comments=comments)
|
||||
|
||||
def validateURN(value):
|
||||
NID_PATTERN = re.compile(r'^[0-9a-z][0-9a-z-]{1,31}$', flags=re.IGNORECASE)
|
||||
NSS_PCHAR = '[a-z0-9-._~]|%[a-f0-9]{2}|[!$&\'()*+,;=]|:|@'
|
||||
NSS_PATTERN = re.compile(fr'^({NSS_PCHAR})({NSS_PCHAR}|/|\?)*$', re.IGNORECASE)
|
||||
|
||||
prefix=value.split(':')
|
||||
if (len(prefix)<3):
|
||||
current_app.logger.warning( "Too small urn prefix" )
|
||||
return False
|
||||
|
||||
urn=prefix[0]
|
||||
nid=prefix[1]
|
||||
nss=value.replace(urn+":"+nid+":", "")
|
||||
|
||||
if not urn.lower()=="urn":
|
||||
current_app.logger.warning( urn + ' contains invalid characters ' )
|
||||
return False
|
||||
if not re.match(NID_PATTERN, nid.lower()):
|
||||
current_app.logger.warning( nid + ' contains invalid characters ' )
|
||||
return False
|
||||
if not re.match(NSS_PATTERN, nss):
|
||||
current_app.logger.warning( nss + ' contains invalid characters ' )
|
||||
return False
|
||||
|
||||
return True
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import json
|
||||
from urllib.parse import urljoin
|
||||
from base64 import b64encode
|
||||
from flask import (
|
||||
Blueprint, g, request, abort, current_app, make_response, jsonify,
|
||||
)
|
||||
|
@ -13,29 +14,38 @@ from ..models import (
|
|||
from ..lib import utils, helper
|
||||
from ..lib.schema import (
|
||||
ApiKeySchema, DomainSchema, ApiPlainKeySchema, UserSchema, AccountSchema,
|
||||
UserDetailedSchema,
|
||||
)
|
||||
from ..lib.errors import (
|
||||
StructuredException,
|
||||
DomainNotExists, DomainAlreadyExists, DomainAccessForbidden,
|
||||
RequestIsNotJSON, ApiKeyCreateFail, ApiKeyNotUsable, NotEnoughPrivileges,
|
||||
AccountCreateFail, AccountUpdateFail, AccountDeleteFail,
|
||||
UserCreateFail, UserUpdateFail, UserDeleteFail,
|
||||
AccountCreateDuplicate, AccountNotExists,
|
||||
UserCreateFail, UserCreateDuplicate, UserUpdateFail, UserDeleteFail,
|
||||
UserUpdateFailEmail,
|
||||
)
|
||||
from ..decorators import (
|
||||
api_basic_auth, api_can_create_domain, is_json, apikey_auth,
|
||||
apikey_is_admin, apikey_can_access_domain, api_role_can,
|
||||
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,
|
||||
)
|
||||
import random
|
||||
import secrets
|
||||
import string
|
||||
|
||||
api_bp = Blueprint('api', __name__, url_prefix='/api/v1')
|
||||
|
||||
apikey_schema = ApiKeySchema(many=True)
|
||||
apikey_single_schema = ApiKeySchema()
|
||||
domain_schema = DomainSchema(many=True)
|
||||
apikey_plain_schema = ApiPlainKeySchema(many=True)
|
||||
apikey_plain_schema = ApiPlainKeySchema()
|
||||
user_schema = UserSchema(many=True)
|
||||
user_single_schema = UserSchema()
|
||||
user_detailed_schema = UserDetailedSchema()
|
||||
account_schema = AccountSchema(many=True)
|
||||
|
||||
account_single_schema = AccountSchema()
|
||||
|
||||
def get_user_domains():
|
||||
domains = db.session.query(Domain) \
|
||||
|
@ -91,62 +101,62 @@ def get_role_id(role_name, role_id=None):
|
|||
|
||||
@api_bp.errorhandler(400)
|
||||
def handle_400(err):
|
||||
return json.dumps({"msg": "Bad Request"}), 400
|
||||
return jsonify({"msg": "Bad Request"}), 400
|
||||
|
||||
|
||||
@api_bp.errorhandler(401)
|
||||
def handle_401(err):
|
||||
return json.dumps({"msg": "Unauthorized"}), 401
|
||||
return jsonify({"msg": "Unauthorized"}), 401
|
||||
|
||||
|
||||
@api_bp.errorhandler(409)
|
||||
def handle_409(err):
|
||||
return json.dumps({"msg": "Conflict"}), 409
|
||||
return jsonify({"msg": "Conflict"}), 409
|
||||
|
||||
|
||||
@api_bp.errorhandler(500)
|
||||
def handle_500(err):
|
||||
return json.dumps({"msg": "Internal Server Error"}), 500
|
||||
return jsonify({"msg": "Internal Server Error"}), 500
|
||||
|
||||
|
||||
@api_bp.errorhandler(StructuredException)
|
||||
def handle_StructuredException(err):
|
||||
return json.dumps(err.to_dict()), err.status_code
|
||||
return jsonify(err.to_dict()), err.status_code
|
||||
|
||||
|
||||
@api_bp.errorhandler(DomainNotExists)
|
||||
def handle_domain_not_exists(err):
|
||||
return json.dumps(err.to_dict()), err.status_code
|
||||
return jsonify(err.to_dict()), err.status_code
|
||||
|
||||
|
||||
@api_bp.errorhandler(DomainAlreadyExists)
|
||||
def handle_domain_already_exists(err):
|
||||
return json.dumps(err.to_dict()), err.status_code
|
||||
return jsonify(err.to_dict()), err.status_code
|
||||
|
||||
|
||||
@api_bp.errorhandler(DomainAccessForbidden)
|
||||
def handle_domain_access_forbidden(err):
|
||||
return json.dumps(err.to_dict()), err.status_code
|
||||
return jsonify(err.to_dict()), err.status_code
|
||||
|
||||
|
||||
@api_bp.errorhandler(ApiKeyCreateFail)
|
||||
def handle_apikey_create_fail(err):
|
||||
return json.dumps(err.to_dict()), err.status_code
|
||||
return jsonify(err.to_dict()), err.status_code
|
||||
|
||||
|
||||
@api_bp.errorhandler(ApiKeyNotUsable)
|
||||
def handle_apikey_not_usable(err):
|
||||
return json.dumps(err.to_dict()), err.status_code
|
||||
return jsonify(err.to_dict()), err.status_code
|
||||
|
||||
|
||||
@api_bp.errorhandler(NotEnoughPrivileges)
|
||||
def handle_not_enough_privileges(err):
|
||||
return json.dumps(err.to_dict()), err.status_code
|
||||
return jsonify(err.to_dict()), err.status_code
|
||||
|
||||
|
||||
@api_bp.errorhandler(RequestIsNotJSON)
|
||||
def handle_request_is_not_json(err):
|
||||
return json.dumps(err.to_dict()), err.status_code
|
||||
return jsonify(err.to_dict()), err.status_code
|
||||
|
||||
|
||||
@api_bp.before_request
|
||||
|
@ -198,10 +208,15 @@ def api_login_create_zone():
|
|||
current_app.logger.debug("Request to powerdns API successful")
|
||||
data = request.get_json(force=True)
|
||||
|
||||
domain = Domain()
|
||||
domain.update()
|
||||
domain_id = domain.get_id_by_name(data['name'].rstrip('.'))
|
||||
|
||||
history = History(msg='Add domain {0}'.format(
|
||||
data['name'].rstrip('.')),
|
||||
detail=json.dumps(data),
|
||||
created_by=current_user.username)
|
||||
created_by=current_user.username,
|
||||
domain_id=domain_id)
|
||||
history.add()
|
||||
|
||||
if current_user.role.name not in ['Administrator', 'Operator']:
|
||||
|
@ -211,9 +226,6 @@ def api_login_create_zone():
|
|||
domain.update()
|
||||
domain.grant_privileges([current_user.id])
|
||||
|
||||
domain = Domain()
|
||||
domain.update()
|
||||
|
||||
if resp.status_code == 409:
|
||||
raise (DomainAlreadyExists)
|
||||
|
||||
|
@ -229,7 +241,7 @@ def api_login_list_zones():
|
|||
domain_obj_list = Domain.query.all()
|
||||
|
||||
domain_obj_list = [] if domain_obj_list is None else domain_obj_list
|
||||
return json.dumps(domain_schema.dump(domain_obj_list)), 200
|
||||
return jsonify(domain_schema.dump(domain_obj_list)), 200
|
||||
|
||||
|
||||
@api_bp.route('/pdnsadmin/zones/<string:domain_name>', methods=['DELETE'])
|
||||
|
@ -270,13 +282,18 @@ def api_login_delete_zone(domain_name):
|
|||
if resp.status_code == 204:
|
||||
current_app.logger.debug("Request to powerdns API successful")
|
||||
|
||||
history = History(msg='Delete domain {0}'.format(domain_name),
|
||||
domain = Domain()
|
||||
domain_id = domain.get_id_by_name(domain_name)
|
||||
domain.update()
|
||||
|
||||
history = History(msg='Delete domain {0}'.format(
|
||||
pretty_domain_name(domain_name)),
|
||||
detail='',
|
||||
created_by=current_user.username)
|
||||
created_by=current_user.username,
|
||||
domain_id=domain_id)
|
||||
history.add()
|
||||
|
||||
domain = Domain()
|
||||
domain.update()
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error('Error: {0}'.format(e))
|
||||
abort(500)
|
||||
|
@ -292,25 +309,51 @@ def api_generate_apikey():
|
|||
role_name = None
|
||||
apikey = None
|
||||
domain_obj_list = []
|
||||
account_obj_list = []
|
||||
|
||||
abort(400) if 'domains' not in data else None
|
||||
abort(400) if not isinstance(data['domains'], (list, )) else None
|
||||
abort(400) if 'role' not in data else None
|
||||
|
||||
description = data['description'] if 'description' in data else None
|
||||
role_name = data['role']
|
||||
domains = data['domains']
|
||||
if 'domains' not in data:
|
||||
domains = []
|
||||
elif not isinstance(data['domains'], (list, )):
|
||||
abort(400)
|
||||
else:
|
||||
domains = [d['name'] if isinstance(d, dict) else d for d in data['domains']]
|
||||
|
||||
if role_name == 'User' and len(domains) == 0:
|
||||
current_app.logger.error("Apikey with User role must have 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):
|
||||
role_name = data['role']
|
||||
elif isinstance(data['role'], dict) and 'name' in data['role'].keys():
|
||||
role_name = data['role']['name']
|
||||
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")
|
||||
raise ApiKeyNotUsable()
|
||||
elif role_name == 'User':
|
||||
|
||||
if role_name == 'User' and len(domains) > 0:
|
||||
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
|
||||
|
@ -320,6 +363,11 @@ 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]
|
||||
|
@ -338,7 +386,8 @@ def api_generate_apikey():
|
|||
|
||||
apikey = ApiKey(desc=description,
|
||||
role_name=role_name,
|
||||
domains=domain_obj_list)
|
||||
domains=domain_obj_list,
|
||||
accounts=account_obj_list)
|
||||
|
||||
try:
|
||||
apikey.create()
|
||||
|
@ -346,7 +395,8 @@ def api_generate_apikey():
|
|||
current_app.logger.error('Error: {0}'.format(e))
|
||||
raise ApiKeyCreateFail(message='Api key create failed')
|
||||
|
||||
return json.dumps(apikey_plain_schema.dump([apikey])), 201
|
||||
apikey.plain_key = b64encode(apikey.plain_key.encode('utf-8')).decode('utf-8')
|
||||
return jsonify(apikey_plain_schema.dump(apikey)), 201
|
||||
|
||||
|
||||
@api_bp.route('/pdnsadmin/apikeys', defaults={'domain_name': None})
|
||||
|
@ -387,7 +437,24 @@ def api_get_apikeys(domain_name):
|
|||
current_app.logger.error('Error: {0}'.format(e))
|
||||
abort(500)
|
||||
|
||||
return json.dumps(apikey_schema.dump(apikeys)), 200
|
||||
return jsonify(apikey_schema.dump(apikeys)), 200
|
||||
|
||||
|
||||
@api_bp.route('/pdnsadmin/apikeys/<int:apikey_id>', methods=['GET'])
|
||||
@api_basic_auth
|
||||
def api_get_apikey(apikey_id):
|
||||
apikey = ApiKey.query.get(apikey_id)
|
||||
|
||||
if not apikey:
|
||||
abort(404)
|
||||
|
||||
current_app.logger.debug(current_user.role.name)
|
||||
|
||||
if current_user.role.name not in ['Administrator', 'Operator']:
|
||||
if apikey_id not in [a.id for a in get_user_apikeys()]:
|
||||
raise DomainAccessForbidden()
|
||||
|
||||
return jsonify(apikey_single_schema.dump(apikey)), 200
|
||||
|
||||
|
||||
@api_bp.route('/pdnsadmin/apikeys/<int:apikey_id>', methods=['DELETE'])
|
||||
|
@ -433,28 +500,85 @@ 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
|
||||
data = request.get_json()
|
||||
description = data['description'] if 'description' in data else None
|
||||
role_name = data['role'] if 'role' in data else None
|
||||
domains = data['domains'] if 'domains' in data else None
|
||||
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
|
||||
|
||||
if 'role' in data:
|
||||
if isinstance(data['role'], str):
|
||||
role_name = data['role']
|
||||
elif isinstance(data['role'], dict) and 'name' in data['role'].keys():
|
||||
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
|
||||
elif not isinstance(data['domains'], (list, )):
|
||||
abort(400)
|
||||
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']]
|
||||
|
||||
current_app.logger.debug('Updating apikey with id {0}'.format(apikey_id))
|
||||
|
||||
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 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 current_user.role.name not in ['Administrator', 'Operator']:
|
||||
if role_name != 'User':
|
||||
|
@ -462,8 +586,12 @@ 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()
|
||||
|
@ -487,12 +615,7 @@ def api_update_apikey(apikey_id):
|
|||
current_app.logger.error(msg)
|
||||
raise DomainAccessForbidden()
|
||||
|
||||
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:
|
||||
if role_name == apikey.role.name:
|
||||
current_app.logger.debug("Role is same, apikey role won't be updated")
|
||||
role_name = None
|
||||
|
||||
|
@ -501,10 +624,13 @@ 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))
|
||||
|
@ -520,12 +646,12 @@ def api_update_apikey(apikey_id):
|
|||
def api_list_users(username=None):
|
||||
if username is None:
|
||||
user_list = [] or User.query.all()
|
||||
return jsonify(user_schema.dump(user_list)), 200
|
||||
else:
|
||||
user_list = [] or User.query.filter(User.username == username).all()
|
||||
if not user_list:
|
||||
user = User.query.filter(User.username == username).first()
|
||||
if user is None:
|
||||
abort(404)
|
||||
|
||||
return json.dumps(user_schema.dump(user_list)), 200
|
||||
return jsonify(user_detailed_schema.dump(user)), 200
|
||||
|
||||
|
||||
@api_bp.route('/pdnsadmin/users', methods=['POST'])
|
||||
|
@ -563,7 +689,7 @@ def api_create_user():
|
|||
|
||||
if not plain_text_password and not password:
|
||||
plain_text_password = ''.join(
|
||||
random.choice(string.ascii_letters + string.digits)
|
||||
secrets.choice(string.ascii_letters + string.digits)
|
||||
for _ in range(15))
|
||||
if not role_name and not role_id:
|
||||
role_name = 'User'
|
||||
|
@ -593,12 +719,12 @@ def api_create_user():
|
|||
if not result['status']:
|
||||
current_app.logger.warning('Create user ({}, {}) error: {}'.format(
|
||||
username, email, result['msg']))
|
||||
raise UserCreateFail(message=result['msg'])
|
||||
raise UserCreateDuplicate(message=result['msg'])
|
||||
|
||||
history = History(msg='Created user {0}'.format(user.username),
|
||||
created_by=current_user.username)
|
||||
history.add()
|
||||
return json.dumps(user_schema.dump([user])), 201
|
||||
return jsonify(user_single_schema.dump(user)), 201
|
||||
|
||||
|
||||
@api_bp.route('/pdnsadmin/users/<int:user_id>', methods=['PUT'])
|
||||
|
@ -662,7 +788,10 @@ def api_update_user(user_id):
|
|||
if not result['status']:
|
||||
current_app.logger.warning('Update user ({}, {}) error: {}'.format(
|
||||
username, email, result['msg']))
|
||||
raise UserCreateFail(message=result['msg'])
|
||||
if result['msg'].startswith('New email'):
|
||||
raise UserUpdateFailEmail(message=result['msg'])
|
||||
else:
|
||||
raise UserCreateFail(message=result['msg'])
|
||||
|
||||
history = History(msg='Updated user {0}'.format(user.username),
|
||||
created_by=current_user.username)
|
||||
|
@ -713,12 +842,13 @@ def api_list_accounts(account_name):
|
|||
else:
|
||||
if account_name is None:
|
||||
account_list = [] or Account.query.all()
|
||||
return jsonify(account_schema.dump(account_list)), 200
|
||||
else:
|
||||
account_list = [] or Account.query.filter(
|
||||
Account.name == account_name).all()
|
||||
if not account_list:
|
||||
account = Account.query.filter(
|
||||
Account.name == account_name).first()
|
||||
if account is None:
|
||||
abort(404)
|
||||
return json.dumps(account_schema.dump(account_list)), 200
|
||||
return jsonify(account_single_schema.dump(account)), 200
|
||||
|
||||
|
||||
@api_bp.route('/pdnsadmin/accounts', methods=['POST'])
|
||||
|
@ -736,6 +866,12 @@ def api_create_account():
|
|||
current_app.logger.debug("Account name missing")
|
||||
abort(400)
|
||||
|
||||
account_exists = [] or Account.query.filter(Account.name == name).all()
|
||||
if len(account_exists) > 0:
|
||||
msg = "Account {} already exists".format(name)
|
||||
current_app.logger.debug(msg)
|
||||
raise AccountCreateDuplicate(message=msg)
|
||||
|
||||
account = Account(name=name,
|
||||
description=description,
|
||||
contact=contact,
|
||||
|
@ -752,7 +888,7 @@ def api_create_account():
|
|||
history = History(msg='Create account {0}'.format(account.name),
|
||||
created_by=current_user.username)
|
||||
history.add()
|
||||
return json.dumps(account_schema.dump([account])), 201
|
||||
return jsonify(account_single_schema.dump(account)), 201
|
||||
|
||||
|
||||
@api_bp.route('/pdnsadmin/accounts/<int:account_id>', methods=['PUT'])
|
||||
|
@ -788,7 +924,7 @@ def api_update_account(account_id):
|
|||
"Updating account {} ({})".format(account_id, account.name))
|
||||
result = account.update_account()
|
||||
if not result['status']:
|
||||
raise AccountDeleteFail(message=result['msg'])
|
||||
raise AccountUpdateFail(message=result['msg'])
|
||||
history = History(msg='Update account {0}'.format(account.name),
|
||||
created_by=current_user.username)
|
||||
history.add()
|
||||
|
@ -808,7 +944,7 @@ def api_delete_account(account_id):
|
|||
"Deleting account {} ({})".format(account_id, account.name))
|
||||
result = account.delete_account()
|
||||
if not result:
|
||||
raise AccountUpdateFail(message=result['msg'])
|
||||
raise AccountDeleteFail(message=result['msg'])
|
||||
|
||||
history = History(msg='Delete account {0}'.format(account.name),
|
||||
created_by=current_user.username)
|
||||
|
@ -817,6 +953,7 @@ def api_delete_account(account_id):
|
|||
|
||||
|
||||
@api_bp.route('/pdnsadmin/accounts/users/<int:account_id>', methods=['GET'])
|
||||
@api_bp.route('/pdnsadmin/accounts/<int:account_id>/users', methods=['GET'])
|
||||
@api_basic_auth
|
||||
@api_role_can('list account users')
|
||||
def api_list_account_users(account_id):
|
||||
|
@ -825,12 +962,15 @@ def api_list_account_users(account_id):
|
|||
abort(404)
|
||||
user_list = User.query.join(AccountUser).filter(
|
||||
AccountUser.account_id == account_id).all()
|
||||
return json.dumps(user_schema.dump(user_list)), 200
|
||||
return jsonify(user_schema.dump(user_list)), 200
|
||||
|
||||
|
||||
@api_bp.route(
|
||||
'/pdnsadmin/accounts/users/<int:account_id>/<int:user_id>',
|
||||
methods=['PUT'])
|
||||
@api_bp.route(
|
||||
'/pdnsadmin/accounts/<int:account_id>/users/<int:user_id>',
|
||||
methods=['PUT'])
|
||||
@api_basic_auth
|
||||
@api_role_can('add user to account')
|
||||
def api_add_account_user(account_id, user_id):
|
||||
|
@ -845,7 +985,7 @@ def api_add_account_user(account_id, user_id):
|
|||
user.username, account.name))
|
||||
|
||||
history = History(
|
||||
msg='Revoke {} user privileges on {}'.format(
|
||||
msg='Add {} user privileges on {}'.format(
|
||||
user.username, account.name),
|
||||
created_by=current_user.username)
|
||||
history.add()
|
||||
|
@ -855,6 +995,9 @@ def api_add_account_user(account_id, user_id):
|
|||
@api_bp.route(
|
||||
'/pdnsadmin/accounts/users/<int:account_id>/<int:user_id>',
|
||||
methods=['DELETE'])
|
||||
@api_bp.route(
|
||||
'/pdnsadmin/accounts/<int:account_id>/users/<int:user_id>',
|
||||
methods=['DELETE'])
|
||||
@api_basic_auth
|
||||
@api_role_can('remove user from account')
|
||||
def api_remove_account_user(account_id, user_id):
|
||||
|
@ -882,6 +1025,28 @@ 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'])
|
||||
|
@ -896,35 +1061,41 @@ 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()
|
||||
domain = Domain()
|
||||
domain.update()
|
||||
if not Setting().get('bg_domain_updates'):
|
||||
domain = Domain()
|
||||
domain.update()
|
||||
status = resp.status_code
|
||||
if 200 <= status < 300:
|
||||
current_app.logger.debug("Request to powerdns API successful")
|
||||
if request.method != 'GET' and request.method != 'DELETE':
|
||||
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)
|
||||
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('.')))
|
||||
history.add()
|
||||
elif request.method == 'DELETE':
|
||||
history = History(msg='Deleted zone {0}'.format(domain.name),
|
||||
detail='',
|
||||
created_by=g.apikey.description)
|
||||
history.add()
|
||||
return resp.content, resp.status_code, resp.headers.items()
|
||||
|
||||
|
||||
@api_bp.route('/servers', methods=['GET'])
|
||||
@apikey_auth
|
||||
@apikey_is_admin
|
||||
def api_server_forward():
|
||||
resp = helper.forward_request()
|
||||
return resp.content, resp.status_code, resp.headers.items()
|
||||
|
||||
|
||||
|
@ -938,6 +1109,7 @@ 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()
|
||||
|
||||
|
@ -945,12 +1117,6 @@ def api_create_zone(server_id):
|
|||
current_app.logger.debug("Request to powerdns API successful")
|
||||
data = request.get_json(force=True)
|
||||
|
||||
history = History(msg='Add domain {0}'.format(
|
||||
data['name'].rstrip('.')),
|
||||
detail=json.dumps(data),
|
||||
created_by=g.apikey.description)
|
||||
history.add()
|
||||
|
||||
if g.apikey.role.name not in ['Administrator', 'Operator']:
|
||||
current_app.logger.debug(
|
||||
"Apikey is user key, assigning created domain")
|
||||
|
@ -960,6 +1126,13 @@ def api_create_zone(server_id):
|
|||
domain = Domain()
|
||||
domain.update()
|
||||
|
||||
history = History(msg='Add domain {0}'.format(
|
||||
data['name'].rstrip('.')),
|
||||
detail=json.dumps(data),
|
||||
created_by=g.apikey.description,
|
||||
domain_id=domain.get_id_by_name(data['name'].rstrip('.')))
|
||||
history.add()
|
||||
|
||||
return resp.content, resp.status_code, resp.headers.items()
|
||||
|
||||
|
||||
|
@ -971,15 +1144,40 @@ def api_get_zones(server_id):
|
|||
domain_obj_list = g.apikey.domains
|
||||
else:
|
||||
domain_obj_list = Domain.query.all()
|
||||
return json.dumps(domain_schema.dump(domain_obj_list)), 200
|
||||
return jsonify(domain_schema.dump(domain_obj_list)), 200
|
||||
else:
|
||||
resp = helper.forward_request()
|
||||
return resp.content, resp.status_code, resp.headers.items()
|
||||
if (g.apikey.role.name not in ['Administrator', 'Operator']
|
||||
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])
|
||||
return content, resp.status_code, resp.headers.items()
|
||||
else:
|
||||
return resp.content, resp.status_code, resp.headers.items()
|
||||
|
||||
|
||||
@api_bp.route('/servers', methods=['GET'])
|
||||
@apikey_auth
|
||||
def api_server_forward():
|
||||
resp = helper.forward_request()
|
||||
return resp.content, resp.status_code, resp.headers.items()
|
||||
|
||||
@api_bp.route('/servers/<string:server_id>', methods=['GET'])
|
||||
@apikey_auth
|
||||
def api_server_config_forward(server_id):
|
||||
resp = helper.forward_request()
|
||||
return resp.content, resp.status_code, resp.headers.items()
|
||||
|
||||
# The endpoint to snychronize Domains in background
|
||||
@api_bp.route('/sync_domains', methods=['GET'])
|
||||
@apikey_auth
|
||||
@apikey_or_basic_auth
|
||||
def sync_domains():
|
||||
domain = Domain()
|
||||
domain.update()
|
||||
|
|
|
@ -3,6 +3,7 @@ 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
|
||||
|
@ -60,7 +61,7 @@ def domains_custom(boxId):
|
|||
))
|
||||
|
||||
template = current_app.jinja_env.get_template("dashboard_domain.html")
|
||||
render = template.make_module(vars={"current_user": current_user})
|
||||
render = template.make_module(vars={"current_user": current_user, "allow_user_view_history": Setting().get('allow_user_view_history')})
|
||||
|
||||
columns = [
|
||||
Domain.name, Domain.dnssec, Domain.type, Domain.serial, Domain.master,
|
||||
|
@ -150,11 +151,46 @@ 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 = Domain.query.count()
|
||||
domain_count = 0
|
||||
history_number = 0
|
||||
history = []
|
||||
user_num = User.query.count()
|
||||
history_number = History.query.count()
|
||||
history = History.query.order_by(History.created_on.desc()).limit(4)
|
||||
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()
|
||||
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()
|
||||
history = history[:4]
|
||||
domain_count = 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
|
||||
)).count()
|
||||
|
||||
from .admin import convert_histories, DetailedHistory
|
||||
detailedHistories = convert_histories(history)
|
||||
|
||||
server = Server(server_id='localhost')
|
||||
statistics = server.get_statistic()
|
||||
if statistics:
|
||||
|
@ -171,13 +207,14 @@ def dashboard():
|
|||
user_num=user_num,
|
||||
history_number=history_number,
|
||||
uptime=uptime,
|
||||
histories=history,
|
||||
show_bg_domain_button=BG_DOMAIN_UPDATE,
|
||||
histories=detailedHistories,
|
||||
show_bg_domain_button=show_bg_domain_button,
|
||||
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()
|
||||
|
|
|
@ -8,8 +8,9 @@ from distutils.version import StrictVersion
|
|||
from flask import Blueprint, render_template, make_response, url_for, current_app, request, redirect, abort, jsonify, g, session
|
||||
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
|
||||
from ..decorators import can_create_domain, operator_role_required, can_access_domain, can_configure_dnssec, can_remove_domain
|
||||
from ..models.user import User, Anonymous
|
||||
from ..models.account import Account
|
||||
from ..models.setting import Setting
|
||||
|
@ -20,7 +21,11 @@ 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',
|
||||
|
@ -127,7 +132,217 @@ def domain(domain_name):
|
|||
records=records,
|
||||
editable_records=editable_records,
|
||||
quick_edit=quick_edit,
|
||||
ttl_options=ttl_options)
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@domain_bp.route('/add', methods=['GET', 'POST'])
|
||||
|
@ -148,7 +363,30 @@ def add():
|
|||
'errors/400.html',
|
||||
msg="Please enter a valid domain name"), 400
|
||||
|
||||
# If User creates the domain, check some additional stuff
|
||||
if current_user.role.name not in ['Administrator', 'Operator']:
|
||||
# Get all the account_ids of the user
|
||||
user_accounts_ids = current_user.get_accounts()
|
||||
user_accounts_ids = [x.id for x in user_accounts_ids]
|
||||
# User may not create domains without Account
|
||||
if int(account_id) == 0 or int(account_id) not in user_accounts_ids:
|
||||
return render_template(
|
||||
'errors/400.html',
|
||||
msg="Please use a valid Account"), 400
|
||||
|
||||
|
||||
#TODO: Validate ip addresses input
|
||||
|
||||
# Encode domain name into punycode (IDN)
|
||||
try:
|
||||
domain_name = domain_name.encode('idna').decode()
|
||||
except:
|
||||
current_app.logger.error("Cannot encode the domain name {}".format(domain_name))
|
||||
current_app.logger.debug(traceback.format_exc())
|
||||
return render_template(
|
||||
'errors/400.html',
|
||||
msg="Please enter a valid domain name"), 400
|
||||
|
||||
if domain_type == 'slave':
|
||||
if request.form.getlist('domain_master_address'):
|
||||
domain_master_string = request.form.getlist(
|
||||
|
@ -168,13 +406,16 @@ def add():
|
|||
domain_master_ips=domain_master_ips,
|
||||
account_name=account_name)
|
||||
if result['status'] == 'ok':
|
||||
history = History(msg='Add domain {0}'.format(domain_name),
|
||||
domain_id = Domain().get_id_by_name(domain_name)
|
||||
history = History(msg='Add domain {0}'.format(
|
||||
pretty_domain_name(domain_name)),
|
||||
detail=str({
|
||||
'domain_type': domain_type,
|
||||
'domain_master_ips': domain_master_ips,
|
||||
'account_id': account_id
|
||||
}),
|
||||
created_by=current_user.username)
|
||||
created_by=current_user.username,
|
||||
domain_id=domain_id)
|
||||
history.add()
|
||||
|
||||
# grant user access to the domain
|
||||
|
@ -215,7 +456,8 @@ def add():
|
|||
"del_rrests":
|
||||
result['data'][1]['rrsets']
|
||||
})),
|
||||
created_by=current_user.username)
|
||||
created_by=current_user.username,
|
||||
domain_id=domain_id)
|
||||
history.add()
|
||||
else:
|
||||
history = History(
|
||||
|
@ -234,13 +476,19 @@ def add():
|
|||
current_app.logger.debug(traceback.format_exc())
|
||||
abort(500)
|
||||
|
||||
# Get
|
||||
else:
|
||||
accounts = Account.query.order_by(Account.name).all()
|
||||
# Admins and Operators can set to any account
|
||||
if current_user.role.name in ['Administrator', 'Operator']:
|
||||
accounts = Account.query.order_by(Account.name).all()
|
||||
else:
|
||||
accounts = current_user.get_accounts()
|
||||
return render_template('domain_add.html',
|
||||
templates=templates,
|
||||
accounts=accounts)
|
||||
|
||||
|
||||
|
||||
@domain_bp.route('/setting/<path:domain_name>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@operator_role_required
|
||||
|
@ -251,7 +499,8 @@ def delete(domain_name):
|
|||
if result['status'] == 'error':
|
||||
abort(500)
|
||||
|
||||
history = History(msg='Delete domain {0}'.format(domain_name),
|
||||
history = History(msg='Delete domain {0}'.format(
|
||||
pretty_domain_name(domain_name)),
|
||||
created_by=current_user.username)
|
||||
history.add()
|
||||
|
||||
|
@ -294,9 +543,11 @@ def setting(domain_name):
|
|||
d.grant_privileges(new_user_ids)
|
||||
|
||||
history = History(
|
||||
msg='Change domain {0} access control'.format(domain_name),
|
||||
msg='Change domain {0} access control'.format(
|
||||
pretty_domain_name(domain_name)),
|
||||
detail=str({'user_has_access': new_user_list}),
|
||||
created_by=current_user.username)
|
||||
created_by=current_user.username,
|
||||
domain_id=d.id)
|
||||
history.add()
|
||||
|
||||
return redirect(url_for('domain.setting', domain_name=domain_name))
|
||||
|
@ -330,13 +581,15 @@ def change_type(domain_name):
|
|||
kind=domain_type,
|
||||
masters=domain_master_ips)
|
||||
if status['status'] == 'ok':
|
||||
history = History(msg='Update type for domain {0}'.format(domain_name),
|
||||
history = History(msg='Update type for domain {0}'.format(
|
||||
pretty_domain_name(domain_name)),
|
||||
detail=str({
|
||||
"domain": domain_name,
|
||||
"type": domain_type,
|
||||
"masters": domain_master_ips
|
||||
}),
|
||||
created_by=current_user.username)
|
||||
created_by=current_user.username,
|
||||
domain_id=Domain().get_id_by_name(domain_name))
|
||||
history.add()
|
||||
return redirect(url_for('domain.setting', domain_name = domain_name))
|
||||
else:
|
||||
|
@ -362,12 +615,14 @@ def change_soa_edit_api(domain_name):
|
|||
soa_edit_api=new_setting)
|
||||
if status['status'] == 'ok':
|
||||
history = History(
|
||||
msg='Update soa_edit_api for domain {0}'.format(domain_name),
|
||||
msg='Update soa_edit_api for domain {0}'.format(
|
||||
pretty_domain_name(domain_name)),
|
||||
detail=str({
|
||||
"domain": domain_name,
|
||||
"soa_edit_api": new_setting
|
||||
}),
|
||||
created_by=current_user.username)
|
||||
created_by=current_user.username,
|
||||
domain_id=d.get_id_by_name(domain_name))
|
||||
history.add()
|
||||
return redirect(url_for('domain.setting', domain_name = domain_name))
|
||||
else:
|
||||
|
@ -421,26 +676,28 @@ def record_apply(domain_name):
|
|||
'status':
|
||||
'error',
|
||||
'msg':
|
||||
'Domain name {0} does not exist'.format(domain_name)
|
||||
'Domain name {0} does not exist'.format(pretty_domain_name(domain_name))
|
||||
}), 404)
|
||||
|
||||
r = Record()
|
||||
result = r.apply(domain_name, submitted_record)
|
||||
if result['status'] == 'ok':
|
||||
history = History(
|
||||
msg='Apply record changes to domain {0}'.format(domain_name),
|
||||
msg='Apply record changes to domain {0}'.format(pretty_domain_name(domain_name)),
|
||||
detail=str(
|
||||
json.dumps({
|
||||
"domain": domain_name,
|
||||
"add_rrests": result['data'][0]['rrsets'],
|
||||
"del_rrests": result['data'][1]['rrsets']
|
||||
})),
|
||||
created_by=current_user.username)
|
||||
created_by=current_user.username,
|
||||
domain_id=domain.id)
|
||||
history.add()
|
||||
return make_response(jsonify(result), 200)
|
||||
else:
|
||||
history = History(
|
||||
msg='Failed to apply record changes to domain {0}'.format(domain_name),
|
||||
msg='Failed to apply record changes to domain {0}'.format(
|
||||
pretty_domain_name(domain_name)),
|
||||
detail=str(
|
||||
json.dumps({
|
||||
"domain": domain_name,
|
||||
|
@ -566,8 +823,10 @@ def admin_setdomainsetting(domain_name):
|
|||
if setting.set(new_value):
|
||||
history = History(
|
||||
msg='Setting {0} changed value to {1} for {2}'.
|
||||
format(new_setting, new_value, domain.name),
|
||||
created_by=current_user.username)
|
||||
format(new_setting, new_value,
|
||||
pretty_domain_name(domain_name)),
|
||||
created_by=current_user.username,
|
||||
domain_id=domain.id)
|
||||
history.add()
|
||||
return make_response(
|
||||
jsonify({
|
||||
|
@ -585,8 +844,9 @@ def admin_setdomainsetting(domain_name):
|
|||
history = History(
|
||||
msg=
|
||||
'New setting {0} with value {1} for {2} has been created'
|
||||
.format(new_setting, new_value, domain.name),
|
||||
created_by=current_user.username)
|
||||
.format(new_setting, new_value, pretty_domain_name(domain_name)),
|
||||
created_by=current_user.username,
|
||||
domain_id=domain.id)
|
||||
history.add()
|
||||
return make_response(
|
||||
jsonify({
|
||||
|
|
|
@ -4,6 +4,7 @@ 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
|
||||
|
@ -43,7 +44,6 @@ index_bp = Blueprint('index',
|
|||
template_folder='templates',
|
||||
url_prefix='/')
|
||||
|
||||
|
||||
@index_bp.before_app_first_request
|
||||
def register_modules():
|
||||
global google
|
||||
|
@ -168,10 +168,8 @@ def login():
|
|||
return redirect(url_for('index.login'))
|
||||
|
||||
session['user_id'] = user.id
|
||||
login_user(user, remember=False)
|
||||
session['authentication_type'] = 'OAuth'
|
||||
signin_history(user.username, 'Google OAuth', True)
|
||||
return redirect(url_for('index.index'))
|
||||
return authenticate_user(user, 'Google OAuth')
|
||||
|
||||
if 'github_token' in session:
|
||||
me = json.loads(github.get('user').text)
|
||||
|
@ -196,9 +194,7 @@ def login():
|
|||
|
||||
session['user_id'] = user.id
|
||||
session['authentication_type'] = 'OAuth'
|
||||
login_user(user, remember=False)
|
||||
signin_history(user.username, 'Github OAuth', True)
|
||||
return redirect(url_for('index.index'))
|
||||
return authenticate_user(user, 'Github OAuth')
|
||||
|
||||
if 'azure_token' in session:
|
||||
azure_info = azure.get('me?$select=displayName,givenName,id,mail,surname,userPrincipalName').text
|
||||
|
@ -282,46 +278,52 @@ def login():
|
|||
# Handle account/group creation, if enabled
|
||||
if Setting().get('azure_group_accounts_enabled') and mygroups:
|
||||
current_app.logger.info('Azure group account sync enabled')
|
||||
name_value = Setting().get('azure_group_accounts_name')
|
||||
description_value = Setting().get('azure_group_accounts_description')
|
||||
select_values = name_value
|
||||
if description_value != '':
|
||||
select_values += ',' + description_value
|
||||
|
||||
mygroups = get_azure_groups(
|
||||
'me/memberOf/microsoft.graph.group?$count=false&$securityEnabled=true&$select={}'.format(select_values))
|
||||
|
||||
description_pattern = Setting().get('azure_group_accounts_description_re')
|
||||
pattern = Setting().get('azure_group_accounts_name_re')
|
||||
|
||||
# Loop through users security groups
|
||||
for azure_group in mygroups:
|
||||
|
||||
name_value = Setting().get('azure_group_accounts_name')
|
||||
description_value = Setting().get('azure_group_accounts_description')
|
||||
|
||||
select_values = name_value
|
||||
if description_value != '':
|
||||
select_values += ',' + description_value
|
||||
azure_group_info = azure.get('groups/{}?$select={}'.format(azure_group, select_values)).text
|
||||
current_app.logger.info('Group name for {}: {}'.format(azure_group, azure_group_info))
|
||||
group_info = json.loads(azure_group_info)
|
||||
if name_value in group_info:
|
||||
group_name = group_info[name_value]
|
||||
if name_value in azure_group:
|
||||
group_name = azure_group[name_value]
|
||||
group_description = ''
|
||||
if description_value in group_info:
|
||||
group_description = group_info[description_value]
|
||||
if description_value in azure_group:
|
||||
group_description = azure_group[description_value]
|
||||
|
||||
# Do regex search if enabled for group description
|
||||
description_pattern = Setting().get('azure_group_accounts_description_re')
|
||||
if description_pattern != '':
|
||||
current_app.logger.info('Matching group description {} against regex {}'.format(group_description, description_pattern))
|
||||
matches = re.match(description_pattern,group_description)
|
||||
current_app.logger.info('Matching group description {} against regex {}'.format(
|
||||
group_description, description_pattern))
|
||||
matches = re.match(
|
||||
description_pattern, group_description)
|
||||
if matches:
|
||||
current_app.logger.info('Group {} matched regexp'.format(group_description))
|
||||
current_app.logger.info(
|
||||
'Group {} matched regexp'.format(group_description))
|
||||
group_description = matches.group(1)
|
||||
else:
|
||||
# Regexp didn't match, continue to next iteration
|
||||
next
|
||||
continue
|
||||
|
||||
# Do regex search if enabled for group name
|
||||
pattern = Setting().get('azure_group_accounts_name_re')
|
||||
if pattern != '':
|
||||
current_app.logger.info('Matching group name {} against regex {}'.format(group_name, pattern))
|
||||
matches = re.match(pattern,group_name)
|
||||
current_app.logger.info(
|
||||
'Matching group name {} against regex {}'.format(group_name, pattern))
|
||||
matches = re.match(pattern, group_name)
|
||||
if matches:
|
||||
current_app.logger.info('Group {} matched regexp'.format(group_name))
|
||||
current_app.logger.info(
|
||||
'Group {} matched regexp'.format(group_name))
|
||||
group_name = matches.group(1)
|
||||
else:
|
||||
# Regexp didn't match, continue to next iteration
|
||||
next
|
||||
continue
|
||||
|
||||
account = Account()
|
||||
account_id = account.get_id_by_name(account_name=group_name)
|
||||
|
@ -361,10 +363,7 @@ def login():
|
|||
history.add()
|
||||
current_app.logger.warning('group info: {} '.format(account_id))
|
||||
|
||||
|
||||
login_user(user, remember=False)
|
||||
signin_history(user.username, 'Azure OAuth', True)
|
||||
return redirect(url_for('index.index'))
|
||||
return authenticate_user(user, 'Azure OAuth')
|
||||
|
||||
if 'oidc_token' in session:
|
||||
me = json.loads(oidc.get('userinfo').text)
|
||||
|
@ -392,22 +391,43 @@ 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:
|
||||
account = handle_account(me[name_prop], me[desc_prop])
|
||||
account.add_user(user)
|
||||
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)
|
||||
user_accounts = user.get_accounts()
|
||||
for ua in user_accounts:
|
||||
if ua.name != account.name:
|
||||
ua.remove_user(user)
|
||||
|
||||
# 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)
|
||||
|
||||
session['user_id'] = user.id
|
||||
session['authentication_type'] = 'OAuth'
|
||||
login_user(user, remember=False)
|
||||
signin_history(user.username, 'OIDC OAuth', True)
|
||||
return redirect(url_for('index.index'))
|
||||
return authenticate_user(user, 'OIDC OAuth')
|
||||
|
||||
if request.method == 'GET':
|
||||
return render_template('login.html', saml_enabled=SAML_ENABLED)
|
||||
|
@ -467,9 +487,36 @@ def login():
|
|||
saml_enabled=SAML_ENABLED,
|
||||
error='Token required')
|
||||
|
||||
login_user(user, remember=remember_me)
|
||||
signin_history(user.username, 'LOCAL', True)
|
||||
return redirect(session.get('next', url_for('index.index')))
|
||||
if Setting().get('autoprovisioning') and auth_method!='LOCAL':
|
||||
urn_value=Setting().get('urn_value')
|
||||
Entitlements=user.read_entitlements(Setting().get('autoprovisioning_attribute'))
|
||||
if len(Entitlements)==0 and Setting().get('purge'):
|
||||
user.set_role("User")
|
||||
user.revoke_privilege(True)
|
||||
|
||||
elif len(Entitlements)!=0:
|
||||
if checkForPDAEntries(Entitlements, urn_value):
|
||||
user.updateUser(Entitlements)
|
||||
else:
|
||||
current_app.logger.warning('Not a single powerdns-admin record was found, possibly a typo in the prefix')
|
||||
if Setting().get('purge'):
|
||||
user.set_role("User")
|
||||
user.revoke_privilege(True)
|
||||
current_app.logger.warning('Procceding to revoke every privilige from ' + user.username + '.' )
|
||||
|
||||
return authenticate_user(user, 'LOCAL', remember_me)
|
||||
|
||||
def checkForPDAEntries(Entitlements, urn_value):
|
||||
"""
|
||||
Run through every record located in the ldap attribute given and determine if there are any valid powerdns-admin records
|
||||
"""
|
||||
urnArguments=[x.lower() for x in urn_value.split(':')]
|
||||
for Entitlement in Entitlements:
|
||||
entArguments=Entitlement.split(':powerdns-admin')
|
||||
entArguments=[x.lower() for x in entArguments[0].split(':')]
|
||||
if (entArguments==urnArguments):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def clear_session():
|
||||
|
@ -512,6 +559,38 @@ def signin_history(username, authenticator, success):
|
|||
}),
|
||||
created_by='System').add()
|
||||
|
||||
# Get a list of Azure security groups the user is a member of
|
||||
def get_azure_groups(uri):
|
||||
azure_info = azure.get(uri).text
|
||||
current_app.logger.info('Azure groups returned: ' + azure_info)
|
||||
grouplookup = json.loads(azure_info)
|
||||
if "value" in grouplookup:
|
||||
mygroups = grouplookup["value"]
|
||||
# If "@odata.nextLink" exists in the results, we need to get more groups
|
||||
if "@odata.nextLink" in grouplookup:
|
||||
# The additional groups are added to the existing array
|
||||
mygroups.extend(get_azure_groups(grouplookup["@odata.nextLink"]))
|
||||
else:
|
||||
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():
|
||||
|
@ -575,12 +654,12 @@ def register():
|
|||
if request.method == 'GET':
|
||||
return render_template('register.html')
|
||||
elif request.method == 'POST':
|
||||
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')
|
||||
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', '')
|
||||
|
||||
if not username or not password or not email:
|
||||
return render_template(
|
||||
|
@ -602,7 +681,12 @@ def register():
|
|||
if result and result['status']:
|
||||
if Setting().get('verify_user_email'):
|
||||
send_account_verification(email)
|
||||
return redirect(url_for('index.login'))
|
||||
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'))
|
||||
else:
|
||||
return render_template('register.html',
|
||||
error=result['msg'])
|
||||
|
@ -612,6 +696,28 @@ 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)
|
||||
|
@ -750,7 +856,8 @@ def dyndns_update():
|
|||
msg=
|
||||
"DynDNS update: attempted update of {0} but record already up-to-date"
|
||||
.format(hostname),
|
||||
created_by=current_user.username)
|
||||
created_by=current_user.username,
|
||||
domain_id=domain.id)
|
||||
history.add()
|
||||
else:
|
||||
oldip = r.data
|
||||
|
@ -765,7 +872,8 @@ def dyndns_update():
|
|||
"old_value": oldip,
|
||||
"new_value": str(ip)
|
||||
}),
|
||||
created_by=current_user.username)
|
||||
created_by=current_user.username,
|
||||
domain_id=domain.id)
|
||||
history.add()
|
||||
response = 'good'
|
||||
else:
|
||||
|
@ -804,7 +912,8 @@ def dyndns_update():
|
|||
"record": hostname,
|
||||
"value": str(ip)
|
||||
}),
|
||||
created_by=current_user.username)
|
||||
created_by=current_user.username,
|
||||
domain_id=domain.id)
|
||||
history.add()
|
||||
response = 'good'
|
||||
else:
|
||||
|
@ -919,7 +1028,7 @@ def saml_authorized():
|
|||
else:
|
||||
user_groups = []
|
||||
if admin_attribute_name or group_attribute_name:
|
||||
user_accounts = set(user.get_account())
|
||||
user_accounts = set(user.get_accounts())
|
||||
saml_accounts = []
|
||||
for group_mapping in group_to_account_mapping:
|
||||
mapping = group_mapping.split('=')
|
||||
|
@ -962,9 +1071,7 @@ def saml_authorized():
|
|||
user.plain_text_password = None
|
||||
user.update_profile()
|
||||
session['authentication_type'] = 'SAML'
|
||||
login_user(user, remember=False)
|
||||
signin_history(user.username, 'SAML', True)
|
||||
return redirect(url_for('index.login'))
|
||||
return authenticate_user(user, 'SAML')
|
||||
else:
|
||||
return render_template('errors/SAML.html', errors=errors)
|
||||
|
||||
|
|
|
@ -1,7 +1,4 @@
|
|||
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
|
||||
|
||||
|
@ -41,13 +38,10 @@ def profile():
|
|||
return render_template('user_profile.html')
|
||||
if request.method == 'POST':
|
||||
if session['authentication_type'] == 'LOCAL':
|
||||
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 ''
|
||||
firstname = request.form.get('firstname', '').strip()
|
||||
lastname = request.form.get('lastname', '').strip()
|
||||
email = request.form.get('email', '').strip()
|
||||
new_password = request.form.get('password', '')
|
||||
else:
|
||||
firstname = lastname = email = new_password = ''
|
||||
current_app.logger.warning(
|
||||
|
@ -97,13 +91,9 @@ def qrcode():
|
|||
if not current_user:
|
||||
return redirect(url_for('index'))
|
||||
|
||||
img = qrc.make(current_user.get_totp_uri(),
|
||||
image_factory=qrc_svg.SvgPathImage)
|
||||
stream = BytesIO()
|
||||
img.save(stream)
|
||||
return stream.getvalue(), 200, {
|
||||
return current_user.get_qrcode_value(), 200, {
|
||||
'Content-Type': 'image/svg+xml',
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0'
|
||||
}
|
||||
}
|
|
@ -104,10 +104,10 @@ class SAML(object):
|
|||
settings['sp']['entityId'] = current_app.config['SAML_SP_ENTITY_ID']
|
||||
|
||||
|
||||
if ('SAML_CERT_FILE' in current_app.config) and ('SAML_KEY_FILE' in current_app.config):
|
||||
if ('SAML_CERT' in current_app.config) and ('SAML_KEY' in current_app.config):
|
||||
|
||||
saml_cert_file = current_app.config['SAML_CERT_FILE']
|
||||
saml_key_file = current_app.config['SAML_KEY_FILE']
|
||||
saml_cert_file = current_app.config['SAML_CERT']
|
||||
saml_key_file = current_app.config['SAML_KEY']
|
||||
|
||||
if os.path.isfile(saml_cert_file):
|
||||
cert = open(saml_cert_file, "r").readlines()
|
||||
|
|
102
powerdnsadmin/static/assets/css/style.css
Normal file
102
powerdnsadmin/static/assets/css/style.css
Normal file
|
@ -0,0 +1,102 @@
|
|||
/* 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;
|
||||
}
|
|
@ -53,6 +53,7 @@ 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) {
|
||||
|
@ -284,4 +285,14 @@ 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);
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
swagger: '2.0'
|
||||
info:
|
||||
version: "0.0.13"
|
||||
version: "0.0.14"
|
||||
title: PowerDNS Admin Authoritative HTTP API
|
||||
license:
|
||||
name: MIT
|
||||
|
@ -797,6 +797,11 @@ paths:
|
|||
type: array
|
||||
items:
|
||||
$ref: '#/definitions/PDNSAdminZones'
|
||||
'401':
|
||||
description: 'Unauthorized'
|
||||
schema:
|
||||
$ref: '#/definitions/Error'
|
||||
|
||||
post:
|
||||
security:
|
||||
- basicAuth: []
|
||||
|
@ -816,6 +821,23 @@ 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
|
||||
|
@ -839,6 +861,23 @@ 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:
|
||||
|
@ -854,15 +893,23 @@ 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, keys could not be retrieved. Contains error message'
|
||||
description: 'Internal Server Error. There was a problem creating the key'
|
||||
schema:
|
||||
$ref: '#/definitions/Error'
|
||||
post:
|
||||
security:
|
||||
- basicAuth: []
|
||||
summary: 'Add a ApiKey key'
|
||||
description: 'This methods add a new ApiKey. The actual key can be generated by the server or be provided by the client'
|
||||
description: 'This methods add a new ApiKey. The actual key is generated by the server'
|
||||
operationId: api_generate_apikey
|
||||
tags:
|
||||
- apikey
|
||||
|
@ -878,14 +925,27 @@ paths:
|
|||
description: Created
|
||||
schema:
|
||||
$ref: '#/definitions/ApiKey'
|
||||
'422':
|
||||
description: 'Unprocessable Entry, the ApiKey provided has issues.'
|
||||
'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'
|
||||
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
|
||||
|
@ -905,12 +965,16 @@ paths:
|
|||
description: OK.
|
||||
schema:
|
||||
$ref: '#/definitions/ApiKey'
|
||||
'404':
|
||||
description: 'Not found. The ApiKey with the specified apikey_id does not exist'
|
||||
'401':
|
||||
description: 'Unauthorized'
|
||||
schema:
|
||||
$ref: '#/definitions/Error'
|
||||
'500':
|
||||
description: 'Internal Server Error, keys could not be retrieved. Contains error message'
|
||||
'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'
|
||||
delete:
|
||||
|
@ -923,6 +987,14 @@ 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:
|
||||
|
@ -936,9 +1008,11 @@ paths:
|
|||
- basicAuth: []
|
||||
description: |
|
||||
The ApiKey at apikey_id can be changed in multiple ways:
|
||||
* Role, description, domains can be updated
|
||||
* Role, description, accounts and 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:
|
||||
|
@ -955,14 +1029,27 @@ 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. The TSIGKey with the specified tsigkey_id does not exist'
|
||||
description: 'Not found (ApiKey, Domain or Account)'
|
||||
schema:
|
||||
$ref: '#/definitions/Error'
|
||||
'500':
|
||||
description: 'Internal Server Error. Contains error message'
|
||||
schema:
|
||||
$ref: '#/definitions/Error'
|
||||
|
||||
'/pdnsadmin/users':
|
||||
get:
|
||||
security:
|
||||
|
@ -978,6 +1065,10 @@ 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:
|
||||
|
@ -1036,13 +1127,22 @@ paths:
|
|||
schema:
|
||||
$ref: '#/definitions/User'
|
||||
'400':
|
||||
description: Unprocessable Entry, the User data provided has issues
|
||||
description: 'Request is not JSON'
|
||||
schema:
|
||||
$ref: '#/definitions/Error'
|
||||
'401':
|
||||
description: 'Unauthorized'
|
||||
schema:
|
||||
$ref: '#/definitions/Error'
|
||||
'409':
|
||||
description: Duplicate Entry, either the Name or the Email is already in use
|
||||
schema:
|
||||
$ref: '#/definitions/Error'
|
||||
'500':
|
||||
description: Internal Server Error. There was a problem creating the user
|
||||
schema:
|
||||
$ref: '#/definitions/Error'
|
||||
|
||||
'/pdnsadmin/users/{username}':
|
||||
parameters:
|
||||
- name: username
|
||||
|
@ -1061,7 +1161,11 @@ paths:
|
|||
'200':
|
||||
description: Retrieve a specific User
|
||||
schema:
|
||||
$ref: '#/definitions/User'
|
||||
$ref: '#/definitions/UserDetailed'
|
||||
'401':
|
||||
description: 'Unauthorized'
|
||||
schema:
|
||||
$ref: '#/definitions/Error'
|
||||
'404':
|
||||
description: Not found. The User with the specified username does not exist
|
||||
schema:
|
||||
|
@ -1070,6 +1174,7 @@ 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
|
||||
|
@ -1123,10 +1228,22 @@ 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:
|
||||
|
@ -1141,6 +1258,10 @@ 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:
|
||||
|
@ -1149,6 +1270,7 @@ paths:
|
|||
description: Internal Server Error. Contains error message
|
||||
schema:
|
||||
$ref: '#/definitions/Error'
|
||||
|
||||
'/pdnsadmin/accounts':
|
||||
get:
|
||||
security:
|
||||
|
@ -1164,8 +1286,8 @@ paths:
|
|||
type: array
|
||||
items:
|
||||
$ref: '#/definitions/Account'
|
||||
'500':
|
||||
description: Internal Server Error, accounts could not be retrieved. Contains error message
|
||||
'401':
|
||||
description: 'Unauthorized'
|
||||
schema:
|
||||
$ref: '#/definitions/Error'
|
||||
post:
|
||||
|
@ -1201,13 +1323,22 @@ paths:
|
|||
schema:
|
||||
$ref: '#/definitions/Account'
|
||||
'400':
|
||||
description: Unprocessable Entry, the Account data provided has issues.
|
||||
description: 'Request is not JSON'
|
||||
schema:
|
||||
$ref: '#/definitions/Error'
|
||||
'401':
|
||||
description: 'Unauthorized'
|
||||
schema:
|
||||
$ref: '#/definitions/Error'
|
||||
'409':
|
||||
description: Duplicate Entry, the Name is already in use
|
||||
schema:
|
||||
$ref: '#/definitions/Error'
|
||||
'500':
|
||||
description: Internal Server Error. There was a problem creating the account
|
||||
schema:
|
||||
$ref: '#/definitions/Error'
|
||||
|
||||
'/pdnsadmin/accounts/{account_name}':
|
||||
parameters:
|
||||
- name: account_name
|
||||
|
@ -1227,14 +1358,15 @@ 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
|
||||
|
@ -1271,6 +1403,14 @@ 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:
|
||||
|
@ -1289,6 +1429,10 @@ 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:
|
||||
|
@ -1297,7 +1441,8 @@ paths:
|
|||
description: Internal Server Error. Contains error message
|
||||
schema:
|
||||
$ref: '#/definitions/Error'
|
||||
'/pdnsadmin/accounts/users/{account_id}':
|
||||
|
||||
'/pdnsadmin/accounts/{account_id}/users':
|
||||
parameters:
|
||||
- name: account_id
|
||||
type: integer
|
||||
|
@ -1314,20 +1459,52 @@ paths:
|
|||
- user
|
||||
responses:
|
||||
'200':
|
||||
description: List of User objects
|
||||
description: List of Summarized User objects
|
||||
schema:
|
||||
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'
|
||||
'500':
|
||||
description: Internal Server Error, accounts could not be retrieved. Contains error message
|
||||
|
||||
'/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'
|
||||
schema:
|
||||
$ref: '#/definitions/Error'
|
||||
'/pdnsadmin/accounts/users/{account_id}/{user_id}':
|
||||
'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
|
||||
type: integer
|
||||
|
@ -1350,6 +1527,14 @@ 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:
|
||||
|
@ -1369,6 +1554,10 @@ 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:
|
||||
|
@ -1378,6 +1567,68 @@ paths:
|
|||
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:
|
||||
$ref: '#/definitions/Error'
|
||||
'500':
|
||||
description: Internal Server Error. Contains error message
|
||||
schema:
|
||||
$ref: '#/definitions/Error'
|
||||
|
||||
definitions:
|
||||
Server:
|
||||
|
@ -1589,8 +1840,9 @@ definitions:
|
|||
|
||||
PDNSAdminZones:
|
||||
title: PDNSAdminZones
|
||||
description: A ApiKey that can be used to manage domains through API
|
||||
description: 'A list of domains'
|
||||
type: array
|
||||
x-omitempty: false
|
||||
items:
|
||||
properties:
|
||||
id:
|
||||
|
@ -1601,9 +1853,9 @@ definitions:
|
|||
type: string
|
||||
description: 'Name of the zone'
|
||||
|
||||
PDNSAdminApiKeyRole:
|
||||
title: PDNSAdminApiKeyRole
|
||||
description: Role of ApiKey, defines privileges on domains
|
||||
PDNSAdminRole:
|
||||
title: PDNSAdminRole
|
||||
description: Roles of PowerDNS Admin
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
|
@ -1611,11 +1863,11 @@ definitions:
|
|||
readOnly: true
|
||||
name:
|
||||
type: string
|
||||
description: 'Name of role'
|
||||
description: 'The Name of PDNSAdmin role'
|
||||
|
||||
ApiKey:
|
||||
title: ApiKey
|
||||
description: A ApiKey that can be used to manage domains through API
|
||||
description: 'An ApiKey that can be used to manage domains through API'
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
|
@ -1628,12 +1880,27 @@ definitions:
|
|||
type: string
|
||||
description: 'not used on POST, POSTing to server generates the key material'
|
||||
domains:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/definitions/PDNSAdminZones'
|
||||
$ref: '#/definitions/PDNSAdminZones'
|
||||
description: 'domains to which this apikey has access'
|
||||
role:
|
||||
$ref: '#/definitions/PDNSAdminApiKeyRole'
|
||||
$ref: '#/definitions/PDNSAdminRole'
|
||||
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'
|
||||
|
@ -1674,10 +1941,51 @@ definitions:
|
|||
type: boolean
|
||||
description: The confirmed status
|
||||
readOnly: false
|
||||
role_id:
|
||||
role:
|
||||
$ref: '#/definitions/PDNSAdminRole'
|
||||
|
||||
UserDetailed:
|
||||
title: User
|
||||
description: User that can access the gui/api
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
description: The ID of the role
|
||||
description: The ID for this user (unique)
|
||||
readOnly: true
|
||||
username:
|
||||
type: string
|
||||
description: The username for this user (unique, immutable)
|
||||
readOnly: false
|
||||
password:
|
||||
type: string
|
||||
description: The hashed password for this user
|
||||
readOnly: false
|
||||
firstname:
|
||||
type: string
|
||||
description: The firstname of this user
|
||||
readOnly: false
|
||||
lastname:
|
||||
type: string
|
||||
description: The lastname of this user
|
||||
readOnly: false
|
||||
email:
|
||||
type: string
|
||||
description: Email addres for this user
|
||||
readOnly: false
|
||||
otp_secret:
|
||||
type: string
|
||||
description: OTP secret
|
||||
readOnly: false
|
||||
confirmed:
|
||||
type: boolean
|
||||
description: The confirmed status
|
||||
readOnly: false
|
||||
role:
|
||||
$ref: '#/definitions/PDNSAdminRole'
|
||||
accounts:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/definitions/AccountSummary'
|
||||
|
||||
Account:
|
||||
title: Account
|
||||
|
@ -1703,6 +2011,28 @@ 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
|
||||
description: Summary of an Account that 'owns' zones
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
description: The ID for this account (unique)
|
||||
readOnly: true
|
||||
name:
|
||||
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
|
||||
|
|
|
@ -84,7 +84,7 @@
|
|||
<select multiple="multiple" class="form-control" id="account_multi_user"
|
||||
name="account_multi_user">
|
||||
{% for user in users %}
|
||||
<option {% if user.id in account_user_ids %}selected{% endif %}
|
||||
<option {% if user.id in account_user_ids|default([]) %}selected{% endif %}
|
||||
value="{{ user.username }}">{{ user.username }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
@ -162,4 +162,4 @@
|
|||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
{% 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 %}
|
||||
|
@ -49,24 +50,40 @@
|
|||
class="glyphicon glyphicon-pencil form-control-feedback"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box-header with-border">
|
||||
<h3 class="box-title">Access Control</h3>
|
||||
<div class="box-header with-border key-opts"{% if hide_opts %} style="display: none;"{% endif %}>
|
||||
<h3 class="box-title">Accounts Access Control</h3>
|
||||
</div>
|
||||
<div class="box-body">
|
||||
<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 %}>
|
||||
<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 domain in key.domains %}selected{% endif %} value="{{ domain.name }}">{{ domain.name }}</option>
|
||||
<option {% if key and 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">{% if create %}Create{% else %}Update{% endif %}
|
||||
class="btn btn-flat btn-primary" id="key_submit">{% if create %}Create{% else %}Update{% endif %}
|
||||
Key</button>
|
||||
</div>
|
||||
</form>
|
||||
|
@ -82,7 +99,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 which the key has access to.</p>
|
||||
<p><strong>Access Control</strong> The domains or accounts which the key has access to.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -91,6 +108,48 @@
|
|||
{% 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'>",
|
||||
|
@ -126,6 +185,41 @@
|
|||
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");
|
||||
|
@ -165,4 +259,25 @@
|
|||
</div>
|
||||
<!-- /.modal-dialog -->
|
||||
</div>
|
||||
{% endblock %}
|
||||
<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">×</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 %}
|
||||
|
|
|
@ -13,7 +13,12 @@
|
|||
<li class="active">History</li>
|
||||
</ol>
|
||||
</section>
|
||||
{% endblock %} {% block content %}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
{% import 'applied_change_macro.html' as applied_change_macro %}
|
||||
|
||||
|
||||
|
||||
<section class="content">
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
|
@ -28,32 +33,134 @@
|
|||
Clear History <i class="fa fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
<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 <i class="fa fa-info"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<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  </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:  </td>
|
||||
<td>  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>  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>  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>  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:  </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:  </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:  </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> </td></tr>
|
||||
<tr><td> </td></tr>
|
||||
<tr>
|
||||
<td>
|
||||
<button type="submit" id="search-submit" name="search-submit" class="btn btn-flat btn-primary button-filter">Search <i class="fa fa-search"></i></button>
|
||||
</td>
|
||||
<td>
|
||||
<!-- -->
|
||||
<button id="clear-filters" name="clear-filters" class="btn btn-flat btn-warning button-clearf">Clear Filters <i class="fa fa-trash"></i></button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="table_from_ajax"></div>
|
||||
|
||||
|
||||
<!-- /.box-body -->
|
||||
</div>
|
||||
<!-- /.box -->
|
||||
|
@ -65,31 +172,304 @@
|
|||
{% endblock %}
|
||||
{% block extrascripts %}
|
||||
<script>
|
||||
// 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');
|
||||
|
||||
/* 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);
|
||||
},
|
||||
"targets": 2
|
||||
}]
|
||||
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")
|
||||
|
||||
});
|
||||
});
|
||||
$(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');
|
||||
|
||||
$('.checkbox,.radio').iCheck({
|
||||
checkboxClass: 'icheckbox_square-blue',
|
||||
radioClass: 'iradio_square-blue',
|
||||
increaseArea: '20%'
|
||||
});
|
||||
|
||||
//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 %}
|
||||
|
@ -127,7 +507,7 @@
|
|||
<h4 class="modal-title">History Details</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<pre><code id="modal-code-content"></code></pre>
|
||||
<div id="modal-info-content"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-flat btn-default pull-right" data-dismiss="modal">Close</button>
|
||||
|
@ -138,4 +518,4 @@
|
|||
<!-- /.modal-dialog -->
|
||||
</div>
|
||||
<!-- /.modal -->
|
||||
{% endblock %}
|
||||
{% endblock %}
|
90
powerdnsadmin/templates/admin_history_table.html
Normal file
90
powerdnsadmin/templates/admin_history_table.html
Normal file
|
@ -0,0 +1,90 @@
|
|||
|
||||
{% 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 <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>
|
|
@ -35,6 +35,7 @@
|
|||
<th>Role</th>
|
||||
<th>Description</th>
|
||||
<th>Domains</th>
|
||||
<th>Accounts</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
@ -45,6 +46,7 @@
|
|||
<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) }}'">
|
||||
|
|
|
@ -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 class="active"><a href="#tabs-ldap" data-toggle="tab">LDAP</a></li>
|
||||
<li><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>
|
||||
|
@ -73,11 +73,19 @@
|
|||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-flat btn-primary">Save</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
<div class="tab-pane active" id="tabs-ldap">
|
||||
<div class="tab-pane" id="tabs-ldap">
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
{% if error %}
|
||||
<div class="alert alert-danger alert-dismissible">
|
||||
<button type="button" class="close" data-dismiss="alert" aria-hidden="true">×</button>
|
||||
<h4><i class="icon fa fa-ban"></i> Error!</h4>
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<form role="form" method="post" data-toggle="validator">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" value="ldap" name="config_tab" />
|
||||
|
@ -186,6 +194,46 @@
|
|||
<span class="help-block with-errors"></span>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>ADVANCE</legend>
|
||||
<div class="form-group">
|
||||
<label>Roles Autoprovisioning</label>
|
||||
<div class="radio">
|
||||
<label>
|
||||
<input type="radio" name="autoprovisioning" id="autoprovisioning_off" value="OFF" {% if not SETTING.get('autoprovisioning') %}checked{% endif %}> OFF
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<input type="radio" name="autoprovisioning" id="autoprovisioning_on" value="ON"
|
||||
|
||||
{% if SETTING.get('autoprovisioning') %}checked{% endif %}> ON
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="autoprovisioning_attribute">Roles provisioning field</label>
|
||||
<input type="text" class="form-control" name="autoprovisioning_attribute" id="autoprovisioning_attribute" placeholder="e.g. eduPersonEntitlement" data-error=" Please input field responsible for autoprovisioning" value="{{ SETTING.get('autoprovisioning_attribute') }}">
|
||||
<span class="help-block with-errors"></span>
|
||||
</div>
|
||||
|
||||
<div class="form-group {% if error %}has-error{% endif %}">
|
||||
<label for="urn_value">Urn prefix</label>
|
||||
<input type="text" class="form-control" name="urn_value" id="urn_value" placeholder="e.g. urn:mace:<yourOrganization>" data-error="Please fill this field" value="{{ SETTING.get('urn_value') }}">
|
||||
{% if error %}
|
||||
<span class="help-block with-errors">Please input the correct prefix for your urn value</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Purge Roles If Empty</label>
|
||||
<div class="radio">
|
||||
<label>
|
||||
<input type="radio" name="purge" id="purge_off" value="OFF" {% if not SETTING.get('purge') %}checked{% endif %}> OFF
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<input type="radio" name="purge" id="purge_on" value="ON" {% if SETTING.get('purge') %}checked{% endif %}> ON
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-flat btn-primary">Save</button>
|
||||
</div>
|
||||
|
@ -261,6 +309,24 @@
|
|||
</li>
|
||||
</ul>
|
||||
</dd>
|
||||
<dt>ADVANCE</dt>
|
||||
<dd> Provision PDA user privileges based on LDAP Object Attributes. Alternative to Group Security Role Management.
|
||||
<ul>
|
||||
<li>
|
||||
Roles Autoprovisioning - If toggled on, the PDA Role and the associations of users found in the local db, will be instantly updated from the LDAP server every time they log in.
|
||||
</li>
|
||||
<li>
|
||||
Roles provisioning field - The attribute in the ldap server populated by the urn values where PDA will look for a new Role and/or new associations to domains/accounts.
|
||||
</li>
|
||||
<li>
|
||||
Urn prefix - The prefix used before the static keyword "powerdns-admin" for your entitlements in the ldap server. Must comply with RFC no.8141.
|
||||
</li>
|
||||
<li>
|
||||
Purge Roles If Empty - If toggled on, ldap entries that have no valid "powerdns-admin" records to their autoprovisioning field, will lose all their associations with any domain or account, also reverting to a User in the process, despite their current role in the local db.<br> If toggled off, in the same scenario they get to keep their existing associations and their current Role.
|
||||
|
||||
</li>
|
||||
</ul>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -560,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_authorize_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_logout_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>
|
||||
|
@ -625,7 +691,7 @@
|
|||
{%- endassets %}
|
||||
|
||||
<script>
|
||||
|
||||
|
||||
$(function() {
|
||||
$('#tabs').tabs({
|
||||
// add url anchor tags
|
||||
|
@ -648,6 +714,11 @@
|
|||
checkboxClass : 'icheckbox_square-blue',
|
||||
increaseArea : '20%'
|
||||
})
|
||||
|
||||
$('#autoprovisioning').iCheck({
|
||||
checkboxClass : 'icheckbox_square-blue',
|
||||
increaseArea : '20%'
|
||||
})
|
||||
// END: General tab js
|
||||
|
||||
// START: LDAP tab js
|
||||
|
@ -679,7 +750,10 @@
|
|||
$('#ldap_operator_group').prop('required', true);
|
||||
$('#ldap_user_group').prop('required', true);
|
||||
}
|
||||
|
||||
if ($('#autoprovisioning').is(":checked")) {
|
||||
$('#autoprovisioning_attribute').prop('required', true);
|
||||
$('#urn_value').prop('required', true);
|
||||
}
|
||||
} else {
|
||||
$('#ldap_uri').prop('required', false);
|
||||
$('#ldap_base_dn').prop('required', false);
|
||||
|
@ -695,6 +769,10 @@
|
|||
$('#ldap_operator_group').prop('required', false);
|
||||
$('#ldap_user_group').prop('required', false);
|
||||
}
|
||||
if ($('#autoprovisioning').is(":checked")) {
|
||||
$('#autoprovisioning_attribute').prop('required', false);
|
||||
$('#urn_value').prop('required', true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -708,8 +786,75 @@
|
|||
$('#ldap_operator_group').prop('required', false);
|
||||
$('#ldap_user_group').prop('required', false);
|
||||
}
|
||||
|
||||
if ($('#ldap_sg_on').is(":checked") && $('#autoprovisioning_on').is(":checked")){
|
||||
document.getElementById('ldap_sg_on').checked=false;
|
||||
document.getElementById('ldap_sg_off').checked=true;
|
||||
var modal = $("#modal_warning");
|
||||
|
||||
var info = "Group Security:Status and Advance:Autoprovisioning can not be both enabled at the same time. Please turn off Advance:Autoprovisioning first" ;
|
||||
modal.find('.modal-body p').text(info);
|
||||
modal.find('#button_warning_confirm').click(function () {
|
||||
modal.modal('hide');
|
||||
})
|
||||
modal.find('#warning_X').click(function () {
|
||||
modal.modal('hide');
|
||||
})
|
||||
modal.modal('show');
|
||||
}
|
||||
});
|
||||
|
||||
$("input[name='autoprovisioning']" ).change(function(){
|
||||
if ($('#autoprovisioning_on').is(":checked") && $('#ldap_enabled').is(":checked")) {
|
||||
$('#autoprovisioning_attribute').prop('required', true);
|
||||
$('#urn_value').prop('required', true);
|
||||
$('#purge').prop('required', true);
|
||||
}
|
||||
else{
|
||||
$('#autoprovisioning_attribute').prop('required', false);
|
||||
$('#urn_value').prop('required', false);
|
||||
$('#purge').prop('required', false);
|
||||
}
|
||||
if ($('#ldap_sg_on').is(":checked") && $('#autoprovisioning_on').is(":checked")){
|
||||
document.getElementById('autoprovisioning_on').checked=false;
|
||||
document.getElementById('autoprovisioning_off').checked=true;
|
||||
var modal = $("#modal_warning");
|
||||
var info = "Group Security:Status and Advance:Autoprovisioning can not be both enabled at the same time. Please turn off Group Security:Status first" ;
|
||||
modal.find('.modal-body p').text(info);
|
||||
modal.find('#button_warning_confirm').click(function () {
|
||||
|
||||
modal.modal('hide');
|
||||
})
|
||||
modal.find('#warning_X').click(function () {
|
||||
modal.modal('hide');
|
||||
})
|
||||
modal.modal('show');
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
$("input[name='purge']" ).change(function(){
|
||||
if ($("#purge_on").is(":checked")){
|
||||
document.getElementById('purge_on').checked=false;
|
||||
document.getElementById('purge_off').checked=true;
|
||||
var modal = $("#modal_confirm");
|
||||
var info = "Are you sure you want to do this? Users will lose their associated domains unless they already have their autoprovisioning field prepopulated." ;
|
||||
modal.find('.modal-body p').text(info);
|
||||
modal.find('#button_confirm').click(function () {
|
||||
document.getElementById('purge_on').checked=true;
|
||||
document.getElementById('purge_off').checked=false;
|
||||
modal.modal('hide');
|
||||
})
|
||||
modal.find('#button_cancel').click(function () {
|
||||
modal.modal('hide');
|
||||
})
|
||||
modal.find('#X').click(function () {
|
||||
modal.modal('hide');
|
||||
})
|
||||
modal.modal('show');
|
||||
}
|
||||
});
|
||||
|
||||
$("input[name='ldap_type']" ).change(function(){
|
||||
if ($('#ldap').is(":checked") && $('#ldap_enabled').is(":checked")) {
|
||||
$('#ldap_admin_group').prop('required', true);
|
||||
|
@ -747,7 +892,14 @@
|
|||
$('#ldap_operator_group').prop('required', true);
|
||||
$('#ldap_user_group').prop('required', true);
|
||||
}
|
||||
|
||||
if ($('#autoprovisioning_on').is(":checked")) {
|
||||
$('#autoprovisioning_attribute').prop('required', true);
|
||||
$('#urn_value').prop('required', true);
|
||||
}
|
||||
|
||||
{% endif %}
|
||||
|
||||
// END: LDAP tab js
|
||||
|
||||
// START: Google tab js
|
||||
|
@ -900,3 +1052,51 @@
|
|||
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block modals %}
|
||||
<div class="modal fade modal-warning" id="modal_confirm" data-keyboard="false" data-backdrop="static">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close" id="X" >
|
||||
<span aria-hidden="true">×</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_cancel" name="purge" value="OFF" data-dismiss="modal" >Cancel</button>
|
||||
<button type="button" class="btn btn-flat btn-success" id="button_confirm">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- /.modal-content -->
|
||||
</div>
|
||||
<!-- /.modal-dialog -->
|
||||
</div>
|
||||
|
||||
|
||||
<div class="modal fade modal-warning" id="modal_warning" data-keyboard="false" data-backdrop="static">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close" id="warning_X" >
|
||||
<span aria-hidden="true">×</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-success" id="button_warning_confirm">Yes I understand</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- /.modal-content -->
|
||||
</div>
|
||||
<!-- /.modal-dialog -->
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
|
133
powerdnsadmin/templates/applied_change_macro.html
Normal file
133
powerdnsadmin/templates/applied_change_macro.html
Normal file
|
@ -0,0 +1,133 @@
|
|||
{% 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 %}
|
|
@ -6,6 +6,7 @@
|
|||
<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') }}">
|
||||
|
@ -16,9 +17,14 @@
|
|||
{% endif %}
|
||||
<!-- 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">
|
||||
<!-- Tell Safari to not recognise telephone numbers -->
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
{% 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]>
|
||||
|
@ -112,6 +118,11 @@
|
|||
<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 '' }}">
|
||||
|
@ -151,6 +162,11 @@
|
|||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
{% elif SETTING.get('allow_user_view_history') %}
|
||||
<li class="header">ADMINISTRATION</li>
|
||||
<li class="{{ 'active' if active_page == 'admin_history' else '' }}">
|
||||
<a href="{{ url_for('admin.history') }}"><i class="fa fa-calendar"></i> <span>History</span></a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
{% set active_page = "dashboard" %}
|
||||
{% block title %}<title>Dashboard - {{ SITE_NAME }}</title>{% endblock %}
|
||||
|
||||
|
||||
{% block dashboard_stat %}
|
||||
<!-- Content Header (Page header) -->
|
||||
<section class="content-header">
|
||||
|
@ -16,10 +17,12 @@
|
|||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% import 'applied_change_macro.html' as applied_change_macro %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Main content -->
|
||||
<section class="content">
|
||||
{% if current_user.role.name in ['Administrator', 'Operator'] %}
|
||||
{% if current_user.role.name in ['Administrator', 'Operator'] or SETTING.get('allow_user_view_history') %}
|
||||
<div class="row">
|
||||
<div class="col-xs-3">
|
||||
<div class="box">
|
||||
|
@ -40,6 +43,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if current_user.role.name in ['Administrator', 'Operator'] %}
|
||||
<div class="col-lg-6">
|
||||
<a href="{{ url_for('admin.manage_user') }}">
|
||||
<div class="small-box bg-green">
|
||||
|
@ -53,6 +57,7 @@
|
|||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
|
@ -68,6 +73,7 @@
|
|||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{% if current_user.role.name in ['Administrator', 'Operator'] %}
|
||||
<div class="col-lg-6">
|
||||
<a href="{{ url_for('admin.pdns_stats') }}">
|
||||
<div class="small-box bg-green">
|
||||
|
@ -81,6 +87,7 @@
|
|||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -103,13 +110,25 @@
|
|||
<tbody>
|
||||
{% for history in histories %}
|
||||
<tr class="odd">
|
||||
<td>{{ history.created_by }}</td>
|
||||
<td>{{ history.msg }}</td>
|
||||
<td>{{ history.created_on }}</td>
|
||||
<td>{{ history.history.created_by }}</td>
|
||||
<td>{{ history.history.msg }}</td>
|
||||
<td>{{ history.history.created_on }}</td>
|
||||
<td width="6%">
|
||||
<button type="button" class="btn btn-flat btn-primary history-info-button" value='{{ history.detail }}'>
|
||||
Info <i class="fa fa-info"></i>
|
||||
</button>
|
||||
<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 <i class="fa fa-info"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
@ -222,11 +241,11 @@
|
|||
]
|
||||
});
|
||||
|
||||
$(document.body).on('click', '.history-info-button', function()
|
||||
{
|
||||
$(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));
|
||||
var history_id = $(this).val();
|
||||
var info = $("#history-info-div-" + history_id).html();
|
||||
$('#modal-info-content').html(info);
|
||||
modal.modal('show');
|
||||
});
|
||||
|
||||
|
@ -293,7 +312,7 @@
|
|||
<h4 class="modal-title">History Details</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<pre><code id="modal-code-content"></code></pre>
|
||||
<div id="modal-info-content"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-flat btn-default pull-right"
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{% macro name(domain) %}
|
||||
<a href="{{ url_for('domain.domain', domain_name=domain.name) }}"><strong>{{ domain.name }}</strong></a>
|
||||
<a href="{{ url_for('domain.domain', domain_name=domain.name) }}"><strong>{{ domain.name | pretty_domain_name }}</strong></a>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro dnssec(domain) %}
|
||||
|
@ -15,11 +15,11 @@
|
|||
{% 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) %}
|
||||
{% if domain.master == '[]'%}-{% else %}{{ domain.master|display_master_name }}{% endif %}
|
||||
{% if domain.master == '[]'%}-{% else %}{{ domain.master | display_master_name }}{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro account(domain) %}
|
||||
|
@ -40,12 +40,20 @@
|
|||
<button type="button" class="btn btn-flat btn-danger" onclick="window.location.href='{{ url_for('domain.setting', domain_name=domain.name) }}'">
|
||||
Admin <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 <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 <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 <i class="fa fa-history" aria-hidden="true"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
|
62
powerdnsadmin/templates/domain.html
Normal file → Executable file
62
powerdnsadmin/templates/domain.html
Normal file → Executable file
|
@ -1,16 +1,16 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}<title>{{ domain.name }} - {{ SITE_NAME }}</title>{% endblock %}
|
||||
{% block title %}<title>{{ domain.name | pretty_domain_name }} - {{ SITE_NAME }}</title>{% endblock %}
|
||||
|
||||
{% block dashboard_stat %}
|
||||
<section class="content-header">
|
||||
<h1>
|
||||
Manage domain: <b>{{ domain.name }}</b>
|
||||
Manage domain: <b>{{ domain.name | pretty_domain_name }}</b>
|
||||
</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 }}</li>
|
||||
<li class="active">{{ domain.name | pretty_domain_name }}</li>
|
||||
</ol>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
@ -33,6 +33,17 @@
|
|||
Update from Master <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 <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 <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">
|
||||
|
@ -46,13 +57,16 @@
|
|||
<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>
|
||||
{% for record in records %}
|
||||
<tr class="odd row_record" id="{{ domain.name }}">
|
||||
<td>
|
||||
{{ (record.name,domain.name)|display_record_name }}
|
||||
{{ (record.name,domain.name) | display_record_name | pretty_domain_name }}
|
||||
</td>
|
||||
<td>
|
||||
{{ record.type }}
|
||||
|
@ -64,7 +78,7 @@
|
|||
{{ record.ttl }}
|
||||
</td>
|
||||
<td>
|
||||
{{ record.data }}
|
||||
{{ record.data | pretty_domain_name }}
|
||||
</td>
|
||||
<td>
|
||||
{{ record.comment }}
|
||||
|
@ -91,6 +105,13 @@
|
|||
</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">
|
||||
<i class="fa fa-history" aria-hidden="true"></i>
|
||||
</button>
|
||||
</td>
|
||||
{% endif %}
|
||||
<!-- hidden column that we can sort on -->
|
||||
<td>1</td>
|
||||
</tr>
|
||||
|
@ -110,8 +131,8 @@
|
|||
{% block extrascripts %}
|
||||
<script>
|
||||
// superglobals
|
||||
window.records_allow_edit = {{ editable_records|tojson }};
|
||||
window.ttl_options = {{ ttl_options|tojson }};
|
||||
window.records_allow_edit = {{ editable_records | tojson }};
|
||||
window.ttl_options = {{ ttl_options | tojson }};
|
||||
window.nEditing = null;
|
||||
window.nNew = false;
|
||||
|
||||
|
@ -123,7 +144,7 @@
|
|||
"ordering" : true,
|
||||
"info" : true,
|
||||
"autoWidth" : false,
|
||||
{% if SETTING.get('default_record_table_size')|string in ['5','15','20'] %}
|
||||
{% if SETTING.get('default_record_table_size') | string in ['5','15','20'] %}
|
||||
"lengthMenu": [ [5, 15, 20, -1],
|
||||
[5, 15, 20, "All"]],
|
||||
{% else %}
|
||||
|
@ -144,14 +165,33 @@
|
|||
// 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
|
||||
|
@ -243,7 +283,11 @@
|
|||
|
||||
// add new row
|
||||
var default_type = records_allow_edit[0]
|
||||
var nRow = jQuery('#tbl_records').dataTable().fnAddData(['', default_type, 'Active', window.ttl_options[0][0], '', '', '', '', '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 %}
|
||||
editRow($("#tbl_records").DataTable(), nRow);
|
||||
document.getElementById("edit-row-focus").focus();
|
||||
nEditing = nRow;
|
||||
|
|
116
powerdnsadmin/templates/domain_changelog.html
Normal file
116
powerdnsadmin/templates/domain_changelog.html
Normal file
|
@ -0,0 +1,116 @@
|
|||
{% 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}}   {{ 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 <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 %}
|
129
powerdnsadmin/templates/domain_remove.html
Normal file
129
powerdnsadmin/templates/domain_remove.html
Normal file
|
@ -0,0 +1,129 @@
|
|||
{% 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">×</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 %}
|
|
@ -19,7 +19,7 @@
|
|||
{% endif %}
|
||||
<section class="content-header">
|
||||
<h1>
|
||||
Manage domain <small>{{ domain.name }}</small>
|
||||
Manage domain <small>{{ domain.name | pretty_domain_name }}</small>
|
||||
</h1>
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="{{ url_for('dashboard.dashboard') }}"><i class="fa fa-dashboard"></i> Home</a></li>
|
||||
|
@ -42,7 +42,7 @@
|
|||
<div class="row">
|
||||
<div class="col-xs-2">
|
||||
<p>Users on the right have access to manage the records in
|
||||
the {{ domain.name }} domain.</p>
|
||||
the {{ domain.name | pretty_domain_name }} domain.</p>
|
||||
<p>Click on users to move from between columns.</p>
|
||||
<p>
|
||||
Users in <font style="color: red;">red</font> are Administrators
|
||||
|
@ -94,7 +94,7 @@
|
|||
{% endfor %}
|
||||
</select><br />
|
||||
<button type="submit" class="btn btn-flat btn-primary" id="change_soa_edit_api">
|
||||
<i class="fa fa-check"></i> Change account for {{ domain.name }}
|
||||
<i class="fa fa-check"></i> Change account for {{ domain.name | pretty_domain_name }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
@ -173,7 +173,7 @@
|
|||
placeholder="Enter valid master ip addresses (separated by commas)">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-flat btn-primary" id="change_type">
|
||||
<i class="fa fa-check"></i> Change type for {{ domain.name }}
|
||||
<i class="fa fa-check"></i> Change type for {{ domain.name | pretty_domain_name }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
@ -216,7 +216,7 @@
|
|||
<option>OFF</option>
|
||||
</select><br />
|
||||
<button type="submit" class="btn btn-flat btn-primary" id="change_soa_edit_api">
|
||||
<i class="fa fa-check"></i> Change SOA-EDIT-API setting for {{ domain.name }}
|
||||
<i class="fa fa-check"></i> Change SOA-EDIT-API setting for {{ domain.name | pretty_domain_name }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
@ -235,7 +235,7 @@
|
|||
reverted.</p>
|
||||
<button type="button" class="btn btn-flat btn-danger pull-left delete_domain"
|
||||
id="{{ domain.name }}">
|
||||
<i class="fa fa-trash"></i> DELETE DOMAIN {{ domain.name }}
|
||||
<i class="fa fa-trash"></i> DELETE DOMAIN {{ domain.name | pretty_domain_name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -11,7 +11,9 @@
|
|||
{% 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]>
|
||||
|
@ -46,9 +48,11 @@
|
|||
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">
|
||||
<input type="otptoken" class="form-control" placeholder="OTP Token" name="otptoken" autocomplete="off">
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if SETTING.get('ldap_enabled') and SETTING.get('local_db_enabled') %}
|
||||
<div class="form-group">
|
||||
<select class="form-control" name="auth_method">
|
||||
|
|
90
powerdnsadmin/templates/register_otp.html
Executable file
90
powerdnsadmin/templates/register_otp.html
Executable file
|
@ -0,0 +1,90 @@
|
|||
<!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">×</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>
|
|
@ -51,7 +51,7 @@
|
|||
{% if session['authentication_type'] != 'LOCAL' %}disabled{% endif %}>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="email">E-mail</label> <input type="text" class="form-control"
|
||||
<label for="email">E-mail</label> <input type="email" 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,6 +93,14 @@
|
|||
{% 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"
|
||||
|
@ -103,8 +111,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
|
||||
nobody can capture it.</i></strong></font>
|
||||
<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>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
|
@ -8,7 +8,7 @@ mysqlclient==2.0.1
|
|||
configobj==5.0.6
|
||||
bcrypt>=3.1.7
|
||||
requests==2.24.0
|
||||
python-ldap==3.3.1
|
||||
python-ldap==3.4.0
|
||||
pyotp==2.4.0
|
||||
qrcode==6.1
|
||||
dnspython>=1.16.0
|
||||
|
@ -17,13 +17,14 @@ python3-saml
|
|||
pyOpenSSL==19.1.0
|
||||
pytz==2020.1
|
||||
cssmin==0.2.0
|
||||
jsmin==2.2.2
|
||||
jsmin==3.0.0
|
||||
Authlib==0.15
|
||||
Flask-SeaSurf==0.2.2
|
||||
bravado-core==5.17.0
|
||||
lima==0.5
|
||||
pytest==6.1.1
|
||||
pytimeparse==1.1.8
|
||||
PyYAML==5.3.1
|
||||
PyYAML==5.4
|
||||
Flask-SSLify==0.1.5
|
||||
Flask-Mail==0.9.1
|
||||
flask-session==0.3.2
|
||||
|
|
34
yarn.lock
34
yarn.lock
|
@ -102,10 +102,10 @@ base64-js@^1.0.2:
|
|||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.0.tgz#cab1e6118f051095e58b5281aea8c1cd22bfc0e3"
|
||||
|
||||
bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0:
|
||||
version "4.11.9"
|
||||
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.9.tgz#26d556829458f9d1e81fc48952493d0ba3507828"
|
||||
integrity sha512-E6QoYqCKZfgatHTdHzs1RRKP7ip4vvm+EyRUeE2RF0NblwVvb0p6jSVeNTOFxPn26QXN2o6SMfNxKp6kU8zQaw==
|
||||
bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.11.9:
|
||||
version "4.12.0"
|
||||
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.0.tgz#775b3f278efbb9718eec7361f483fb36fbbfea88"
|
||||
integrity sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==
|
||||
|
||||
bootstrap-colorpicker@^2.5.3:
|
||||
version "2.5.3"
|
||||
|
@ -152,7 +152,7 @@ brace-expansion@^1.1.7:
|
|||
balanced-match "^1.0.0"
|
||||
concat-map "0.0.1"
|
||||
|
||||
brorand@^1.0.1:
|
||||
brorand@^1.0.1, brorand@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f"
|
||||
integrity sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=
|
||||
|
@ -490,17 +490,17 @@ duplexer2@^0.1.2, duplexer2@~0.1.0, duplexer2@~0.1.2:
|
|||
readable-stream "^2.0.2"
|
||||
|
||||
elliptic@^6.0.0:
|
||||
version "6.5.3"
|
||||
resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.3.tgz#cb59eb2efdaf73a0bd78ccd7015a62ad6e0f93d6"
|
||||
integrity sha512-IMqzv5wNQf+E6aHeIqATs0tOLeOTwj1QKbRcS3jBbYkl5oLAserA8yJTT7/VyHUYG91PRmPyeQDObKLPpeS4dw==
|
||||
version "6.5.4"
|
||||
resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.4.tgz#da37cebd31e79a1367e941b592ed1fbebd58abbb"
|
||||
integrity sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==
|
||||
dependencies:
|
||||
bn.js "^4.4.0"
|
||||
brorand "^1.0.1"
|
||||
bn.js "^4.11.9"
|
||||
brorand "^1.1.0"
|
||||
hash.js "^1.0.0"
|
||||
hmac-drbg "^1.0.0"
|
||||
inherits "^2.0.1"
|
||||
minimalistic-assert "^1.0.0"
|
||||
minimalistic-crypto-utils "^1.0.0"
|
||||
hmac-drbg "^1.0.1"
|
||||
inherits "^2.0.4"
|
||||
minimalistic-assert "^1.0.1"
|
||||
minimalistic-crypto-utils "^1.0.1"
|
||||
|
||||
eve-raphael@0.5.0:
|
||||
version "0.5.0"
|
||||
|
@ -575,7 +575,7 @@ hash.js@^1.0.0, hash.js@^1.0.3:
|
|||
inherits "^2.0.3"
|
||||
minimalistic-assert "^1.0.1"
|
||||
|
||||
hmac-drbg@^1.0.0:
|
||||
hmac-drbg@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1"
|
||||
integrity sha1-0nRXAQJabHdabFRXk+1QL8DGSaE=
|
||||
|
@ -607,7 +607,7 @@ inflight@^1.0.4:
|
|||
once "^1.3.0"
|
||||
wrappy "1"
|
||||
|
||||
inherits@2, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.1, inherits@~2.0.3:
|
||||
inherits@2, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.1, inherits@~2.0.3:
|
||||
version "2.0.4"
|
||||
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
|
||||
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
|
||||
|
@ -768,7 +768,7 @@ minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1:
|
|||
resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7"
|
||||
integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==
|
||||
|
||||
minimalistic-crypto-utils@^1.0.0, minimalistic-crypto-utils@^1.0.1:
|
||||
minimalistic-crypto-utils@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a"
|
||||
integrity sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=
|
||||
|
|
Loading…
Reference in a new issue