diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index f8d96a4..0000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1 +0,0 @@ -github: [ngoduykhanh] diff --git a/README.md b/README.md index e924bb0..bec39a5 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,3 @@ You can then access PowerDNS-Admin by pointing your browser to http://localhost: ## LICENSE MIT. See [LICENSE](https://github.com/ngoduykhanh/PowerDNS-Admin/blob/master/LICENSE) -## Support -If you like the project and want to support it, you can *buy me a coffee* ☕ - -Buy Me A Coffee diff --git a/docs/API.md b/docs/API.md index ae52025..890f556 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,105 +1,134 @@ ### API Usage +#### Getting started with docker + 1. Run docker image docker-compose up, go to UI http://localhost:9191, at http://localhost:9191/swagger is swagger API specification 2. Click to register user, type e.g. user: admin and password: admin 3. Login to UI in settings enable allow domain creation for users, now you can create and manage domains with admin account and also ordinary users -4. Encode your user and password to base64, in our example we have user admin and password admin so in linux cmd line we type: +4. Click on the API Keys menu then click on teh "Add Key" button to add a new Administrator Key +5. Keep the base64 encoded apikey somewhere safe as it won't be available in clear anymore -``` + +#### Accessing the API + +The PDA API consists of two distinct parts: + +- The /powerdnsadmin endpoints manages PDA content (accounts, users, apikeys) and also allow domain creation/deletion +- The /server endpoints are proxying queries to the backend PowerDNS instance's API. PDA acts as a proxy managing several API Keys and permissions to the PowerDNS content. + +The requests to the API needs two headers: + +- The classic 'Content-Type: application/json' is required to all POST and PUT requests, though it's armless to use it on each call +- The authentication header to provide either the login:password basic authentication or the Api Key authentication. + +When you access the `/powerdnsadmin` endpoint, you must use the Basic Auth: + +```bash +# Encode your user and password to base64 $ echo -n 'admin:admin'|base64 YWRtaW46YWRtaW4= +# Use the ouput as your basic auth header +curl -H 'Authorization: Basic YWRtaW46YWRtaW4=' -X ``` -we use generated output in basic authentication, we authenticate as user, -with basic authentication, we can create/delete/get zone and create/delete/get/update apikeys - -creating domain: +When you access the `/server` endpoint, you must use the ApiKey +```bash +# Use the already base64 encoded key in your header +curl -H 'X-API-Key: YUdDdGhQM0tMQWV5alpJ' -X ``` + +Finally, the `/sync_domains` endpoint accepts both basic and apikey authentication + +#### Examples + +Creating domain via `/powerdnsadmin`: + +```bash curl -L -vvv -H 'Content-Type: application/json' -H 'Authorization: Basic YWRtaW46YWRtaW4=' -X POST http://localhost:9191/api/v1/pdnsadmin/zones --data '{"name": "yourdomain.com.", "kind": "NATIVE", "nameservers": ["ns1.mydomain.com."]}' ``` -creating apikey which has Administrator role, apikey can have also User role, when creating such apikey you have to specify also domain for which apikey is valid: +Creating an apikey which has the Administrator role: -``` +```bash +# Create the key curl -L -vvv -H 'Content-Type: application/json' -H 'Authorization: Basic YWRtaW46YWRtaW4=' -X POST http://localhost:9191/api/v1/pdnsadmin/apikeys --data '{"description": "masterkey","domains":[], "role": "Administrator"}' ``` +Example response (don't forget to save the plain key from the output) -call above will return response like this: - -``` -[{"description": "samekey", "domains": [], "role": {"name": "Administrator", "id": 1}, "id": 2, "plain_key": "aGCthP3KLAeyjZI"}] +```json +[ + { + "accounts": [], + "description": "masterkey", + "domains": [], + "role": { + "name": "Administrator", + "id": 1 + }, + "id": 2, + "plain_key": "aGCthP3KLAeyjZI" + } +] ``` -we take plain_key and base64 encode it, this is the only time we can get API key in plain text and save it somewhere: +We can use the apikey for all calls to PowerDNS (don't forget to specify Content-Type): -``` -$ echo -n 'aGCthP3KLAeyjZI'|base64 -YUdDdGhQM0tMQWV5alpJ -``` +Getting powerdns configuration (Administrator Key is needed): -We can use apikey for all calls specified in our API specification (it tries to follow powerdns API 1:1, only tsigkeys endpoints are not yet implemented), don't forget to specify Content-Type! - -getting powerdns configuration: - -``` +```bash curl -L -vvv -H 'Content-Type: application/json' -H 'X-API-KEY: YUdDdGhQM0tMQWV5alpJ' -X GET http://localhost:9191/api/v1/servers/localhost/config ``` -creating and updating records: +Creating and updating records: -``` +```bash curl -X PATCH -H 'Content-Type: application/json' --data '{"rrsets": [{"name": "test1.yourdomain.com.","type": "A","ttl": 86400,"changetype": "REPLACE","records": [ {"content": "192.0.2.5", "disabled": false} ]},{"name": "test2.yourdomain.com.","type": "AAAA","ttl": 86400,"changetype": "REPLACE","records": [ {"content": "2001:db8::6", "disabled": false} ]}]}' -H 'X-API-Key: YUdDdGhQM0tMQWV5alpJ' http://127.0.0.1:9191/api/v1/servers/localhost/zones/yourdomain.com. ``` -getting domain: +Getting a domain: -``` +```bash curl -L -vvv -H 'Content-Type: application/json' -H 'X-API-KEY: YUdDdGhQM0tMQWV5alpJ' -X GET http://localhost:9191/api/v1/servers/localhost/zones/yourdomain.com ``` -list zone records: +List a zone's records: -``` +```bash curl -H 'Content-Type: application/json' -H 'X-API-Key: YUdDdGhQM0tMQWV5alpJ' http://localhost:9191/api/v1/servers/localhost/zones/yourdomain.com ``` -add new record: +Add a new record: -``` +```bash curl -H 'Content-Type: application/json' -X PATCH --data '{"rrsets": [ {"name": "test.yourdomain.com.", "type": "A", "ttl": 86400, "changetype": "REPLACE", "records": [ {"content": "192.0.5.4", "disabled": false } ] } ] }' -H 'X-API-Key: YUdDdGhQM0tMQWV5alpJ' http://localhost:9191/api/v1/servers/localhost/zones/yourdomain.com | jq . ``` -update record: +Update a record: -``` +```bash curl -H 'Content-Type: application/json' -X PATCH --data '{"rrsets": [ {"name": "test.yourdomain.com.", "type": "A", "ttl": 86400, "changetype": "REPLACE", "records": [ {"content": "192.0.2.5", "disabled": false, "name": "test.yourdomain.com.", "ttl": 86400, "type": "A"}]}]}' -H 'X-API-Key: YUdDdGhQM0tMQWV5alpJ' http://localhost:9191/api/v1/servers/localhost/zones/yourdomain.com | jq . ``` -delete record: +Delete a record: -``` +```bash curl -H 'Content-Type: application/json' -X PATCH --data '{"rrsets": [ {"name": "test.yourdomain.com.", "type": "A", "ttl": 86400, "changetype": "DELETE"}]}' -H 'X-API-Key: YUdDdGhQM0tMQWV5alpJ' http://localhost:9191/api/v1/servers/localhost/zones/yourdomain.com | jq ``` ### Generate ER diagram -``` +With docker + +```bash +# Install build packages apt-get install python-dev graphviz libgraphviz-dev pkg-config -``` - -``` +# Get the required python libraries pip install graphviz mysqlclient ERAlchemy -``` - -``` +# Start the docker container docker-compose up -d -``` - -``` +# Set environment variables source .env -``` - -``` +# Generate the diagrams eralchemy -i 'mysql://${PDA_DB_USER}:${PDA_DB_PASSWORD}@'$(docker inspect powerdns-admin-mysql|jq -jr '.[0].NetworkSettings.Networks.powerdnsadmin_default.IPAddress')':3306/powerdns_admin' -o /tmp/output.pdf ``` diff --git a/migrations/versions/0967658d9c0d_add_apikey_account_mapping_table.py b/migrations/versions/0967658d9c0d_add_apikey_account_mapping_table.py new file mode 100644 index 0000000..f2c87ed --- /dev/null +++ b/migrations/versions/0967658d9c0d_add_apikey_account_mapping_table.py @@ -0,0 +1,41 @@ +"""add apikey account mapping table + +Revision ID: 0967658d9c0d +Revises: 0d3d93f1c2e0 +Create Date: 2021-11-13 22:28:46.133474 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '0967658d9c0d' +down_revision = '0d3d93f1c2e0' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('apikey_account', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('apikey_id', sa.Integer(), nullable=False), + sa.Column('account_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['account_id'], ['account.id'], ), + sa.ForeignKeyConstraint(['apikey_id'], ['apikey.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('history', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_history_created_on'), ['created_on'], unique=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('history', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_history_created_on')) + + op.drop_table('apikey_account') + # ### end Alembic commands ### diff --git a/powerdnsadmin/__init__.py b/powerdnsadmin/__init__.py index 98690c2..c70b273 100755 --- a/powerdnsadmin/__init__.py +++ b/powerdnsadmin/__init__.py @@ -55,6 +55,8 @@ def create_app(config=None): csrf.exempt(routes.api.api_list_account_users) csrf.exempt(routes.api.api_add_account_user) csrf.exempt(routes.api.api_remove_account_user) + csrf.exempt(routes.api.api_zone_cryptokeys) + csrf.exempt(routes.api.api_zone_cryptokey) # Load config from env variables if using docker if os.path.exists(os.path.join(app.root_path, 'docker_config.py')): diff --git a/powerdnsadmin/assets.py b/powerdnsadmin/assets.py index dfe79ff..e7c6354 100644 --- a/powerdnsadmin/assets.py +++ b/powerdnsadmin/assets.py @@ -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') diff --git a/powerdnsadmin/decorators.py b/powerdnsadmin/decorators.py index 44e545b..e2a35bb 100644 --- a/powerdnsadmin/decorators.py +++ b/powerdnsadmin/decorators.py @@ -192,6 +192,24 @@ def is_json(f): return decorated_function +def callback_if_request_body_contains_key(callback, http_methods=[], keys=[]): + """ + If request body contains one or more of specified keys, call + :param callback + """ + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + check_current_http_method = not http_methods or request.method in http_methods + if (check_current_http_method and + set(request.get_json(force=True).keys()).intersection(set(keys)) + ): + callback(*args, **kwargs) + return f(*args, **kwargs) + return decorated_function + return decorator + + def api_role_can(action, roles=None, allow_self=False): """ Grant access if: @@ -246,6 +264,48 @@ def api_can_create_domain(f): return decorated_function +def apikey_can_create_domain(f): + """ + Grant access if: + - user is in Operator role or higher, or + - allow_user_create_domain is on + """ + @wraps(f) + def decorated_function(*args, **kwargs): + if g.apikey.role.name not in [ + 'Administrator', 'Operator' + ] and not Setting().get('allow_user_create_domain'): + msg = "ApiKey #{0} does not have enough privileges to create domain" + current_app.logger.error(msg.format(g.apikey.id)) + raise NotEnoughPrivileges() + return f(*args, **kwargs) + + return decorated_function + + +def apikey_can_remove_domain(http_methods=[]): + """ + Grant access if: + - user is in Operator role or higher, or + - allow_user_remove_domain is on + """ + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + check_current_http_method = not http_methods or request.method in http_methods + + if (check_current_http_method and + g.apikey.role.name not in ['Administrator', 'Operator'] and + not Setting().get('allow_user_remove_domain') + ): + msg = "ApiKey #{0} does not have enough privileges to remove domain" + current_app.logger.error(msg.format(g.apikey.id)) + raise NotEnoughPrivileges() + return f(*args, **kwargs) + return decorated_function + return decorator + + def apikey_is_admin(f): """ Grant access if user is in Administrator role @@ -262,21 +322,52 @@ def apikey_is_admin(f): def apikey_can_access_domain(f): + """ + Grant access if: + - user has Operator role or higher, or + - user has explicitly been granted access to domain + """ @wraps(f) def decorated_function(*args, **kwargs): - apikey = g.apikey if g.apikey.role.name not in ['Administrator', 'Operator']: - domains = apikey.domains zone_id = kwargs.get('zone_id').rstrip(".") - domain_names = [item.name for item in domains] + domain_names = [item.name for item in g.apikey.domains] - if zone_id not in domain_names: + accounts = g.apikey.accounts + accounts_domains = [domain.name for a in accounts for domain in a.domains] + + allowed_domains = set(domain_names + accounts_domains) + + if zone_id not in allowed_domains: raise DomainAccessForbidden() return f(*args, **kwargs) return decorated_function +def apikey_can_configure_dnssec(http_methods=[]): + """ + Grant access if: + - user is in Operator role or higher, or + - dnssec_admins_only is off + """ + def decorator(f=None): + @wraps(f) + def decorated_function(*args, **kwargs): + check_current_http_method = not http_methods or request.method in http_methods + + if (check_current_http_method and + g.apikey.role.name not in ['Administrator', 'Operator'] and + Setting().get('dnssec_admins_only') + ): + msg = "ApiKey #{0} does not have enough privileges to configure dnssec" + current_app.logger.error(msg.format(g.apikey.id)) + raise DomainAccessForbidden(message=msg) + return f(*args, **kwargs) if f else None + return decorated_function + return decorator + + def apikey_auth(f): @wraps(f) def decorated_function(*args, **kwargs): diff --git a/powerdnsadmin/lib/errors.py b/powerdnsadmin/lib/errors.py index 8642cf9..687f554 100644 --- a/powerdnsadmin/lib/errors.py +++ b/powerdnsadmin/lib/errors.py @@ -60,7 +60,8 @@ class ApiKeyNotUsable(StructuredException): def __init__( self, name=None, - message="Api key must have domains or have administrative role"): + message=("Api key must have domains or accounts" + " or an administrative role")): StructuredException.__init__(self) self.message = message self.name = name @@ -120,6 +121,15 @@ class AccountDeleteFail(StructuredException): self.name = name +class AccountNotExists(StructuredException): + status_code = 404 + + def __init__(self, name=None, message="Account does not exist"): + StructuredException.__init__(self) + self.message = message + self.name = name + + class UserCreateFail(StructuredException): status_code = 500 diff --git a/powerdnsadmin/lib/schema.py b/powerdnsadmin/lib/schema.py index 78d8369..e0d7efc 100644 --- a/powerdnsadmin/lib/schema.py +++ b/powerdnsadmin/lib/schema.py @@ -11,10 +11,21 @@ class RoleSchema(Schema): name = fields.String() +class AccountSummarySchema(Schema): + id = fields.Integer() + name = fields.String() + domains = fields.Embed(schema=DomainSchema, many=True) + +class ApiKeySummarySchema(Schema): + id = fields.Integer() + description = fields.String() + + class ApiKeySchema(Schema): id = fields.Integer() role = fields.Embed(schema=RoleSchema) domains = fields.Embed(schema=DomainSchema, many=True) + accounts = fields.Embed(schema=AccountSummarySchema, many=True) description = fields.String() key = fields.String() @@ -23,15 +34,11 @@ class ApiPlainKeySchema(Schema): id = fields.Integer() role = fields.Embed(schema=RoleSchema) domains = fields.Embed(schema=DomainSchema, many=True) + accounts = fields.Embed(schema=AccountSummarySchema, many=True) description = fields.String() plain_key = fields.String() -class AccountSummarySchema(Schema): - id = fields.Integer() - name = fields.String() - - class UserSchema(Schema): id = fields.Integer() username = fields.String() @@ -56,3 +63,4 @@ class AccountSchema(Schema): contact = fields.String() mail = fields.String() domains = fields.Embed(schema=DomainSchema, many=True) + apikeys = fields.Embed(schema=ApiKeySummarySchema, many=True) diff --git a/powerdnsadmin/models/__init__.py b/powerdnsadmin/models/__init__.py index 1562319..9c009f2 100644 --- a/powerdnsadmin/models/__init__.py +++ b/powerdnsadmin/models/__init__.py @@ -8,6 +8,7 @@ from .account_user import AccountUser from .server import Server from .history import History from .api_key import ApiKey +from .api_key_account import ApiKeyAccount from .setting import Setting from .domain import Domain from .domain_setting import DomainSetting diff --git a/powerdnsadmin/models/account.py b/powerdnsadmin/models/account.py index ad46ef9..4d08fc1 100644 --- a/powerdnsadmin/models/account.py +++ b/powerdnsadmin/models/account.py @@ -17,6 +17,9 @@ class Account(db.Model): contact = db.Column(db.String(128)) mail = db.Column(db.String(128)) domains = db.relationship("Domain", back_populates="account") + apikeys = db.relationship("ApiKey", + secondary="apikey_account", + back_populates="accounts") def __init__(self, name=None, description=None, contact=None, mail=None): self.name = name diff --git a/powerdnsadmin/models/api_key.py b/powerdnsadmin/models/api_key.py index cb05890..4c26cd2 100644 --- a/powerdnsadmin/models/api_key.py +++ b/powerdnsadmin/models/api_key.py @@ -1,12 +1,12 @@ -import random +import secrets import string import bcrypt from flask import current_app -from .base import db, domain_apikey +from .base import db from ..models.role import Role from ..models.domain import Domain - +from ..models.account import Account class ApiKey(db.Model): __tablename__ = "apikey" @@ -16,17 +16,21 @@ class ApiKey(db.Model): role_id = db.Column(db.Integer, db.ForeignKey('role.id')) role = db.relationship('Role', back_populates="apikeys", lazy=True) domains = db.relationship("Domain", - secondary=domain_apikey, + secondary="domain_apikey", back_populates="apikeys") + accounts = db.relationship("Account", + secondary="apikey_account", + back_populates="apikeys") - def __init__(self, key=None, desc=None, role_name=None, domains=[]): + def __init__(self, key=None, desc=None, role_name=None, domains=[], accounts=[]): self.id = None self.description = desc self.role_name = role_name self.domains[:] = domains + self.accounts[:] = accounts if not key: rand_key = ''.join( - random.choice(string.ascii_letters + string.digits) + secrets.choice(string.ascii_letters + string.digits) for _ in range(15)) self.plain_key = rand_key self.key = self.get_hashed_password(rand_key).decode('utf-8') @@ -54,7 +58,7 @@ class ApiKey(db.Model): db.session.rollback() raise e - def update(self, role_name=None, description=None, domains=None): + def update(self, role_name=None, description=None, domains=None, accounts=None): try: if role_name: role = Role.query.filter(Role.name == role_name).first() @@ -63,12 +67,18 @@ class ApiKey(db.Model): if description: self.description = description - if domains: + if domains is not None: domain_object_list = Domain.query \ .filter(Domain.name.in_(domains)) \ .all() self.domains[:] = domain_object_list + if accounts is not None: + account_object_list = Account.query \ + .filter(Account.name.in_(accounts)) \ + .all() + self.accounts[:] = account_object_list + db.session.commit() except Exception as e: msg_str = 'Update of apikey failed. Error: {0}' @@ -121,3 +131,12 @@ class ApiKey(db.Model): raise Exception("Unauthorized") return apikey + + def associate_account(self, account): + return True + + def dissociate_account(self, account): + return True + + def get_accounts(self): + return True diff --git a/powerdnsadmin/models/api_key_account.py b/powerdnsadmin/models/api_key_account.py new file mode 100644 index 0000000..904f335 --- /dev/null +++ b/powerdnsadmin/models/api_key_account.py @@ -0,0 +1,20 @@ +from .base import db + + +class ApiKeyAccount(db.Model): + __tablename__ = 'apikey_account' + id = db.Column(db.Integer, primary_key=True) + apikey_id = db.Column(db.Integer, + db.ForeignKey('apikey.id'), + nullable=False) + account_id = db.Column(db.Integer, + db.ForeignKey('account.id'), + nullable=False) + db.UniqueConstraint('apikey_id', 'account_id', name='uniq_apikey_account') + + def __init__(self, apikey_id, account_id): + self.apikey_id = apikey_id + self.account_id = account_id + + def __repr__(self): + return ''.format(self.apikey_id, self.account_id) diff --git a/powerdnsadmin/models/setting.py b/powerdnsadmin/models/setting.py index afedbb1..1e49aaa 100644 --- a/powerdnsadmin/models/setting.py +++ b/powerdnsadmin/models/setting.py @@ -198,6 +198,8 @@ class Setting(db.Model): 'max_history_records': 1000, 'zxcvbn_enabled': False, 'zxcvbn_guesses_log' : 11 + 'otp_force': False, + 'max_history_records': 1000 } def __init__(self, id=None, name=None, value=None): diff --git a/powerdnsadmin/models/user.py b/powerdnsadmin/models/user.py index 72491a3..ca0561b 100644 --- a/powerdnsadmin/models/user.py +++ b/powerdnsadmin/models/user.py @@ -8,6 +8,9 @@ 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 @@ -628,10 +631,18 @@ class User(db.Model): Account)\ .filter(self.id == AccountUser.user_id)\ .filter(Account.id == AccountUser.account_id)\ + .order_by(Account.name)\ .all() for q in query: accounts.append(q[1]) return accounts + + def get_qrcode_value(self): + img = qrc.make(self.get_totp_uri(), + image_factory=qrc_svg.SvgPathImage) + stream = BytesIO() + img.save(stream) + return stream.getvalue() def read_entitlements(self, key): @@ -793,7 +804,4 @@ def getUserInfo(DomainsOrAccounts): current=[] for DomainOrAccount in DomainsOrAccounts: current.append(DomainOrAccount.name) - return current - - - + return current \ No newline at end of file diff --git a/powerdnsadmin/routes/admin.py b/powerdnsadmin/routes/admin.py index 0c1ceb6..94ee878 100644 --- a/powerdnsadmin/routes/admin.py +++ b/powerdnsadmin/routes/admin.py @@ -4,7 +4,7 @@ import traceback import re from base64 import b64encode from ast import literal_eval -from flask import Blueprint, render_template, make_response, url_for, current_app, request, redirect, jsonify, abort, flash, session +from flask import Blueprint, render_template, render_template_string, make_response, url_for, current_app, request, redirect, jsonify, abort, flash, session from flask_login import login_required, current_user from ..decorators import operator_role_required, admin_role_required, history_access_required @@ -40,7 +40,7 @@ old_state: dictionary with "disabled" and "content" keys. {"disabled" : False, " 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, +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): @@ -57,12 +57,12 @@ def get_record_changes(del_rrest, add_rrest): {"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: @@ -78,7 +78,7 @@ def get_record_changes(del_rrest, add_rrest): exists = False for c in changeSet: if c[1] != None and c[1]["content"] == a['content']: - exists = True + exists = True break if not exists: changeSet.append( ( {"disabled":a['disabled'], "content":a['content']}, {"disabled":a['disabled'], "content":a['content']}, "unchanged") ) @@ -92,8 +92,9 @@ def extract_changelogs_from_a_history_entry(out_changes, history_entry, change_n if history_entry.detail is None: return - detail_dict = json.loads(history_entry.detail.replace("'", '"')) - if "add_rrests" not in detail_dict: + 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'] @@ -123,7 +124,7 @@ def extract_changelogs_from_a_history_entry(out_changes, history_entry, change_n 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 @@ -172,7 +173,7 @@ class HistoryRecordEntry: if add_rrest['ttl'] != del_rrest['ttl']: self.changed_fields.append("ttl") self.changeSet = get_record_changes(del_rrest, add_rrest) - + def toDict(self): @@ -300,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 @@ -316,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) @@ -323,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: @@ -344,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)) @@ -354,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) @@ -390,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)) @@ -740,110 +754,140 @@ def manage_account(): class DetailedHistory(): - def __init__(self, history, change_set): - self.history = history - self.detailed_msg = "" - self.change_set = change_set - - if history.detail is None: - self.detailed_msg = "" - # if 'Create account' in history.msg: - # account = Account.query.filter( - # Account.name == history.msg.split(' ')[2]).first() - # self.detailed_msg = str(account.get_user()) - # # WRONG, cannot do query afterwards, db may have changed - return + def __init__(self, history, change_set): + self.history = history + self.detailed_msg = "" + self.change_set = change_set - detail_dict = json.loads(history.detail.replace("'", '"')) - if 'domain_type' in detail_dict.keys() and 'account_id' in detail_dict.keys(): # this is a domain creation - self.detailed_msg = """ -
Domain type:{0}
Account:{1}
- """.format(detail_dict['domain_type'], - 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.keys(): # this is a user authentication - self.detailed_msg = """ - - - - - """.format(detail_dict['username']) + if not history.detail: + self.detailed_msg = "" + return - else: - self.detailed_msg+= """ - rgba(201,48,44);">

- User {0} authentication failure - - """.format(detail_dict['username']) + if 'add_rrest' in history.detail: + detail_dict = json.loads(history.detail.replace("\'", '')) + else: + detail_dict = json.loads(history.detail.replace("'", '"')) - self.detailed_msg+= """ -

- - - - - - - - - - - -

- User {0} authentication success -

Authenticator Type:{0}
IP Address{1}
- """.format(detail_dict['authenticator'], detail_dict['ip_address']) + if 'domain_type' in detail_dict and 'account_id' in detail_dict: # this is a domain creation + self.detailed_msg = render_template_string(""" + + + +
Domain type:{{ domaintype }}
Account:{{ account }}
+ """, + domaintype=detail_dict['domain_type'], + account=Account.get_name_by_id(self=None, account_id=detail_dict['account_id']) if detail_dict['account_id'] != "0" else "None") + + elif 'authenticator' in detail_dict: # this is a user authentication + self.detailed_msg = render_template_string(""" + + + + + + + + + + + + + + + + +
+

User {{ username }} authentication {{ auth_result }}

+
Authenticator Type:{{ authenticator }}
IP Address{{ ip_address }}
+ """, + background_rgba="68,157,68" if detail_dict['success'] == 1 else "201,48,44", + username=detail_dict['username'], + auth_result="success" if detail_dict['success'] == 1 else "failure", + authenticator=detail_dict['authenticator'], + ip_address=detail_dict['ip_address']) + + elif 'add_rrests' in detail_dict: # this is a domain record change + # changes_set = [] + self.detailed_msg = "" + # extract_changelogs_from_a_history_entry(changes_set, history, 0) + + elif 'name' in detail_dict and 'template' in history.msg: # template creation / deletion + self.detailed_msg = render_template_string(""" + + + +
Template name:{{ template_name }}
Description:{{ description }}
+ """, + template_name=DetailedHistory.get_key_val(detail_dict, "name"), + description=DetailedHistory.get_key_val(detail_dict, "description")) + + elif 'Change domain' in history.msg and 'access control' in history.msg: # added or removed a user from a domain + users_with_access = DetailedHistory.get_key_val(detail_dict, "user_has_access") + self.detailed_msg = render_template_string(""" + + + +
Users with access to this domain{{ users_with_access }}
Number of users:{{ users_with_access | length }}
+ """, + users_with_access=users_with_access) + + elif 'Created API key' in history.msg or 'Updated API key' in history.msg: + self.detailed_msg = render_template_string(""" + + + + + + +
Key: {{ keyname }}
Role:{{ rolename }}
Description:{{ description }}
Accessible domains with this API key:{{ linked_domains }}
Accessible accounts with this API key:{{ linked_accounts }}
+ """, + keyname=DetailedHistory.get_key_val(detail_dict, "key"), + rolename=DetailedHistory.get_key_val(detail_dict, "role"), + description=DetailedHistory.get_key_val(detail_dict, "description"), + linked_domains=DetailedHistory.get_key_val(detail_dict, "domains" if "domains" in detail_dict else "domain_acl"), + linked_accounts=DetailedHistory.get_key_val(detail_dict, "accounts")) + + elif 'Delete API key' in history.msg: + self.detailed_msg = render_template_string(""" + + + + + +
Key: {{ keyname }}
Role:{{ rolename }}
Description:{{ description }}
Accessible domains with this API key:{{ linked_domains }}
+ """, + keyname=DetailedHistory.get_key_val(detail_dict, "key"), + rolename=DetailedHistory.get_key_val(detail_dict, "role"), + description=DetailedHistory.get_key_val(detail_dict, "description"), + linked_domains=DetailedHistory.get_key_val(detail_dict, "domains")) + + elif 'Update type for domain' in history.msg: + self.detailed_msg = render_template_string(""" + + + + +
Domain: {{ domain }}
Domain type:{{ domain_type }}
Masters:{{ masters }}
+ """, + 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(""" + + + +
Domain Type: {{ domain_type }}
Domain Master IPs:{{ domain_master_ips }}
+ """, + 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(), ''))) - elif 'add_rrests' in detail_dict.keys(): # 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.keys() and 'template' in history.msg: # template creation - self.detailed_msg = """ -
Template name:{0}
Description:{1}
- """.format(detail_dict['name'], detail_dict['description']) - elif 'Change domain' in history.msg and 'access control' in history.msg: # added or removed a user from a domain - self.detailed_msg = """ -
Users with access to this domain{0}
Number of users:{1}
- """.format(str(detail_dict['user_has_access']).replace("]","").replace("[", ""), len((detail_dict['user_has_access']))) - elif 'Created API key' in history.msg or 'Updated API key' in history.msg: - self.detailed_msg = """ - - - - - -
Key: {0}
Role:{1}
Description:{2}
Accessible domains with this API key:{3}
- """.format(detail_dict['key'], detail_dict['role'], detail_dict['description'], str(detail_dict['domain_acl']).replace("]","").replace("[", "")) - elif 'Update type for domain' in history.msg: - self.detailed_msg = """ - - - - -
Domain: {0}
Domain type:{1}
Masters:{2}
- """.format(detail_dict['domain'], detail_dict['type'], str(detail_dict['masters']).replace("]","").replace("[", "")) - elif 'Delete API key' in history.msg: - self.detailed_msg = """ - - - - - -
Key: {0}
Role:{1}
Description:{2}
Accessible domains with this API key:{3}
- """.format(detail_dict['key'], detail_dict['role'], detail_dict['description'], str(detail_dict['domains']).replace("]","").replace("[", "")) - elif 'reverse' in history.msg: - self.detailed_msg = """ - - - -
Domain Type: {0}
Domain Master IPs:{1}
- """.format(detail_dict['domain_type'], detail_dict['domain_master_ips']) # convert a list of History objects into DetailedHistory objects def convert_histories(histories): @@ -851,8 +895,7 @@ def convert_histories(histories): detailedHistories = [] j = 0 for i in range(len(histories)): - # if histories[i].detail != None and 'add_rrests' in json.loads(histories[i].detail.replace("'", '"')): - if histories[i].detail != None and ('add_rrests' in json.loads(histories[i].detail.replace("'", '"')) or 'del_rrests' in json.loads(histories[i].detail.replace("'", '"'))): + 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])) @@ -895,7 +938,7 @@ def history(): }), 500) - if request.method == 'GET': + if request.method == 'GET': doms = accounts = users = "" if current_user.role.name in [ 'Administrator', 'Operator']: all_domain_names = Domain.query.all() @@ -903,7 +946,7 @@ def history(): all_user_names = User.query.all() - + for d in all_domain_names: doms += d.name + " " for acc in all_account_names: @@ -931,9 +974,9 @@ def history(): AccountUser.user_id == current_user.id )).all() - + all_user_names = [] - for a in all_account_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) \ @@ -951,11 +994,11 @@ def history(): 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 + " " + 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 @@ -1005,7 +1048,7 @@ def history_table(): # ajax call data if current_user.role.name in [ 'Administrator', 'Operator' ]: base_query = History.query else: - # if the user isn't an administrator or operator, + # 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) \ @@ -1020,7 +1063,7 @@ def history_table(): # ajax call data )) 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 + 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 \ @@ -1217,8 +1260,7 @@ def setting_basic(): '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', - 'delete_sso_accounts', 'otp_field_enabled', 'custom_css', 'enable_api_rr_history', 'max_history_records' - + '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) diff --git a/powerdnsadmin/routes/api.py b/powerdnsadmin/routes/api.py index ccbc460..4fce368 100644 --- a/powerdnsadmin/routes/api.py +++ b/powerdnsadmin/routes/api.py @@ -21,16 +21,18 @@ from ..lib.errors import ( DomainNotExists, DomainAlreadyExists, DomainAccessForbidden, RequestIsNotJSON, ApiKeyCreateFail, ApiKeyNotUsable, NotEnoughPrivileges, AccountCreateFail, AccountUpdateFail, AccountDeleteFail, - AccountCreateDuplicate, + 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_or_basic_auth, + apikey_can_create_domain, apikey_can_remove_domain, + apikey_is_admin, apikey_can_access_domain, apikey_can_configure_dnssec, + api_role_can, apikey_or_basic_auth, + callback_if_request_body_contains_key, ) -import random +import secrets import string api_bp = Blueprint('api', __name__, url_prefix='/api/v1') @@ -307,6 +309,7 @@ def api_generate_apikey(): role_name = None apikey = None domain_obj_list = [] + account_obj_list = [] abort(400) if 'role' not in data else None @@ -317,6 +320,13 @@ def api_generate_apikey(): else: domains = [d['name'] if isinstance(d, dict) else d for d in data['domains']] + if 'accounts' not in data: + accounts = [] + elif not isinstance(data['accounts'], (list, )): + abort(400) + else: + accounts = [a['name'] if isinstance(a, dict) else a for a in data['accounts']] + description = data['description'] if 'description' in data else None if isinstance(data['role'], str): @@ -326,16 +336,24 @@ def api_generate_apikey(): else: abort(400) - if role_name == 'User' and len(domains) == 0: - current_app.logger.error("Apikey with User role must have domains") + 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 @@ -345,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] @@ -363,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() @@ -476,9 +500,16 @@ def api_update_apikey(apikey_id): # if role different and user is allowed to change it, update # if apikey domains are different and user is allowed to handle # that domains update domains + domain_obj_list = None + account_obj_list = None + + apikey = ApiKey.query.get(apikey_id) + + if not apikey: + abort(404) + data = request.get_json() description = data['description'] if 'description' in data else None - domain_obj_list = None if 'role' in data: if isinstance(data['role'], str): @@ -487,8 +518,11 @@ def api_update_apikey(apikey_id): role_name = data['role']['name'] else: abort(400) + + target_role = role_name else: role_name = None + target_role = apikey.role.name if 'domains' not in data: domains = None @@ -497,22 +531,54 @@ def api_update_apikey(apikey_id): else: domains = [d['name'] if isinstance(d, dict) else d for d in data['domains']] - apikey = ApiKey.query.get(apikey_id) - - if not apikey: - abort(404) + 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': @@ -520,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() @@ -545,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 @@ -559,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)) @@ -621,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' @@ -856,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() @@ -876,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) @@ -957,6 +1025,28 @@ def api_remove_account_user(account_id, user_id): return '', 204 +@api_bp.route( + '/servers//zones//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//zones//cryptokeys/', + 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//zones//', methods=['GET', 'POST', 'PUT', 'PATCH', 'DELETE']) @@ -971,6 +1061,10 @@ def api_zone_subpath_forward(server_id, zone_id, subpath): methods=['GET', 'PUT', 'PATCH', 'DELETE']) @apikey_auth @apikey_can_access_domain +@apikey_can_remove_domain(http_methods=['DELETE']) +@callback_if_request_body_contains_key(apikey_can_configure_dnssec()(), + http_methods=['PUT'], + keys=['dnssec', 'nsec3param']) def api_zone_forward(server_id, zone_id): resp = helper.forward_request() if not Setting().get('bg_domain_updates'): @@ -1004,6 +1098,7 @@ def api_zone_forward(server_id, zone_id): history.add() return resp.content, resp.status_code, resp.headers.items() + @api_bp.route('/servers/', methods=['GET', 'PUT']) @apikey_auth @apikey_is_admin @@ -1014,6 +1109,7 @@ def api_server_sub_forward(subpath): @api_bp.route('/servers//zones', methods=['POST']) @apikey_auth +@apikey_can_create_domain def api_create_zone(server_id): resp = helper.forward_request() @@ -1055,8 +1151,13 @@ def api_get_zones(server_id): and resp.status_code == 200): domain_list = [d['name'] for d in domain_schema.dump(g.apikey.domains)] + + accounts_domains = [d.name for a in g.apikey.accounts for d in a.domains] + allowed_domains = set(domain_list + accounts_domains) + current_app.logger.debug("Account domains: {}".format( + '/'.join(accounts_domains))) content = json.dumps([i for i in json.loads(resp.content) - if i['name'].rstrip('.') in domain_list]) + 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() diff --git a/powerdnsadmin/routes/domain.py b/powerdnsadmin/routes/domain.py index 1142aea..e3b61cc 100644 --- a/powerdnsadmin/routes/domain.py +++ b/powerdnsadmin/routes/domain.py @@ -340,7 +340,8 @@ def record_changelog(domain_name, record_name, record_type): 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) + return render_template('domain_changelog.html', domain=domain, allHistoryChanges=changes_set_of_record, + record_name = record_name, record_type = record_type) diff --git a/powerdnsadmin/routes/index.py b/powerdnsadmin/routes/index.py index aaa3630..ae63bde 100644 --- a/powerdnsadmin/routes/index.py +++ b/powerdnsadmin/routes/index.py @@ -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 @@ -168,10 +169,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 +195,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 @@ -367,10 +364,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) @@ -434,9 +428,7 @@ def login(): 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) @@ -513,9 +505,7 @@ def login(): user.revoke_privilege(True) current_app.logger.warning('Procceding to revoke every privilige from ' + user.username + '.' ) - login_user(user, remember=remember_me) - signin_history(user.username, 'LOCAL', True) - return redirect(session.get('next', url_for('index.index'))) + return authenticate_user(user, 'LOCAL', remember_me) def checkForPDAEntries(Entitlements, urn_value): """ @@ -585,6 +575,23 @@ def get_azure_groups(uri): mygroups = [] return mygroups +# Handle user login, write history and, if set, handle showing the register_otp QR code. +# if Setting for OTP on first login is enabled, and OTP field is also enabled, +# but user isn't using it yet, enable OTP, get QR code and display it, logging the user out. +def authenticate_user(user, authenticator, remember=False): + login_user(user, remember=remember) + signin_history(user.username, authenticator, True) + if Setting().get('otp_force') and Setting().get('otp_field_enabled') and not user.otp_secret: + user.update_profile(enable_otp=True) + user_id = current_user.id + prepare_welcome_user(user_id) + return redirect(url_for('index.welcome')) + return redirect(url_for('index.login')) + +# Prepare user to enter /welcome screen, otherwise they won't have permission to do so +def prepare_welcome_user(user_id): + logout_user() + session['welcome_user_id'] = user_id @index_bp.route('/logout') def logout(): @@ -778,7 +785,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']) @@ -788,6 +800,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/', methods=['GET']) def confirm_email(token): email = confirm_token(token) @@ -1141,9 +1175,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) diff --git a/powerdnsadmin/routes/user.py b/powerdnsadmin/routes/user.py index 924b936..ef6c8c2 100644 --- a/powerdnsadmin/routes/user.py +++ b/powerdnsadmin/routes/user.py @@ -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 @@ -97,13 +94,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' - } + } \ No newline at end of file diff --git a/powerdnsadmin/static/custom/js/custom.js b/powerdnsadmin/static/custom/js/custom.js index 9bd9669..395e1ac 100644 --- a/powerdnsadmin/static/custom/js/custom.js +++ b/powerdnsadmin/static/custom/js/custom.js @@ -285,4 +285,14 @@ function timer(elToUpdate, maxTime) { }, 1000); return interval; -} \ No newline at end of file +} + +// 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); + } \ No newline at end of file diff --git a/powerdnsadmin/swagger-spec.yaml b/powerdnsadmin/swagger-spec.yaml index 6c7e575..dbf484e 100644 --- a/powerdnsadmin/swagger-spec.yaml +++ b/powerdnsadmin/swagger-spec.yaml @@ -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,14 +965,16 @@ paths: description: OK. schema: $ref: '#/definitions/ApiKey' - '403': - description: 'The authenticated user has User role and is not allowed on any of the domains assigned to the key' - '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: @@ -925,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: @@ -938,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: @@ -957,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: @@ -980,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: @@ -1038,7 +1127,11 @@ 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': @@ -1049,6 +1142,7 @@ paths: description: Internal Server Error. There was a problem creating the user schema: $ref: '#/definitions/Error' + '/pdnsadmin/users/{username}': parameters: - name: username @@ -1068,6 +1162,10 @@ paths: description: Retrieve a specific User schema: $ref: '#/definitions/UserDetailed' + '401': + description: 'Unauthorized' + schema: + $ref: '#/definitions/Error' '404': description: Not found. The User with the specified username does not exist schema: @@ -1076,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 @@ -1129,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: @@ -1147,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: @@ -1155,6 +1270,7 @@ paths: description: Internal Server Error. Contains error message schema: $ref: '#/definitions/Error' + '/pdnsadmin/accounts': get: security: @@ -1170,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: @@ -1207,7 +1323,11 @@ 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': @@ -1218,6 +1338,7 @@ paths: description: Internal Server Error. There was a problem creating the account schema: $ref: '#/definitions/Error' + '/pdnsadmin/accounts/{account_name}': parameters: - name: account_name @@ -1237,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 @@ -1281,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: @@ -1299,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: @@ -1307,6 +1441,7 @@ paths: description: Internal Server Error. Contains error message schema: $ref: '#/definitions/Error' + '/pdnsadmin/accounts/{account_id}/users': parameters: - name: account_id @@ -1329,14 +1464,46 @@ paths: type: array items: $ref: '#/definitions/User' + '401': + description: 'Unauthorized' + schema: + $ref: '#/definitions/Error' '404': description: Not found. The Account with the specified account_id does not exist schema: $ref: '#/definitions/Error' - '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' + '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 @@ -1360,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: @@ -1379,6 +1554,73 @@ paths: responses: '204': description: OK. User is unlinked (empty response body) + '401': + description: 'Unauthorized' + schema: + $ref: '#/definitions/Error' + '404': + description: Not found. The Account or User with the specified id does not exist or user was not linked to account + schema: + $ref: '#/definitions/Error' + '500': + description: Internal Server Error. Contains error message + schema: + $ref: '#/definitions/Error' + + '/pdnsadmin/accounts/users/{account_id}/{user_id}': + parameters: + - name: account_id + type: integer + in: path + required: true + description: The id of the account to link/unlink users to account + - name: user_id + type: integer + in: path + required: true + description: The id of the user to (un)link to/from account + put: + security: + - basicAuth: [] + summary: Link user to account + operationId: api_add_user_account + tags: + - account + - user + responses: + '204': + description: OK. User is linked (empty response body) + '400': + description: 'Request is not JSON' + schema: + $ref: '#/definitions/Error' + '401': + description: 'Unauthorized' + schema: + $ref: '#/definitions/Error' + '404': + description: Not found. The Account or User with the specified id does not exist + schema: + $ref: '#/definitions/Error' + '500': + description: Internal Server Error. Contains error message + schema: + $ref: '#/definitions/Error' + delete: + security: + - basicAuth: [] + summary: Unlink user from account + operationId: api_remove_user_account + tags: + - account + - user + responses: + '204': + description: OK. User is unlinked (empty response body) + '401': + description: 'Unauthorized' + schema: + $ref: '#/definitions/Error' '404': description: Not found. The Account or User with the specified id does not exist or user was not linked to account schema: @@ -1598,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: @@ -1624,7 +1867,7 @@ definitions: 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 @@ -1644,6 +1887,23 @@ definitions: description: type: string description: 'Some user defined description' + accounts: + type: array + description: 'A list of accounts bound to this ApiKey' + items: + $ref: '#/definitions/AccountSummary' + + ApiKeySummary: + title: ApiKeySummary + description: Summary of an ApiKey + properties: + id: + type: integer + description: 'The ID for this key, used in the ApiKey URL endpoint.' + readOnly: true + description: + type: string + description: 'Some user defined description' User: title: User @@ -1751,6 +2011,12 @@ 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 @@ -1764,6 +2030,9 @@ definitions: type: string description: The name for this account (unique, immutable) readOnly: false + domains: + description: The list of domains owned by this account + $ref: '#/definitions/PDNSAdminZones' ConfigSetting: title: ConfigSetting diff --git a/powerdnsadmin/templates/admin_edit_key.html b/powerdnsadmin/templates/admin_edit_key.html index d89cad1..7c8ca17 100644 --- a/powerdnsadmin/templates/admin_edit_key.html +++ b/powerdnsadmin/templates/admin_edit_key.html @@ -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 %} Edit Key - {{ SITE_NAME }} {% endblock %} @@ -49,10 +50,26 @@ class="glyphicon glyphicon-pencil form-control-feedback"> -
-

Access Control

+ -
+ + +
@@ -91,6 +108,48 @@ {% endblock %} {% block extrascripts %} + {%- endassets %} + {% assets "js_validation" -%} + + {%- endassets %} + \ No newline at end of file diff --git a/powerdnsadmin/templates/user_profile.html b/powerdnsadmin/templates/user_profile.html index 8acffdd..5445980 100644 --- a/powerdnsadmin/templates/user_profile.html +++ b/powerdnsadmin/templates/user_profile.html @@ -114,6 +114,14 @@ {% if current_user.otp_secret %}

+
+ Your secret key is:
+
+ + +
Copied. +
+
You can use Google Authenticator (Android - iOS) on your smartphone to scan the QR code.
- Make sure only you can see this QR Code and - nobody can capture it. + Make sure only you can see this QR Code and secret key and + nobody can capture them.
{% endif %} diff --git a/requirements.txt b/requirements.txt index bb36841..2bb91cf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/swagger-specv2.yaml b/swagger-specv2.yaml deleted file mode 100644 index e20b8a0..0000000 --- a/swagger-specv2.yaml +++ /dev/null @@ -1,1990 +0,0 @@ -swagger: '2.0' -info: - version: "0.0.13" - title: PowerDNS Admin Authoritative HTTP API - license: - name: MIT -host: localhost:80 -basePath: /api/v1 -schemes: - - http -consumes: - - application/json -produces: - - application/json -securityDefinitions: - # X-API-Key: abcdef12345 - APIKeyHeader: - type: apiKey - in: header - name: X-API-Key - basicAuth: - type: basic - -# Overall TODOS: -# TODO: Return types are not consistent across documentation -# We need to look at the code and figure out the default HTTP response -# codes and adjust docs accordingly. -paths: - '/servers': - get: - security: - - APIKeyHeader: [] - summary: List all servers - operationId: listServers - tags: - - servers - responses: - '200': - description: An array of servers - schema: - type: array - items: - $ref: '#/definitions/Server' - - '/sync_domains': - get: - security: - - APIKeyHeader: [] - summary: Sync PDNS with PDNSAdmin - operationId: synchronizeDomains - tags: - - pdnsadmin_zones - responses: - '200': - description: Synchronize PDNS Domains with PDNSAdmin - '403': - description: Wrong authentication - - '/servers/{server_id}': - get: - security: - - APIKeyHeader: [] - summary: List a server - operationId: listServer - tags: - - servers - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - responses: - '200': - description: A server - schema: - $ref: '#/definitions/Server' - - '/servers/{server_id}/cache/flush': - put: - security: - - APIKeyHeader: [] - summary: Flush a cache-entry by name - operationId: cacheFlushByName - tags: - - servers - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: domain - in: query - required: true - description: The domain name to flush from the cache - type: string - responses: - '200': - description: Flush successful - schema: - $ref: '#/definitions/CacheFlushResult' - - '/servers/{server_id}/zones': - get: - security: - - APIKeyHeader: [] - summary: List all Zones in a server - operationId: listZones - tags: - - zones - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: zone - in: query - required: false - type: string - description: | - When set to the name of a zone, only this zone is returned. - If no zone with that name exists, the response is an empty array. - This can e.g. be used to check if a zone exists in the database without having to guess/encode the zone's id or to check if a zone exists. - responses: - '200': - description: An array of Zones - schema: - type: array - items: - $ref: '#/definitions/Zone' - post: - security: - - APIKeyHeader: [] - summary: Creates a new domain, returns the Zone on creation. - operationId: createZone - tags: - - zones - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: rrsets - in: query - description: '“true” (default) or “false”, whether to include the “rrsets” in the response Zone object.' - type: boolean - default: true - - name: zone_struct - description: The zone struct to patch with - required: true - in: body - schema: - $ref: '#/definitions/Zone' - responses: - '201': - description: A zone - schema: - $ref: '#/definitions/Zone' - - '/servers/{server_id}/zones/{zone_id}': - get: - security: - - APIKeyHeader: [] - summary: zone managed by a server - operationId: listZone - tags: - - zones - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: zone_id - type: string - in: path - required: true - description: The id of the zone to retrieve - responses: - '200': - description: A Zone - schema: - $ref: '#/definitions/Zone' - delete: - security: - - APIKeyHeader: [] - summary: Deletes this zone, all attached metadata and rrsets. - operationId: deleteZone - tags: - - zones - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: zone_id - type: string - in: path - required: true - description: The id of the zone to retrieve - responses: - '204': - description: 'Returns 204 No Content on success.' - patch: - security: - - APIKeyHeader: [] - summary: 'Creates/modifies/deletes RRsets present in the payload and their comments. Returns 204 No Content on success.' - operationId: patchZone - tags: - - zones - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: zone_id - type: string - in: path - required: true - - name: zone_struct - description: The zone struct to patch with - required: true - in: body - schema: - $ref: '#/definitions/Zone' - responses: - '204': - description: 'Returns 204 No Content on success.' - - put: - security: - - APIKeyHeader: [] - summary: Modifies basic zone data (metadata). - description: 'Allowed fields in client body: all except id, url and name. Returns 204 No Content on success.' - operationId: putZone - tags: - - zones - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: zone_id - type: string - in: path - required: true - - name: zone_struct - description: The zone struct to patch with - required: true - in: body - schema: - $ref: '#/definitions/Zone' - responses: - '204': - description: 'Returns 204 No Content on success.' - - '/servers/{server_id}/zones/{zone_id}/notify': - put: - security: - - APIKeyHeader: [] - summary: Send a DNS NOTIFY to all slaves. - description: 'Fails when zone kind is not Master or Slave, or master and slave are disabled in the configuration. Only works for Slave if renotify is on. Clients MUST NOT send a body.' - operationId: notifyZone - tags: - - zones - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: zone_id - type: string - in: path - required: true - description: The id of the zone to retrieve - responses: - '200': - description: OK - - '/servers/{server_id}/zones/{zone_id}/axfr-retrieve': - put: - security: - - APIKeyHeader: [] - summary: Retrieve slave zone from its master. - description: 'Fails when zone kind is not Slave, or slave is disabled in the configuration. Clients MUST NOT send a body.' - operationId: axfrRetrieveZone - tags: - - zones - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: zone_id - type: string - in: path - required: true - description: The id of the zone to retrieve - responses: - '200': - description: OK - - '/servers/{server_id}/zones/{zone_id}/export': - get: - security: - - APIKeyHeader: [] - summary: 'Returns the zone in AXFR format.' - operationId: axfrExportZone - tags: - - zones - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: zone_id - type: string - in: path - required: true - description: The id of the zone to retrieve - responses: - '200': - description: OK - schema: - type: string - - '/servers/{server_id}/zones/{zone_id}/check': - get: - security: - - APIKeyHeader: [] - summary: 'Verify zone contents/configuration.' - operationId: checkZone - tags: - - zones - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: zone_id - type: string - in: path - required: true - description: The id of the zone to retrieve - responses: - '200': - description: OK - schema: - type: string - - '/servers/{server_id}/zones/{zone_id}/rectify': - put: - security: - - APIKeyHeader: [] - summary: 'Rectify the zone data.' - description: 'This does not take into account the API-RECTIFY metadata. Fails on slave zones and zones that do not have DNSSEC.' - operationId: rectifyZone - tags: - - zones - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: zone_id - type: string - in: path - required: true - description: The id of the zone to retrieve - responses: - '200': - description: OK - schema: - type: string - - '/servers/{server_id}/config': - get: - security: - - APIKeyHeader: [] - summary: 'Returns all ConfigSettings for a single server' - operationId: getConfig - tags: - - config - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - responses: - '200': - description: List of config values - schema: - type: array - items: - $ref: '#/definitions/ConfigSetting' - - '/servers/{server_id}/config/{config_setting_name}': - get: - security: - - APIKeyHeader: [] - summary: 'Returns a specific ConfigSetting for a single server' - description: 'NOT IMPLEMENTED' - operationId: getConfigSetting - tags: - - config - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: config_setting_name - in: path - required: true - description: The name of the setting to retrieve - type: string - responses: - '200': - description: List of config values - schema: - $ref: '#/definitions/ConfigSetting' - - '/servers/{server_id}/statistics': - get: - security: - - APIKeyHeader: [] - summary: 'Query statistics.' - description: 'Query PowerDNS internal statistics. Returns a list of BaseStatisticItem derived elements.' - operationId: getStats - tags: - - stats - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - responses: - '200': - description: List of Statistic Items - schema: - type: array - items: - # these can be commented because the swagger code generator fails on them - # and replaced with - # type: string - # or something like that - $ref: '#/definitions/MapStatisticItem' - # - $ref: '#/definitions/StatisticItem' - # - $ref: '#/definitions/RingStatisticItem' - - '/servers/{server_id}/search-data': - get: - security: - - APIKeyHeader: [] - summary: 'Search the data inside PowerDNS' - description: 'Search the data inside PowerDNS for search_term and return at most max_results. This includes zones, records and comments. The * character can be used in search_term as a wildcard character and the ? character can be used as a wildcard for a single character.' - operationId: searchData - tags: - - search - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: q - in: query - required: true - description: 'The string to search for' - type: string - - name: max - in: query - required: true - description: 'Maximum number of entries to return' - type: integer - responses: - '200': - description: Returns a JSON array with results - schema: - $ref: '#/definitions/SearchResults' - - '/servers/{server_id}/zones/{zone_id}/metadata': - get: - security: - - APIKeyHeader: [] - summary: Get all the MetaData associated with the zone. - operationId: listMetadata - tags: - - zonemetadata - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: zone_id - type: string - in: path - required: true - description: The id of the zone to retrieve - responses: - '200': - description: List of Metadata objects - schema: - type: array - items: - $ref: '#/definitions/Metadata' - post: - security: - - APIKeyHeader: [] - summary: 'Creates a set of metadata entries' - description: 'Creates a set of metadata entries of given kind for the zone. Existing metadata entries for the zone with the same kind are not overwritten.' - operationId: createMetadata - tags: - - zonemetadata - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: zone_id - type: string - in: path - required: true - - name: metadata - description: List of metadata to add/create - required: true - in: body - schema: - type: array - items: - $ref: '#/definitions/Metadata' - responses: - '204': - description: OK - - '/servers/{server_id}/zones/{zone_id}/metadata/{metadata_kind}': - get: - security: - - APIKeyHeader: [] - summary: Get the content of a single kind of domain metadata as a list of MetaData objects. - operationId: getMetadata - tags: - - zonemetadata - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: zone_id - type: string - in: path - required: true - description: The id of the zone to retrieve - - name: metadata_kind - type: string - in: path - required: true - description: '???' - responses: - '200': - description: List of Metadata objects - schema: - $ref: '#/definitions/Metadata' - put: - security: - - APIKeyHeader: [] - summary: 'Modify the content of a single kind of domain metadata.' - operationId: modifyMetadata - tags: - - zonemetadata - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: zone_id - type: string - in: path - required: true - - name: metadata_kind - description: The kind of metadata - required: true - type: string - in: path - - name: metadata - description: metadata to add/create - required: true - in: body - schema: - $ref: '#/definitions/Metadata' - responses: - '204': - description: OK - delete: - security: - - APIKeyHeader: [] - summary: 'Delete all items of a single kind of domain metadata.' - operationId: deleteMetadata - tags: - - zonemetadata - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: zone_id - type: string - in: path - required: true - description: The id of the zone to retrieve - - name: metadata_kind - type: string - in: path - required: true - description: '???' - responses: - '204': - description: OK - - '/servers/{server_id}/zones/{zone_id}/cryptokeys': - get: - security: - - APIKeyHeader: [] - summary: 'Get all CryptoKeys for a zone, except the privatekey' - operationId: listCryptokeys - tags: - - zonecryptokey - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: zone_id - type: string - in: path - required: true - description: The id of the zone to retrieve - responses: - '200': - description: List of Cryptokey objects - schema: - type: array - items: - $ref: '#/definitions/Cryptokey' - post: - security: - - APIKeyHeader: [] - summary: 'Creates a Cryptokey' - description: 'This method adds a new key to a zone. The key can either be generated or imported by supplying the content parameter. if content, bits and algo are null, a key will be generated based on the default-ksk-algorithm and default-ksk-size settings for a KSK and the default-zsk-algorithm and default-zsk-size options for a ZSK.' - operationId: createCryptokey - tags: - - zonecryptokey - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: zone_id - type: string - in: path - required: true - - name: cryptokey - description: Add a Cryptokey - required: true - in: body - schema: - $ref: '#/definitions/Cryptokey' - responses: - '201': - description: Created - schema: - $ref: '#/definitions/Cryptokey' - - '/servers/{server_id}/zones/{zone_id}/cryptokeys/{cryptokey_id}': - get: - security: - - APIKeyHeader: [] - summary: 'Returns all data about the CryptoKey, including the privatekey.' - operationId: getCryptokey - tags: - - zonecryptokey - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: zone_id - type: string - in: path - required: true - description: The id of the zone to retrieve - - name: cryptokey_id - type: string - in: path - required: true - description: 'The id value of the CryptoKey' - responses: - '200': - description: Cryptokey - schema: - $ref: '#/definitions/Cryptokey' - put: - security: - - APIKeyHeader: [] - summary: 'This method (de)activates a key from zone_name specified by cryptokey_id' - operationId: modifyCryptokey - tags: - - zonecryptokey - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: zone_id - type: string - in: path - required: true - - name: cryptokey_id - description: Cryptokey to manipulate - required: true - in: path - type: string - - name: cryptokey - description: the Cryptokey - required: true - in: body - schema: - $ref: '#/definitions/Cryptokey' - responses: - '204': - description: OK - '422': - description: 'Returned when something is wrong with the content of the request. Contains an error message' - delete: - security: - - APIKeyHeader: [] - summary: 'This method deletes a key specified by cryptokey_id.' - operationId: deleteCryptokey - tags: - - zonecryptokey - parameters: - - name: server_id - in: path - required: true - description: The id of the server to retrieve - type: string - - name: zone_id - type: string - in: path - required: true - description: The id of the zone to retrieve - - name: cryptokey_id - type: string - in: path - required: true - description: 'The id value of the Cryptokey' - responses: - '204': - description: OK - '422': - description: 'Returned when something is wrong with the content of the request. Contains an error message' - - '/pdnsadmin/zones': - get: - security: - - basicAuth: [] - summary: List all Zones in a server - operationId: api_login_list_zones - tags: - - pdnsadmin_zones - responses: - '200': - description: An array of Zones - schema: - type: array - items: - $ref: '#/definitions/PDNSAdminZones' - post: - security: - - basicAuth: [] - summary: Creates a new domain, returns the Zone on creation. - operationId: api_login_create_zone - tags: - - pdnsadmin_zones - parameters: - - name: zone_struct - description: The zone struct to patch with - required: true - in: body - schema: - $ref: '#/definitions/Zone' - responses: - '201': - description: A zone - schema: - $ref: '#/definitions/Zone' - '/pdnsadmin/zones/{zone_id}': - parameters: - - name: zone_id - type: string - in: path - required: true - description: The id of the zone to retrieve (without dot) - delete: - security: - - basicAuth: [] - summary: Deletes this zone, all attached metadata and rrsets. - operationId: api_login_delete_zone - tags: - - pdnsadmin_zones - parameters: - - name: zone_id - type: string - in: path - required: true - description: The id of the zone to retrieve - responses: - '204': - description: 'Returns 204 No Content on success.' - '/pdnsadmin/apikeys': - get: - security: - - basicAuth: [] - summary: 'Get all ApiKey on the server, except the actual key' - operationId: api_get_apikeys - tags: - - apikey - responses: - '200': - description: List of ApiKey objects - schema: - type: array - items: - $ref: '#/definitions/ApiKey' - '500': - description: 'Internal Server Error, keys could not be retrieved. Contains error message' - schema: - $ref: '#/definitions/Error' - post: - security: - - basicAuth: [] - summary: 'Add a ApiKey key' - description: 'This methods add a new ApiKey. The actual key can be generated by the server or be provided by the client' - operationId: api_generate_apikey - tags: - - apikey - parameters: - - name: apikey - description: The ApiKey to add - required: true - in: body - schema: - $ref: '#/definitions/ApiKey' - responses: - '201': - description: Created - schema: - $ref: '#/definitions/ApiKey' - '422': - description: 'Unprocessable Entry, the ApiKey provided has issues.' - schema: - $ref: '#/definitions/Error' - '500': - description: 'Internal Server Error. There was a problem creating the key' - schema: - $ref: '#/definitions/Error' - '/pdnsadmin/apikeys/{apikey_id}': - parameters: - - name: apikey_id - type: integer - in: path - required: true - description: The id of the apikey to retrieve - get: - security: - - basicAuth: [] - summary: 'Get a specific apikey on the server, hashed' - operationId: api_get_apikey_by_id - tags: - - apikey - responses: - '200': - description: OK. - schema: - $ref: '#/definitions/ApiKey' - '403': - description: 'The authenticated user has User role and is not allowed on any of the domains assigned to the key' - '404': - description: 'Not found. The ApiKey with the specified apikey_id does not exist' - schema: - $ref: '#/definitions/Error' - '500': - description: 'Internal Server Error, keys could not be retrieved. Contains error message' - schema: - $ref: '#/definitions/Error' - delete: - security: - - basicAuth: [] - summary: 'Delete the ApiKey with apikey_id' - operationId: api_delete_apikey - tags: - - apikey - responses: - '204': - description: 'OK, key was deleted' - '404': - description: 'Not found. The ApiKey with the specified apikey_id does not exist' - schema: - $ref: '#/definitions/Error' - '500': - description: 'Internal Server Error. Contains error message' - schema: - $ref: '#/definitions/Error' - put: - security: - - basicAuth: [] - description: | - The ApiKey at apikey_id can be changed in multiple ways: - * Role, description, domains can be updated - * Role can be changed to Administrator only if user has Operator or Administrator privileges - * Domains will be updated only if user has access to them - Only the relevant fields have to be provided in the request body. - operationId: api_update_apikey - tags: - - apikey - parameters: - - name: apikey - description: ApiKey object with the new values - schema: - $ref: '#/definitions/ApiKey' - in: body - required: true - responses: - '204': - description: OK. ApiKey is changed. - schema: - $ref: '#/definitions/ApiKey' - '404': - description: 'Not found. The TSIGKey with the specified tsigkey_id does not exist' - schema: - $ref: '#/definitions/Error' - '500': - description: 'Internal Server Error. Contains error message' - schema: - $ref: '#/definitions/Error' - '/pdnsadmin/users': - get: - security: - - basicAuth: [] - summary: 'Get all User entries' - operationId: api_list_users - tags: - - user - responses: - '200': - description: List of User objects - schema: - type: array - items: - $ref: '#/definitions/User' - '500': - description: Internal Server Error, users could not be retrieved. Contains error message - schema: - $ref: '#/definitions/Error' - post: - security: - - basicAuth: [] - summary: Add a User - description: This methods adds a new User - operationId: api_create_user - tags: - - user - parameters: - - in: body - name: user - description: The user to create - schema: - type: object - required: - - username - - email - properties: - username: - type: string - description: Login name for user (unique, immutable) - password: - type: string - description: Hashed password for authentication - plain_text_password: - type: string - description: Plain text password (will be hashed) for authentication - firstname: - type: string - description: Firstname of user - lastname: - type: string - description: Lastname of user - email: - type: string - description: Email address if user (must be unique) - otp_secret: - type: string - description: OTP secret - confirmed: - type: string - description: Confirmed status - role_name: - type: string - description: Name of role to be assigned to user (default 'User') - role_id: - type: integer - description: Role ID of role to be assigned to user - responses: - '201': - description: Created - schema: - $ref: '#/definitions/User' - '400': - description: Unprocessable Entry, the User data provided has issues - 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 - type: string - in: path - required: true - description: The username of the user to retrieve - get: - security: - - basicAuth: [] - summary: Get a specific User on the server - operationId: api_get_user - tags: - - user - responses: - '200': - description: Retrieve a specific User - schema: - $ref: '#/definitions/UserDetailed' - '404': - description: Not found. The User with the specified username does not exist - schema: - $ref: '#/definitions/Error' - '500': - description: Internal Server Error, user could not be retrieved. Contains error message - schema: - $ref: '#/definitions/Error' - '/pdnsadmin/users/{user_id}': - parameters: - - name: user_id - type: integer - in: path - required: true - description: The id of the user to modify or delete - put: - security: - - basicAuth: [] - summary: Modify a specific User on the server with supplied parameters - operationId: api_update_user - tags: - - user - parameters: - - in: body - name: user - schema: - type: object - properties: - username: - type: string - description: Login name for user (unique, immutable) - password: - type: string - description: Hashed password for authentication - plain_text_password: - type: string - description: Plain text password (will be hashed) for authentication - firstname: - type: string - description: Firstname of user - lastname: - type: string - description: Lastname of user - email: - type: string - description: Email address if user (must be unique) - otp_secret: - type: string - description: OTP secret - confirmed: - type: string - description: Confirmed status - role_name: - type: string - description: Name of role to be assigned to user (default 'User') - role_id: - type: string - description: Role id of role to be assigned to user - responses: - '204': - description: OK. User is modified (empty response body) - '404': - description: Not found. The User with the specified user_id does not exist - schema: - $ref: '#/definitions/Error' - '500': - description: Internal Server Error. Contains error message - schema: - $ref: '#/definitions/Error' - delete: - security: - - basicAuth: [] - summary: Delete a specific User - operationId: api_delete_user - tags: - - user - responses: - '204': - description: OK. User is deleted (empty response body) - '404': - description: Not found. The User with the specified user_id does not exist - schema: - $ref: '#/definitions/Error' - '500': - description: Internal Server Error. Contains error message - schema: - $ref: '#/definitions/Error' - '/pdnsadmin/accounts': - get: - security: - - basicAuth: [] - summary: Get all Account entries - operationId: api_list_accounts - tags: - - account - responses: - '200': - description: List of Account objects - schema: - type: array - items: - $ref: '#/definitions/Account' - '500': - description: Internal Server Error, accounts could not be retrieved. Contains error message - schema: - $ref: '#/definitions/Error' - post: - security: - - basicAuth: [] - summary: Add an Account - description: This methods adds a new Account - operationId: api_create_account - tags: - - account - parameters: - - in: body - name: account - schema: - required: - - name - properties: - name: - type: string - description: Name for account (unique, immutable) - description: - type: string - description: Description of account - contact: - type: string - description: Contact information - mail: - type: string - description: Email address for contact - responses: - '201': - description: Created - schema: - $ref: '#/definitions/Account' - '400': - description: Unprocessable Entry, the Account data provided has issues. - 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 - type: string - in: path - required: true - description: The name of the account to retrieve - get: - security: - - basicAuth: [] - summary: Get a specific Account on the server - operationId: api_get_account_by_name - tags: - - user - responses: - '200': - description: Retrieve a specific account - schema: - $ref: '#/definitions/Account' - '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 - type: integer - in: path - required: true - description: The id of the account to modify or delete - put: - security: - - basicAuth: [] - summary: Modify a specific Account on the server with supplied parameters - operationId: api_update_account - tags: - - user - parameters: - - in: body - name: account - schema: - required: - - name - properties: - name: - type: string - description: Name for account (unique, immutable) - description: - type: string - description: Description of account - contact: - type: string - description: Contact information - mail: - type: string - description: Email address for contact - responses: - '204': - description: OK. Account is modified (empty response body) - '404': - description: Not found. The Account with the specified account_id does not exist - schema: - $ref: '#/definitions/Error' - '500': - description: Internal Server Error. Contains error message - schema: - $ref: '#/definitions/Error' - delete: - security: - - basicAuth: [] - summary: Delete a specific Account - operationId: api_delete_account - tags: - - user - responses: - '204': - description: OK. Account is deleted (empty response body) - '404': - description: Not found. The Account with the specified account_id does not exist - schema: - $ref: '#/definitions/Error' - '500': - description: Internal Server Error. Contains error message - schema: - $ref: '#/definitions/Error' - - '/pdnsadmin/accounts/{account_id}/users': - 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_account_users - tags: - - account - - user - responses: - '200': - description: List of Summarized User objects - schema: - type: array - items: - $ref: '#/definitions/User' - '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 - schema: - $ref: '#/definitions/Error' - '/pdnsadmin/accounts/{account_id}/users/{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_account_user - tags: - - account - - user - responses: - '204': - description: OK. User is linked (empty response body) - '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_account_user - tags: - - account - - user - responses: - '204': - description: OK. User is unlinked (empty response body) - '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: - title: Server - properties: - type: - type: string - description: 'Set to “Server”' - id: - type: string - description: 'The id of the server, “localhost”' - daemon_type: - type: string - description: '“recursor” for the PowerDNS Recursor and “authoritative” for the Authoritative Server' - version: - type: string - description: 'The version of the server software' - url: - type: string - description: 'The API endpoint for this server' - config_url: - type: string - description: 'The API endpoint for this server’s configuration' - zones_url: - type: string - description: 'The API endpoint for this server’s zones' - - Servers: - type: array - items: - $ref: '#/definitions/Server' - - Zone: - title: Zone - description: This represents an authoritative DNS Zone. - properties: - id: - type: string - description: 'Opaque zone id (string), assigned by the server, should not be interpreted by the application. Guaranteed to be safe for embedding in URLs.' - name: - type: string - description: 'Name of the zone (e.g. “example.com.”) MUST have a trailing dot' - type: - type: string - description: 'Set to “Zone”' - url: - type: string - description: 'API endpoint for this zone' - kind: - type: string - enum: - - 'Native' - - 'Master' - - 'Slave' - description: 'Zone kind, one of “Native”, “Master”, “Slave”' - rrsets: - type: array - items: - $ref: '#/definitions/RRSet' - description: 'RRSets in this zone' - serial: - type: integer - description: 'The SOA serial number' - notified_serial: - type: integer - description: 'The SOA serial notifications have been sent out for' - masters: - type: array - items: - type: string - description: ' List of IP addresses configured as a master for this zone (“Slave” type zones only)' - dnssec: - type: boolean - description: 'Whether or not this zone is DNSSEC signed (inferred from presigned being true XOR presence of at least one cryptokey with active being true)' - nsec3param: - type: string - description: 'The NSEC3PARAM record' - nsec3narrow: - type: boolean - description: 'Whether or not the zone uses NSEC3 narrow' - presigned: - type: boolean - description: 'Whether or not the zone is pre-signed' - soa_edit: - type: string - description: 'The SOA-EDIT metadata item' - soa_edit_api: - type: string - description: 'The SOA-EDIT-API metadata item' - api_rectify: - type: boolean - description: ' Whether or not the zone will be rectified on data changes via the API' - zone: - type: string - description: 'MAY contain a BIND-style zone file when creating a zone' - account: - type: string - description: 'MAY be set. Its value is defined by local policy' - nameservers: - type: array - items: - type: string - description: 'MAY be sent in client bodies during creation, and MUST NOT be sent by the server. Simple list of strings of nameserver names, including the trailing dot. Not required for slave zones.' - tsig_master_key_ids: - type: array - items: - type: string - description: 'The id of the TSIG keys used for master operation in this zone' - externalDocs: - url: 'https://doc.powerdns.com/authoritative/tsig.html#provisioning-outbound-axfr-access' - tsig_slave_key_ids: - type: array - items: - type: string - description: 'The id of the TSIG keys used for slave operation in this zone' - externalDocs: - url: 'https://doc.powerdns.com/authoritative/tsig.html#provisioning-signed-notification-and-axfr-requests' - - Zones: - type: array - items: - $ref: '#/definitions/Zone' - - RRSet: - title: RRSet - description: This represents a Resource Record Set (all records with the same name and type). - required: - - name - - type - - ttl - - changetype - - records - properties: - name: - type: string - description: 'Name for record set (e.g. “www.powerdns.com.”)' - type: - type: string - description: 'Type of this record (e.g. “A”, “PTR”, “MX”)' - ttl: - type: integer - description: 'DNS TTL of the records, in seconds. MUST NOT be included when changetype is set to “DELETE”.' - changetype: - type: string - description: 'MUST be added when updating the RRSet. Must be REPLACE or DELETE. With DELETE, all existing RRs matching name and type will be deleted, including all comments. With REPLACE: when records is present, all existing RRs matching name and type will be deleted, and then new records given in records will be created. If no records are left, any existing comments will be deleted as well. When comments is present, all existing comments for the RRs matching name and type will be deleted, and then new comments given in comments will be created.' - records: - type: array - description: 'All records in this RRSet. When updating Records, this is the list of new records (replacing the old ones). Must be empty when changetype is set to DELETE. An empty list results in deletion of all records (and comments).' - items: - $ref: '#/definitions/Record' - comments: - type: array - description: 'List of Comment. Must be empty when changetype is set to DELETE. An empty list results in deletion of all comments. modified_at is optional and defaults to the current server time.' - items: - $ref: '#/definitions/Comment' - - Record: - title: Record - description: The RREntry object represents a single record. - required: - - content - - disabled # PatchZone endpoint complains if this is missing - properties: - content: - type: string - description: 'The content of this record' - disabled: - type: boolean - description: 'Whether or not this record is disabled' - set-ptr: - type: boolean - description: 'If set to true, the server will find the matching reverse zone and create a PTR there. Existing PTR records are replaced. If no matching reverse Zone, an error is thrown. Only valid in client bodies, only valid for A and AAAA types. Not returned by the server.' - - Comment: - title: Comment - description: A comment about an RRSet. - properties: - content: - type: string - description: 'The actual comment' - account: - type: string - description: 'Name of an account that added the comment' - modified_at: - type: integer - description: 'Timestamp of the last change to the comment' - - TSIGKey: - title: TSIGKey - description: A TSIG key that can be used to authenticate NOTIFYs and AXFRs - properties: - name: - type: string - description: 'The name of the key' - id: - type: string - description: 'The ID for this key, used in the TSIGkey URL endpoint.' - readOnly: true - algorithm: - type: string - description: 'The algorithm of the TSIG key' - key: - type: string - description: 'The Base64 encoded secret key, empty when listing keys. MAY be empty when POSTing to have the server generate the key material' - type: - type: string - description: 'Set to "TSIGKey"' - readOnly: true - - PDNSAdminZones: - title: PDNSAdminZones - description: A ApiKey that can be used to manage domains through API - type: array - items: - properties: - id: - type: integer - description: 'The ID for this PDNSAdmin zone' - readOnly: true - name: - type: string - description: 'Name of the zone' - - PDNSAdminRole: - title: PDNSAdminRole - description: Roles of PowerDNS Admin - properties: - id: - type: integer - description: 'The ID for this PDNSAdmin role' - readOnly: true - name: - type: string - description: 'The Name of PDNSAdmin role' - - ApiKey: - title: ApiKey - description: A ApiKey that can be used to manage domains through API - properties: - id: - type: integer - description: 'The ID for this key, used in the ApiKey URL endpoint.' - readOnly: true - plain_key: - type: string - description: 'ApiKey key is return in plain text only at first POST' - key: - type: string - description: 'not used on POST, POSTing to server generates the key material' - domains: - $ref: '#/definitions/PDNSAdminZones' - description: 'domains to which this apikey has access' - role: - $ref: '#/definitions/PDNSAdminRole' - description: - type: string - description: 'Some user defined description' - - User: - title: User - description: User that can access the gui/api - properties: - id: - type: integer - 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' - - UserDetailed: - title: User - description: User that can access the gui/api - properties: - id: - type: integer - 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 - description: 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 - description: - type: string - description: The description for this account - readOnly: false - contact: - type: string - description: The contact details for this account - readOnly: false - mail: - type: string - description: The email address of the contact for this account - readOnly: false - - 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 - - ConfigSetting: - title: ConfigSetting - properties: - name: - type: string - description: 'set to "ConfigSetting"' - type: - type: string - description: 'The name of this setting (e.g. ‘webserver-port’)' - value: - type: string - description: 'The value of setting name' - - BaseStatisticItem: - title: BaseStatisticItem - properties: - name: - type: string - description: 'The name of this item (e.g. ‘uptime’)' - - StatisticItem: - title: StatisticItem - allOf: - - $ref: "#/definitions/BaseStatisticItem" - - properties: - type: - enum: [StatisticItem] - description: 'set to "StatisticItem"' - value: - type: string - description: 'The value of item' - - MapStatisticItem: - title: MapStatisticItem - allOf: - - $ref: "#/definitions/BaseStatisticItem" - - properties: - type: - enum: [MapStatisticItem] - description: 'set to "MapStatisticItem"' - value: - type: array - description: 'named statistic values' - items: - type: object - properties: - name: - type: string - description: 'item name' - value: - type: string - description: 'item value' - - RingStatisticItem: - title: RingStatisticItem - allOf: - - $ref: "#/definitions/BaseStatisticItem" - - properties: - type: - enum: [RingStatisticItem] - description: 'set to "RingStatisticItem"' - size: - type: integer - description: 'for RingStatisticItem objects, the size of the ring' - value: - type: array - description: 'named ring statistic values' - items: - type: object - properties: - name: - type: string - description: 'item name' - value: - type: string - description: 'item value' - - SearchResultZone: - title: SearchResultZone - properties: - name: - type: string - object_type: - type: string - description: 'set to "zone"' - zone_id: - type: string - - SearchResultRecord: - title: SearchResultRecord - properties: - content: - type: string - disabled: - type: boolean - name: - type: string - object_type: - type: string - description: 'set to "record"' - zone_id: - type: string - zone: - type: string - type: - type: string - ttl: - type: integer - - SearchResultComment: - title: SearchResultComment - properties: - content: - type: string - name: - type: string - object_type: - type: string - description: 'set to "comment"' - zone_id: - type: string - zone: - type: string - -# FIXME: This is problematic at the moment, because swagger doesn't support this type of mixed response -# SearchResult: -# anyOf: -# - $ref: '#/definitions/SearchResultZone' -# - $ref: '#/definitions/SearchResultRecord' -# - $ref: '#/definitions/SearchResultComment' - -# Since we can't do 'anyOf' at the moment, we create a 'superset object' - SearchResult: - title: SearchResult - properties: - content: - type: string - disabled: - type: boolean - name: - type: string - object_type: - type: string - description: 'set to one of "record, zone, comment"' - zone_id: - type: string - zone: - type: string - type: - type: string - ttl: - type: integer - - SearchResults: - type: array - items: - $ref: '#/definitions/SearchResult' - - Metadata: - title: Metadata - description: Represents zone metadata - properties: - kind: - type: string - description: 'Name of the metadata' - metadata: - type: array - items: - type: string - description: 'Array with all values for this metadata kind.' - - Cryptokey: - title: Cryptokey - description: 'Describes a DNSSEC cryptographic key' - properties: - type: - type: string - description: 'set to "Cryptokey"' - id: - type: string - description: 'The internal identifier, read only' - keytype: - type: string - enum: [ksk, zsk, csk] - active: - type: boolean - description: 'Whether or not the key is in active use' - dnskey: - type: string - description: 'The DNSKEY record for this key' - ds: - type: array - items: - type: string - description: 'An array of DS records for this key' - privatekey: - type: string - description: 'The private key in ISC format' - algorithm: - type: string - description: 'The name of the algorithm of the key, should be a mnemonic' - bits: - type: integer - description: 'The size of the key' - - Error: - title: Error - description: 'Returned when the server encounters an error. Either in client input or internally' - properties: - error: - type: string - description: 'A human readable error message' - errors: - type: array - items: - type: string - description: 'Optional array of multiple errors encountered during processing' - required: - - error - - CacheFlushResult: - title: CacheFlushResult - description: 'The result of a cache-flush' - properties: - count: - type: number - description: 'Amount of entries flushed' - result: - type: string - description: 'A message about the result like "Flushed cache"'