Compare commits

...

105 commits

Author SHA1 Message Date
dapillc cd94b5c0ac
Update API.md (#1100)
armless > harmless
2022-01-19 17:49:30 +02:00
zoeller-freinet 0b2ad520b7 History table: relocate HTML for modal window (#1090)
- Store HTML for modal window inside an invisible <div> element instead
  of inside the <button> element's value attribute
- Mark history.detailed_msg as safe as it is already manually run
  through the template engine beforehand and would be broken if escaped
  a second time
2022-01-01 21:20:01 +01:00
Christian 302e793665
Add button for admin page in single Domain view (#1076)
* Added button for admin page in domain overview
2021-12-31 00:55:59 +01:00
RGanor 328780e2d4 Revert "Merge branch 'master' into master"
This reverts commit ca4c145a18, reversing
changes made to 7808febad8.
2021-12-25 16:17:54 +02:00
RGanor ca4c145a18
Merge branch 'master' into master 2021-12-25 16:10:18 +02:00
zoeller-freinet 7808febad8 login.html: don't suggest previous OTP tokens
This change has been tested to work with:
- Chromium 96.0.4664.93
- Firefox 95.0
- Edge 96.0.1054.57
2021-12-17 12:48:11 +01:00
dependabot[bot] 9ef0f2b8d6 Bump python-ldap from 3.3.1 to 3.4.0
Bumps [python-ldap](https://github.com/python-ldap/python-ldap) from 3.3.1 to 3.4.0.
- [Release notes](https://github.com/python-ldap/python-ldap/releases)
- [Commits](https://github.com/python-ldap/python-ldap/compare/python-ldap-3.3.1...python-ldap-3.4.0)

---
updated-dependencies:
- dependency-name: python-ldap
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-12-17 12:08:19 +01:00
Vasileios Markopoulos 94a923a965
Add 'otp_force' basic setting (#1051)
If the 'otp_force' and 'otp_field_enabled' basic settings are both enabled, automatically enable 2FA for the user after login or signup, if needed, by setting a new OTP secret. Redirect the user to a welcome page for scanning the QR code.

Also show the secret key in ASCII form on the user profile page for easier copying into other applications.
2021-12-17 11:41:51 +01:00
Jérôme BECOT 0da9b2185e fix: Error in the swagger AccountSummary definition 2021-12-08 23:11:13 +01:00
zoeller-freinet 07f0d215a7 PDNS-API: factor in 'dnssec_admins_only' basic setting (#1055)
`GET cryptokeys/{cryptokey_id}` returns the private key, which justifies
that the setting is honored in this case.
2021-12-06 22:38:16 +01:00
Khanh Ngo fc8367535b
chore: remove funding and sponsor badges (#1073) 2021-12-08 17:44:44 +01:00
Jérôme BECOT d2f35a4059 fix: Check user zone create/delete permission
Co-authored-by: zoeller-freinet <86965592+zoeller-freinet@users.noreply.github.com>
2021-12-05 14:16:45 +01:00
zoeller-freinet 737e1fb93b routes/admin.py: DetailedHistory: backward-compatibility
See https://github.com/ngoduykhanh/PowerDNS-Admin/pull/1066
2021-12-04 17:38:48 +01:00
zoeller-freinet f0008ce401 routes/admin.py: refactor DetailedHistory
- Run HTML through the template engine, preventing XSS from various
  vectors
- Fix uncaught exception when a history entry about domain template
  deletion is processed
- Adapt indentation to 4 space characters per level
2021-12-04 16:09:53 +01:00
Dominic Zöller 6f12b783a8 models.user: get_accounts(): order by name
The order of account names returned by User.get_accounts() affects the
order account names are displyed in on /domain/add if the current user
neither has the Administrator role nor the Operator role and the
`allow_user_create_domain` setting is enabled at the same time.

If the current user does have the Administrator or Operator role,
routes.domain.add() already returns accounts ordered by name, so this
change makes it consistent.
2021-12-04 16:09:15 +01:00
Dominic Zöller 51a7f636b0 Use secrets module for generating new API keys and passwords
The implementation of `random.choice()` uses the Mersenne Twister, the
output of which is predictable by observing previous output, and is as
such unsuitable for security-sensitive applications. A cryptographically
secure pseudorandom number generator - which the `secrets` module relies
on - should be used instead in those instances.
2021-12-04 16:08:07 +01:00
ManosKoukoularis 9f46188c7e
Quotes fix (#1066)
* minor fix in history
* made key access more generic
2021-12-03 20:14:14 +02:00
root caa48b7fe5 Merge branch 'quotes-fix'
Conflicts:
	powerdnsadmin/routes/admin.py
2021-12-03 14:17:39 +00:00
root 591055d4aa Merge branch 'master' of https://github.com/ngoduykhanh/PowerDNS-Admin 2021-12-03 14:12:32 +00:00
root 940551e99e feat: Associate an API Key with accounts (#1044) 2021-12-03 14:12:11 +00:00
jbe-dw f45ff2ce03
feat: Associate an API Key with accounts (#1044) 2021-12-03 15:35:15 +02:00
ManosKoukoularis 6c1dfd2408
Datepicker replace (#1059)
* replaced jquery-ui-datepicker with bootstrap-datepicker

* removed obsolete static files
2021-12-02 11:59:36 +01:00
Dominic Zöller 701a442d12 default config: add exemplary URL encoding step for SQLA DB URL params
SQLAlchemy database URLs follow RFC-1738, so parameters like username
and password need to be encoded accordingly.

https://docs.sqlalchemy.org/en/13/core/engines.html#database-urls
2021-11-30 22:29:00 +01:00
Nick Bouwhuis a3b70a8f47
Add Keycloak documentation (#1053) 2021-11-30 12:26:58 +02:00
ManosKoukoularis 1332c8d29d
History Tab Overhaul & Domain Record Modifications Changelog (#1042)
Co-authored-by: Konstantinos Kouris <85997752+konkourgr@users.noreply.github.com>
Co-authored-by: vmarkop <billy.mark.b.m.10@gmail.com>
Co-authored-by: KostasMparmparousis <mparmparousis.kostas@gmail.com>
Co-authored-by: dimpapac <demispapa@gmail.com>
2021-11-30 11:02:37 +02:00
benshalev849 b3f9b4a2b0
OIDC list accounts (#994)
Added the function to use lists instead of a single string in account autoprovision.
2021-11-19 17:53:17 +02:00
zoeller-freinet bfaf5655ae
Clarify salt re-use for API keys (#1037) 2021-11-09 22:09:15 +02:00
Khanh Ngo dd04a837bb
Update docker image build script 2021-11-06 15:44:20 +01:00
Khanh Ngo 5bb1a7ee29
Update docker image build script 2021-11-06 15:37:13 +01:00
Khanh Ngo c85a5dac24
Update docker image build script 2021-11-06 15:25:20 +01:00
benshalev849 3081036c2c
Env oauth url (#1030)
Overriding settings in DB using environment variable in docker
2021-11-05 18:22:38 +02:00
Daniel Molkentin c7b4aa3434
fix: actually store OIDC logout URL (#988) 2021-11-05 17:28:21 +02:00
Vitali Quiering e7d5a3aba0
feat: enable_api_rr_history setting (#998)
* feat: introduce enable_api_rr_history setting to disable api record
changes
2021-11-05 17:26:38 +02:00
zoeller-freinet 20b866a784
strip() whitespace from new local user master data (#1019)
When creating a new local user, there is a chance that, due to a copy &
paste or typing error, whitespace will be introduced at the start or end
of the username. This can lead to issues when trying to log in using the
affected username, as such a condition can easily be overlooked - no
user will be found in the database if entering the username without the
aforementioned whitespace. This commit therefore strip()s the username
string within routes/{admin,index}.py.

The firstname, lastname and email strings within
routes/{admin,index,user}.py are also strip()ped on this occasion.
2021-11-05 17:04:35 +02:00
Khanh Ngo 1662a812ba Update CI
Signed-off-by: Khanh Ngo <khanh.ngo@taxfix.de>
2021-10-31 14:34:35 +01:00
Khanh Ngo c49df09ac8 Update CI
Signed-off-by: Khanh Ngo <khanh.ngo@taxfix.de>
2021-10-31 14:31:14 +01:00
Khanh Ngo 924537b468 Update CI
Signed-off-by: Khanh Ngo <khanh.ngo@taxfix.de>
2021-10-31 14:25:22 +01:00
Khanh Ngo 4f8a547d47 Update CI
Signed-off-by: Khanh Ngo <khanh.ngo@taxfix.de>
2021-10-31 14:23:49 +01:00
Khanh Ngo ee9f568a8d
Update README.md 2021-10-31 13:16:42 +01:00
Khanh Ngo d7ae34ed53 Update CI
Signed-off-by: Khanh Ngo <khanh.ngo@taxfix.de>
2021-10-31 13:08:22 +01:00
jbe-dw 1c9ca60508
fix: jsmin 2.2.2 no longer available. Use 3.0.0 (#1021) 2021-10-30 21:30:53 +02:00
zoeller-freinet 0e655c1357
user_profile tpl: set email input type attr to "email" (#1020)
It is then consistent with the email address input elements declared in
admin_edit_account.html, admin_edit_user.html and register.html.
2021-10-30 21:30:26 +02:00
steschuser ba2423d6f5
fix if condition in pretty_domain_name (#1008) 2021-10-30 21:29:55 +02:00
Andreas Dirnberger 46e51f16cb
Remove unnecessary build step (#1003)
The builder image does not need to cleanup itself, 
the whole purpose of it is to be dropped after the final artifacts are copied out.
2021-10-30 21:29:23 +02:00
jbe-dw b8ee91ab9a
fix: Accounts API is broken (#996) 2021-10-30 21:28:36 +02:00
RGanor c246775ffe
bg_domain button for operators and higher (#993) 2021-10-30 21:26:46 +02:00
Hidde f96103db79
Replace [ZONE] placeholder with domain_name (#960) 2021-10-30 21:24:16 +02:00
steschuser bf83662108
allow users to remove domain (#952) 2021-10-30 21:21:45 +02:00
steschuser 1f34dbf810
fix for api key (#950) 2021-10-30 21:19:49 +02:00
Khanh Ngo b7197948c1 Reslove conflicts
Signed-off-by: Khanh Ngo <khanh.ngo@taxfix.de>
2021-10-30 21:19:01 +02:00
Khanh Ngo ddf2d4788b Reslove conflicts
Signed-off-by: Khanh Ngo <khanh.ngo@taxfix.de>
2021-10-30 21:15:04 +02:00
steschuser 1ec6b76f89
Remove otp field (#942) 2021-10-30 21:09:04 +02:00
Mark Zealey 4ce1b71c57
Fix when no records returned by API (#923)
For some reason when some programs delete a record we get an entry returned with records: []
2021-10-30 21:07:42 +02:00
steschuser 79457bdc85
Bug domain parse (#936) 2021-10-30 21:06:44 +02:00
RoeiGanor 10dc2b0273 bg_domain button for operators and higher 2021-08-13 20:03:06 +03:00
steschuser 993e02b635
limit user to only create domains for the accounts he belongs to (#970) 2021-08-05 19:42:58 +02:00
steschuser 07c71fb0bf
setting account_user_ids to empty list on GET /account/edit (#966) 2021-08-05 19:41:28 +02:00
steschuser c4a9498898
respect_bg_domain_updates in routes/api (#962) 2021-08-05 19:39:26 +02:00
Kostas Mparmparousis 6e04d0419b
Provision PDA user privileges based On LDAP Attributes (#980) 2021-08-05 19:37:48 +02:00
Carsten Rosenberg d6e64dce8e fix some jinja typos 2021-06-04 15:24:49 +02:00
Steffen Schwebel b069cea8d1 add css to base as well 2021-06-02 09:44:15 +02:00
Steffen Schwebel fd933f8dbc remove unrelated files and changes as best as possible 2021-06-02 09:41:08 +02:00
Steffen Schwebel 0505b934a1 remove unrelated files and changes as best as possible 2021-06-02 09:39:39 +02:00
Steffen Schwebel 083a023e57 fix include 2021-06-01 16:41:26 +02:00
Steffen Schwebel 054e0e6eba add rule for 'custom_css' setting 2021-06-01 16:24:07 +02:00
Steffen Schwebel c13dd2d835 add 'custom_css' setting to model; check for 'custom_css' in template; create custom css dir in dockerfile 2021-06-01 16:15:31 +02:00
steschuser 567f66fbde
Merge pull request #4 from uvensys/remove_otp_field
Remove otp field
2021-06-01 15:28:41 +02:00
steschuser ff5270fbad
Merge pull request #3 from uvensys/add_background_jobs_to_docker
add environment to cron
2021-06-01 15:21:22 +02:00
Steffen Schwebel 92bad7b11c add environment to cron 2021-06-01 14:02:01 +02:00
Steffen Schwebel 43a6e46e66 add setting to hide otp_token field on login page 2021-05-27 22:51:07 +02:00
Steffen Schwebel ee72fdf9c2 Merge branch 'master' of github.com:uvensys/PowerDNS-Admin into remove_otp_field 2021-05-27 21:56:01 +02:00
steschuser 8f73512d2e
Merge pull request #2 from uvensys/add_background_jobs_to_docker
Add background jobs to docker
2021-05-27 21:33:27 +02:00
Steffen Schwebel 700fa0d9ce add new dockerfile with s6 overlay and multiple proccesses to have background jobs updating accounts and zones 2021-05-27 21:32:00 +02:00
Steffen Schwebel 00dc23f21b added new Dockerfile, to support more than one process running in docker, using s6 overlay 2021-05-27 16:39:51 +02:00
Steffen Schwebel 36fdb3733f Merge remote-tracking branch 'origin/master' into remove_otp_field 2021-05-25 15:30:32 +02:00
steschuser ce60ca0b9d
Merge pull request #1 from uvensys/bug_domain_parse
Bug domain parse
2021-05-25 12:53:57 +02:00
Steffen Schwebel b197491a86 remove traceback 2021-05-25 12:44:07 +02:00
Steffen Schwebel d23a57da50 handle decode error, output warning 2021-05-25 12:35:53 +02:00
Steffen Schwebel 4180882fb7 show traceback 2021-05-21 15:10:17 +02:00
root bbbcf271fe remove otp token from login page, depending on Setting 2021-05-20 15:21:56 +02:00
jyoung15 32983635c6
Delete blank comments. Fix for ngoduykhanh/PowerDNS-Admin#919 (#920) 2021-05-07 23:43:44 +02:00
Jay Linski f3a98eb692
Emphasize importance of using a custom SECRET_KEY (#931)
This project provides a default SECRET_KEY for signing session-cookies:
https://flask.palletsprojects.com/en/1.1.x/config/#SECRET_KEY

By using the default SECRET_KEY, everyone will be able to create valid session-cookies.
So users should be aware that it is very important to set a custom SECRET_KEY.
2021-05-07 23:40:54 +02:00
Ian Bobbitt 39cddd3b34
SAML improvements for Docker (#929)
* Fix typo in managing user account membership with SAML assertion

* Support more config options from Docker env.

* Improve support for SAML key and cert from Docker secrets

Co-authored-by: Ian Bobbitt <ibobbitt@globalnoc.iu.edu>
2021-05-07 23:36:55 +02:00
jodygilbert b66b37ecfd
delete history records when a domain is deleted (#916)
Co-authored-by: Jody <jody.gilbert@edftrading.com>
2021-05-07 22:55:45 +02:00
dependabot[bot] 5f10f739ea
Bump pyyaml from 5.3.1 to 5.4 (#912) 2021-03-27 19:33:49 +01:00
jodygilbert 98db953820
Allow user role to view history (#890) 2021-03-27 19:33:11 +01:00
dependabot[bot] 44c4531f02
Bump elliptic from 6.5.3 to 6.5.4 (#896)
Bumps [elliptic](https://github.com/indutny/elliptic) from 6.5.3 to 6.5.4.
- [Release notes](https://github.com/indutny/elliptic/releases)
- [Commits](https://github.com/indutny/elliptic/compare/v6.5.3...v6.5.4)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-03-16 19:41:46 +01:00
jbe-dw 86700f8fd7
upd: improve user api (#878) 2021-03-16 19:39:53 +01:00
R. Daneel Olivaw 46993e08c0
Add punycode (IDN) support (#879) 2021-03-16 19:37:05 +01:00
jodygilbert 4c19f95928
Improve account creation/permission handling based on Azure oAuth group membership (#877) 2021-01-31 11:31:56 +01:00
jbe-dw 3a4efebf95
enh: display b64 encoded apikey on creation through the API (#870) 2021-01-24 09:43:51 +01:00
jodygilbert 7f86730909
allow-server-side-sessions (#855) 2021-01-24 09:09:53 +01:00
jbe-dw 8f6a800836
fix: account API output^ (#874) 2021-01-24 09:08:32 +01:00
jbe-dw 3cd98251b3
fix: API (apikeys) behaviour does not match swagger definition (#868) 2021-01-24 09:06:51 +01:00
jbe-dw 54b257768f
feat: Implement apikeys/<id> endpoint from swagger spec. (#864) 2021-01-16 20:49:41 +01:00
jbe-dw 718b41e3d1
feat: limit zone list for users on servers endpoint (#862) 2021-01-16 20:45:02 +01:00
jbe-dw dd0a5f6326
feat: Allow sync domain with basic auth (#861) 2021-01-16 20:37:11 +01:00
jbe-dw c3d438842f
fix: user jsonify to set response headers to json (#863) 2021-01-16 20:29:40 +01:00
jbe-dw 33e7ffb747
fix: Follow PDNS Api return format (#858) 2021-01-07 23:26:48 +01:00
jbe-dw 2c18e5c88f
fix: User role was not assigned upon creation (#860) 2021-01-07 23:07:20 +01:00
mrsrvman 2917c47fd1
Update entrypoint.sh (#852)
Fix typo
2020-12-23 17:23:32 +01:00
WhatshallIbreaktoday c6e0293177
Tweaks to allow user apikey usage with powerdns terraform provider (#845) 2020-12-07 22:06:37 +01:00
Attila DEBRECZENI 942482b706
set chown to /app docker workdir (#841) 2020-12-07 19:46:08 +01:00
Khanh Ngo 4d1db72699
Merge pull request #828 from andrewnimmo/patch-1
Avoid Safari telephone number detection
2020-10-14 17:49:51 +02:00
Andrew Nimmo 680e4cf431
Avoid Safari telephone number detection
Using PowerDNS-Admin on an iPad with Safari can cause incorrect identification of some record data as a telephone number. When submitted, the record with the incorrectly identified data causes an error because of the additional markup present on the submitted data. This was noted in particular with the SOA record. 

The proposed change is to add the Safari meta tag to disable format detection:
https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariHTMLRef/Articles/MetaTags.html#//apple_ref/doc/uid/TP40008193-SW5
2020-10-14 17:21:59 +02:00
59 changed files with 4420 additions and 530 deletions

1
.github/FUNDING.yml vendored
View file

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

56
.github/workflows/build-and-publish.yml vendored Normal file
View 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 }}

View file

@ -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

View file

@ -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>

View file

@ -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')

View file

@ -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

View file

@ -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'

View file

@ -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

View file

@ -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}"

View file

@ -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
```

View file

@ -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.

View file

@ -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 ###

View file

@ -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 ###

View file

@ -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",

View file

@ -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

View file

@ -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')

View file

@ -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

View file

@ -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')

View file

@ -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

View file

@ -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)

View file

@ -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")

View file

@ -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

View file

@ -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

View file

@ -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

View 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)

View file

@ -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

View file

@ -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()

View file

@ -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:

View file

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

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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()

View file

@ -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({

View file

@ -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)

View file

@ -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'
}
}

View file

@ -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()

View 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;
}

View file

@ -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);
}

View file

@ -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

View file

@ -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 %}

View file

@ -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">&times;</span>
</button>
<h4 class="modal-title">WARNING</h4>
</div>
<div class="modal-body">
<p></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-flat btn-primary center-block" id="button_key_confirm_warn">
OK</button>
</div>
</div>
<!-- /.modal-content -->
</div>
<!-- /.modal-dialog -->
</div>
{% endblock %}

View file

@ -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&nbsp;<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&nbsp;<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 &nbsp</td>
<td>
<input type="checkbox" id="domain_changelog_only_checkbox" name="domain_changelog_only_checkbox"
class="checkbox" style="border:2px dotted #00f;display:block;background:#ff0000;">
</td>
</div>
</td>
</div>
<div class="tab-pane" id="tabs-account">
<td><label>Account Name</label></td>
<td>
<div class="autocomplete" style="width:250px;">
<input type="text" class="form-control" id="account_name_filter" name="account_name_filter" placeholder="Enter * to search for any account" value="">
</div>
</td>
</div>
<div class="tab-pane" id="tabs-auth">
<td><label>Username</label></td>
<td>
<div class="autocomplete" style="width:250px;">
<input type="text" class="form-control" id="auth_name_filter" name="auth_name_filter" placeholder="Enter * to search for any username" value="">
</div>
</td>
<td>
<div style="position: relative; top:10px;">
<td>Authenticator Types: &nbsp</td>
<td>&nbsp All</td>
<td>
<input type="checkbox" checked id="auth_all_checkbox" name="auth_all_checkbox"
class="checkbox" style="border:2px dotted #00f;display:block;background:#ff0000;">
</td>
<td>&nbsp LOCAL</td>
<td>
<input type="checkbox" checked id="auth_local_only_checkbox" name="auth_local_only_checkbox"
class="checkbox" style="border:2px dotted #00f;display:block;background:#ff0000;">
</td>
<td>&nbsp OAuth</td>
<td>
<input type="checkbox" checked id="auth_oauth_only_checkbox" name="auth_oauth_only_checkbox"
class="checkbox" style="border:2px dotted #00f;display:block;background:#ff0000;">
</td>
<td>&nbsp SAML</td>
<td>
<input type="checkbox" checked id="auth_saml_only_checkbox" name="auth_saml_only_checkbox"
class="checkbox" style="border:2px dotted #00f;display:block;background:#ff0000;">
</td>
</div>
</td>
</div>
</div>
<!-- End Custom Tabs -->
<div class="box-body">
<table id="Filters-Table">
<thead>
<th>Filters</th>
</thead>
<tbody>
<tr>
<td><label>Changed by: &nbsp</label></td>
<td>
<div class="autocomplete" style="width:250px;">
<input type="text" style=" border:1px solid #d2d6de; width:250px; height: 34px;" id="user_name_filter" name="user_name_filter" value="">
</div>
</td>
</tr>
<tr>
<td style="position: relative; top:10px;">
<label>Minimum date: &nbsp</label>
</td>
<td style="position: relative; top:10px;">
<input type="text" id="min" name="min" class="datepicker" autocomplete="off" style=" border:1px solid #d2d6de; width:250px; height: 34px;">
</td>
</tr>
<tr>
<td style="position: relative; top:20px;">
<label>Maximum date: &nbsp</label>
</td>
<td style="position: relative; top:20px;">
<input type="text" id="max" name="max" class="datepicker" autocomplete="off" style=" border:1px solid #d2d6de; width:250px; height: 34px;">
</td>
</tr>
<tr><td>&nbsp</td></tr>
<tr><td>&nbsp</td></tr>
<tr>
<td>
<button type="submit" id="search-submit" name="search-submit" class="btn btn-flat btn-primary button-filter">Search&nbsp;<i class="fa fa-search"></i></button>
</td>
<td>
<!-- &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; -->
<button id="clear-filters" name="clear-filters" class="btn btn-flat btn-warning button-clearf">Clear Filters&nbsp;<i class="fa fa-trash"></i></button>
</td>
</tr>
</tbody>
</table>
</div>
</form>
</div>
<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 %}

View 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&nbsp;<i class="fa fa-info"></i>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<script>
var table;
$(document).ready(function () {
table = $('#tbl_history').DataTable({
"order": [
[2, "desc"]
],
"searching": true,
"columnDefs": [{
"type": "time",
"render": function (data, type, row) {
return moment.utc(data).local().format('YYYY-MM-DD HH:mm:ss');
},
"targets": 2
}],
"info": true,
"autoWidth": false,
orderCellsTop: true,
fixedHeader: true
});
$(document.body).on('click', '.history-info-button', function () {
var modal = $("#modal_history_info");
var history_id = $(this).val();
var info = $("#history-info-div-" + history_id).html();
$('#modal-info-content').html(info);
modal.modal('show');
});
$(document.body).on("click", ".button-filter", function (e) {
e.stopPropagation();
var nextRow = $("#filter-table")
if (nextRow.css("visibility") == "visible")
nextRow.css("visibility", "collapse")
else
nextRow.css("visibility", "visible")
});
});
</script>

View file

@ -35,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) }}'">

View file

@ -51,7 +51,7 @@
<div class="nav-tabs-custom" id="tabs">
<ul class="nav nav-tabs">
<li class="active"><a href="#tabs-general" data-toggle="tab">General</a></li>
<li 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">&times;</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>
&nbsp;&nbsp;&nbsp;
<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>
&nbsp;&nbsp;&nbsp;
<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">&times;</span>
</button>
<h4 class="modal-title">Confirmation</h4>
</div>
<div class="modal-body">
<p></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-flat btn-default pull-left" id="button_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">&times;</span>
</button>
<h4 class="modal-title">Warning</h4>
</div>
<div class="modal-body">
<p></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-flat btn-success" id="button_warning_confirm">Yes I understand</button>
</div>
</div>
<!-- /.modal-content -->
</div>
<!-- /.modal-dialog -->
</div>
{% endblock %}

View 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 %}

View file

@ -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 %}

View file

@ -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&nbsp;<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&nbsp;<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"

View file

@ -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&nbsp;<i class="fa fa-cog"></i>
</button>
<button type="button" class="btn btn-flat btn-primary" onclick="window.location.href='{{ url_for('domain.changelog', domain_name=domain.name) }}'">
Changelog&nbsp;<i class="fa fa-history" aria-hidden="true"></i>
</button>
</td>
{% else %}
<td width="6%">
<button type="button" class="btn btn-flat btn-success" onclick="window.location.href='{{ url_for('domain.domain', domain_name=domain.name) }}'">
Manage&nbsp;<i class="fa fa-cog"></i>
</button>
{% if allow_user_view_history %}
<button type="button" class="btn btn-flat btn-primary" onclick="window.location.href='{{ url_for('domain.changelog', domain_name=domain.name) }}'">
Changelog&nbsp;<i class="fa fa-history" aria-hidden="true"></i>
</button>
{% endif %}
</td>
{% endif %}
{% endif %}
{% endmacro %}

62
powerdnsadmin/templates/domain.html Normal file → Executable file
View 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&nbsp;<i class="fa fa-download"></i>
</button>
{% endif %}
{% if current_user.role.name in ['Administrator', 'Operator'] %}
<button type="button" style="position: relative; margin-left: 20px" class="btn btn-flat btn-primary pull-left btn-danger" onclick="window.location.href='{{ url_for('domain.setting', domain_name=domain.name) }}'">
Admin&nbsp;<i class="fa fa-cog"></i>
</button>
{% endif %}
{% if current_user.role.name in ['Administrator', 'Operator'] or SETTING.get('allow_user_view_history') %}
<button type="button" style="position: relative; margin-left: 20px" class="btn btn-flat btn-primary button_changelog" id="{{ domain.name }}">
Changelog&nbsp;<i class="fa fa-history" aria-hidden="true"></i>
</i>
</button>
{% endif %}
</div>
<div class="box-body">
<table id="tbl_records" class="table table-bordered table-striped">
@ -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">&nbsp;&nbsp;
<i class="fa fa-history" aria-hidden="true"></i>&nbsp;&nbsp;&nbsp;
</button>
</td>
{% endif %}
<!-- hidden column that we can sort on -->
<td>1</td>
</tr>
@ -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;

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

View file

@ -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">&times;</span>
</button>
<h4 class="modal-title">Confirmation</h4>
</div>
<div class="modal-body">
<p></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-flat btn-default pull-left" id="button_delete_cancel"
data-dismiss="modal">Close</button>
<button type="button" class="btn btn-flat btn-danger" id="button_delete_confirm">Delete</button>
</div>
</div>
<!-- /.modal-content -->
</div>
<!-- /.modal-dialog -->
</div>
{% endblock %}

View file

@ -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>&nbsp;Change account for {{ domain.name }}
<i class="fa fa-check"></i>&nbsp;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>&nbsp;Change type for {{ domain.name }}
<i class="fa fa-check"></i>&nbsp;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>&nbsp;Change SOA-EDIT-API setting for {{ domain.name }}
<i class="fa fa-check"></i>&nbsp;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>&nbsp;DELETE DOMAIN {{ domain.name }}
<i class="fa fa-trash"></i>&nbsp;DELETE DOMAIN {{ domain.name | pretty_domain_name }}
</button>
</div>
</div>

View file

@ -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">

View 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">&times;</button>
{{ error }}
</div>
{% endif %}
Welcome, {{user.firstname}}! <br />
You will need a Token on login. <br />
Your QR code is:
<div id="token_information">
{% if qrcode_image == None %}
<p><img id="qrcode" src="{{ url_for('user.qrcode') }}"></p>
{% else %}
<p><img id="qrcode" src="data:image/svg+xml;utf8;base64, {{qrcode_image}}"></p>
{% endif %}
<p>
Your secret key is: <br />
<form>
<input type=text id="otp_secret" value={{user.otp_secret}} readonly>
<button type=button style="position:relative; right:28px" onclick="copy_otp_secret_to_clipboard()"> <i class="fa fa-clipboard"></i> </button>
<br /><font color="red" id="copy_tooltip" style="visibility:collapse">Copied.</font>
</form>
</p>
You can use Google Authenticator (<a target="_blank"
href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2">Android</a>
- <a target="_blank"
href="https://apps.apple.com/us/app/google-authenticator/id388497605">iOS</a>)
<br />
or FreeOTP (<a target="_blank"
href="https://play.google.com/store/apps/details?id=org.fedorahosted.freeotp&hl=en">Android</a>
- <a target="_blank"
href="https://itunes.apple.com/en/app/freeotp-authenticator/id872559395?mt=8">iOS</a>)
on your smartphone <br /> to scan the QR code or type the secret key.
<br /> <br />
<font color="red"><strong><i>Make sure only you can see this QR Code <br />
and secret key, and nobody can capture them.</i></strong></font>
</div>
</br>
Please input your OTP token to continue, to ensure the seed has been scanned correctly.
<form action="" method="post" data-toggle="validator">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<input type="text" class="form-control" placeholder="OTP Token" name="otptoken"
data-error="Please input your OTP token" required>
</div>
<div class="row">
<div class="col-xs-4">
<button type="submit" class="btn btn-flat btn-primary btn-block">Continue</button>
</div>
</div>
</form>
</div>
<div class="login-box-footer">
<center>
<p>Powered by <a href="https://github.com/ngoduykhanh/PowerDNS-Admin">PowerDNS-Admin</a></p>
</center>
</div>
</div>
</body>
{% assets "js_login" -%}
<script type="text/javascript" src="{{ ASSET_URL }}"></script>
{%- endassets %}
{% assets "js_validation" -%}
<script type="text/javascript" src="{{ ASSET_URL }}"></script>
{%- endassets %}
</html>

View file

@ -51,7 +51,7 @@
{% if session['authentication_type'] != 'LOCAL' %}disabled{% endif %}>
</div>
<div class="form-group">
<label for="email">E-mail</label> <input type="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>

View file

@ -8,7 +8,7 @@ mysqlclient==2.0.1
configobj==5.0.6
bcrypt>=3.1.7
requests==2.24.0
python-ldap==3.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

View file

@ -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=