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

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

+
Authenticator Type:{{ authenticator }}
IP Address{{ ip_address }}
+ """, + background_rgba="68,157,68" if detail_dict['success'] == 1 else "201,48,44", + username=detail_dict['username'], + auth_result="success" if detail_dict['success'] == 1 else "failure", + authenticator=detail_dict['authenticator'], + ip_address=detail_dict['ip_address']) + + elif 'add_rrests' in detail_dict: # this is a domain record change + # changes_set = [] + self.detailed_msg = "" + # extract_changelogs_from_a_history_entry(changes_set, history, 0) + + elif 'name' in detail_dict and 'template' in history.msg: # template creation / deletion + self.detailed_msg = render_template_string(""" + + + +
Template name:{{ template_name }}
Description:{{ description }}
+ """, + template_name=DetailedHistory.get_key_val(detail_dict, "name"), + description=DetailedHistory.get_key_val(detail_dict, "description")) + + elif 'Change domain' in history.msg and 'access control' in history.msg: # added or removed a user from a domain + users_with_access = DetailedHistory.get_key_val(detail_dict, "user_has_access") + self.detailed_msg = render_template_string(""" + + + +
Users with access to this domain{{ users_with_access }}
Number of users:{{ users_with_access | length }}
+ """, + users_with_access=users_with_access) + + elif 'Created API key' in history.msg or 'Updated API key' in history.msg: + self.detailed_msg = render_template_string(""" + + + + + + +
Key: {{ keyname }}
Role:{{ rolename }}
Description:{{ description }}
Accessible domains with this API key:{{ linked_domains }}
Accessible accounts with this API key:{{ linked_accounts }}
+ """, + keyname=DetailedHistory.get_key_val(detail_dict, "key"), + rolename=DetailedHistory.get_key_val(detail_dict, "role"), + description=DetailedHistory.get_key_val(detail_dict, "description"), + linked_domains=DetailedHistory.get_key_val(detail_dict, "domains" if "domains" in detail_dict else "domain_acl"), + linked_accounts=DetailedHistory.get_key_val(detail_dict, "accounts")) + + elif 'Delete API key' in history.msg: + self.detailed_msg = render_template_string(""" + + + + + +
Key: {{ keyname }}
Role:{{ rolename }}
Description:{{ description }}
Accessible domains with this API key:{{ linked_domains }}
+ """, + keyname=DetailedHistory.get_key_val(detail_dict, "key"), + rolename=DetailedHistory.get_key_val(detail_dict, "role"), + description=DetailedHistory.get_key_val(detail_dict, "description"), + linked_domains=DetailedHistory.get_key_val(detail_dict, "domains")) + + elif 'Update type for domain' in history.msg: + self.detailed_msg = render_template_string(""" + + + + +
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(), ''))) + + +# convert a list of History objects into DetailedHistory objects +def convert_histories(histories): + changes_set = dict() + detailedHistories = [] + j = 0 + for i in range(len(histories)): + if histories[i].detail and ('add_rrests' in histories[i].detail or 'del_rrests' in histories[i].detail): + extract_changelogs_from_a_history_entry(changes_set, histories[i], j) + if j in changes_set: + detailedHistories.append(DetailedHistory(histories[i], changes_set[j])) + else: # no changes were found + detailedHistories.append(DetailedHistory(histories[i], None)) + j += 1 + + else: + detailedHistories.append(DetailedHistory(histories[i], None)) + return detailedHistories + @admin_bp.route('/history', methods=['GET', 'POST']) @login_required @history_access_required def history(): - if request.method == 'POST': - if current_user.role.name != 'Administrator': - return make_response( - jsonify({ - 'status': 'error', - 'msg': 'You do not have permission to remove history.' - }), 401) + if request.method == 'POST': + if current_user.role.name != 'Administrator': + return make_response( + jsonify({ + 'status': 'error', + 'msg': 'You do not have permission to remove history.' + }), 401) - h = History() - result = h.remove_all() - if result: - history = History(msg='Remove all histories', - created_by=current_user.username) - history.add() - return make_response( - jsonify({ - 'status': 'ok', - 'msg': 'Changed user role successfully.' - }), 200) - else: - return make_response( - jsonify({ - 'status': 'error', - 'msg': 'Can not remove histories.' - }), 500) + h = History() + result = h.remove_all() + if result: + history = History(msg='Remove all histories', + created_by=current_user.username) + history.add() + return make_response( + jsonify({ + 'status': 'ok', + 'msg': 'Changed user role successfully.' + }), 200) + else: + return make_response( + jsonify({ + 'status': 'error', + 'msg': 'Can not remove histories.' + }), 500) - if request.method == 'GET': - if current_user.role.name in [ 'Administrator', 'Operator' ]: - histories = History.query.all() - else: - # if the user isn't an administrator or operator, - # allow_user_view_history must be enabled to get here, - # so include history for the domains for the user - histories = db.session.query(History) \ - .join(Domain, History.domain_id == Domain.id) \ - .outerjoin(DomainUser, Domain.id == DomainUser.domain_id) \ - .outerjoin(Account, Domain.account_id == Account.id) \ - .outerjoin(AccountUser, Account.id == AccountUser.account_id) \ - .filter( - db.or_( - DomainUser.user_id == current_user.id, - AccountUser.user_id == current_user.id - )) - return render_template('admin_history.html', histories=histories) + if request.method == 'GET': + doms = accounts = users = "" + if current_user.role.name in [ 'Administrator', 'Operator']: + all_domain_names = Domain.query.all() + all_account_names = Account.query.all() + all_user_names = User.query.all() + + + + for d in all_domain_names: + doms += d.name + " " + for acc in all_account_names: + accounts += acc.name + " " + for usr in all_user_names: + users += usr.username + " " + else: # special autocomplete for users + all_domain_names = db.session.query(Domain) \ + .outerjoin(DomainUser, Domain.id == DomainUser.domain_id) \ + .outerjoin(Account, Domain.account_id == Account.id) \ + .outerjoin(AccountUser, Account.id == AccountUser.account_id) \ + .filter( + db.or_( + DomainUser.user_id == current_user.id, + AccountUser.user_id == current_user.id + )).all() + + all_account_names = db.session.query(Account) \ + .outerjoin(Domain, Domain.account_id == Account.id) \ + .outerjoin(DomainUser, Domain.id == DomainUser.domain_id) \ + .outerjoin(AccountUser, Account.id == AccountUser.account_id) \ + .filter( + db.or_( + DomainUser.user_id == current_user.id, + AccountUser.user_id == current_user.id + )).all() + + + all_user_names = [] + for a in all_account_names: + temp = db.session.query(User) \ + .join(AccountUser, AccountUser.user_id == User.id) \ + .outerjoin(Account, Account.id == AccountUser.account_id) \ + .filter( + db.or_( + Account.id == a.id, + AccountUser.account_id == a.id + ) + ) \ + .all() + for u in temp: + if u in all_user_names: + continue + all_user_names.append(u) + + for d in all_domain_names: + doms += d.name + " " + + for a in all_account_names: + accounts += a.name + " " + for u in all_user_names: + users += u.username + " " + return render_template('admin_history.html', all_domain_names=doms, all_account_names=accounts, all_usernames=users) + +# local_offset is the offset of the utc to the local time +# offset must be int +# return the date converted and simplified +def from_utc_to_local(local_offset, timeframe): + offset = str(local_offset *(-1)) + date_split = str(timeframe).split(".")[0] + date_converted = datetime.datetime.strptime(date_split, '%Y-%m-%d %H:%M:%S') + datetime.timedelta(minutes=int(offset)) + return date_converted + +@admin_bp.route('/history_table', methods=['GET', 'POST']) +@login_required +@history_access_required +def history_table(): # ajax call data + + if request.method == 'POST': + if current_user.role.name != 'Administrator': + return make_response( + jsonify({ + 'status': 'error', + 'msg': 'You do not have permission to remove history.' + }), 401) + + h = History() + result = h.remove_all() + if result: + history = History(msg='Remove all histories', + created_by=current_user.username) + history.add() + return make_response( + jsonify({ + 'status': 'ok', + 'msg': 'Changed user role successfully.' + }), 200) + else: + return make_response( + jsonify({ + 'status': 'error', + 'msg': 'Can not remove histories.' + }), 500) + + detailedHistories = [] + lim = int(Setting().get('max_history_records')) # max num of records + + if request.method == 'GET': + if current_user.role.name in [ 'Administrator', 'Operator' ]: + base_query = History.query + else: + # if the user isn't an administrator or operator, + # allow_user_view_history must be enabled to get here, + # so include history for the domains for the user + base_query = db.session.query(History) \ + .join(Domain, History.domain_id == Domain.id) \ + .outerjoin(DomainUser, Domain.id == DomainUser.domain_id) \ + .outerjoin(Account, Domain.account_id == Account.id) \ + .outerjoin(AccountUser, Account.id == AccountUser.account_id) \ + .filter( + db.or_( + DomainUser.user_id == current_user.id, + AccountUser.user_id == current_user.id + )) + + domain_name = request.args.get('domain_name_filter') if request.args.get('domain_name_filter') != None \ + and len(request.args.get('domain_name_filter')) != 0 else None + account_name = request.args.get('account_name_filter') if request.args.get('account_name_filter') != None \ + and len(request.args.get('account_name_filter')) != 0 else None + user_name = request.args.get('auth_name_filter') if request.args.get('auth_name_filter') != None \ + and len(request.args.get('auth_name_filter')) != 0 else None + + min_date = request.args.get('min') if request.args.get('min') != None and len( request.args.get('min')) != 0 else None + if min_date != None: # get 1 day earlier, to check for timezone errors + min_date = str(datetime.datetime.strptime(min_date, '%Y-%m-%d') - datetime.timedelta(days=1)) + max_date = request.args.get('max') if request.args.get('max') != None and len( request.args.get('max')) != 0 else None + if max_date != None: # get 1 day later, to check for timezone errors + max_date = str(datetime.datetime.strptime(max_date, '%Y-%m-%d') + datetime.timedelta(days=1)) + tzoffset = request.args.get('tzoffset') if request.args.get('tzoffset') != None and len(request.args.get('tzoffset')) != 0 else None + changed_by = request.args.get('user_name_filter') if request.args.get('user_name_filter') != None \ + and len(request.args.get('user_name_filter')) != 0 else None + """ + Auth methods: LOCAL, Github OAuth, Azure OAuth, SAML, OIDC OAuth, Google OAuth + """ + auth_methods = [] + if (request.args.get('auth_local_only_checkbox') is None \ + and request.args.get('auth_oauth_only_checkbox') is None \ + and request.args.get('auth_saml_only_checkbox') is None and request.args.get('auth_all_checkbox') is None): + auth_methods = [] + if request.args.get('auth_all_checkbox') == "on": + auth_methods.append("") + if request.args.get('auth_local_only_checkbox') == "on": + auth_methods.append("LOCAL") + if request.args.get('auth_oauth_only_checkbox') == "on": + auth_methods.append("OAuth") + if request.args.get('auth_saml_only_checkbox') == "on": + auth_methods.append("SAML") + + if request.args.get('domain_changelog_only_checkbox') != None: + changelog_only = True if request.args.get('domain_changelog_only_checkbox') == "on" else False + else: + changelog_only = False + + + + + # users cannot search for authentication + if user_name != None and current_user.role.name not in [ 'Administrator', 'Operator']: + histories = [] + elif domain_name != None: + + if not changelog_only: + histories = base_query \ + .filter( + db.and_( + db.or_( + History.msg.like("%domain "+ domain_name) if domain_name != "*" else History.msg.like("%domain%"), + History.msg.like("%domain "+ domain_name + " access control") if domain_name != "*" else History.msg.like("%domain%access control") + ), + History.created_on <= max_date if max_date != None else True, + History.created_on >= min_date if min_date != None else True, + History.created_by == changed_by if changed_by != None else True + ) + ).order_by(History.created_on.desc()).limit(lim).all() + else: + # search for records changes only + histories = base_query \ + .filter( + db.and_( + History.msg.like("Apply record changes to domain " + domain_name) if domain_name != "*" \ + else History.msg.like("Apply record changes to domain%"), + History.created_on <= max_date if max_date != None else True, + History.created_on >= min_date if min_date != None else True, + History.created_by == changed_by if changed_by != None else True + + ) + ).order_by(History.created_on.desc()) \ + .limit(lim).all() + elif account_name != None: + if current_user.role.name in ['Administrator', 'Operator']: + histories = base_query \ + .join(Domain, History.domain_id == Domain.id) \ + .outerjoin(Account, Domain.account_id == Account.id) \ + .filter( + db.and_( + Account.id == Domain.account_id, + account_name == Account.name if account_name != "*" else True, + History.created_on <= max_date if max_date != None else True, + History.created_on >= min_date if min_date != None else True, + History.created_by == changed_by if changed_by != None else True + ) + ).order_by(History.created_on.desc()) \ + .limit(lim).all() + else: + histories = base_query \ + .filter( + db.and_( + Account.id == Domain.account_id, + account_name == Account.name if account_name != "*" else True, + History.created_on <= max_date if max_date != None else True, + History.created_on >= min_date if min_date != None else True, + History.created_by == changed_by if changed_by != None else True + ) + ).order_by(History.created_on.desc()) \ + .limit(lim).all() + elif user_name != None and current_user.role.name in [ 'Administrator', 'Operator']: # only admins can see the user login-logouts + + histories = History.query \ + .filter( + db.and_( + db.or_( + History.msg.like("User "+ user_name + " authentication%") if user_name != "*" and user_name != None else History.msg.like("%authentication%"), + History.msg.like("User "+ user_name + " was not authorized%") if user_name != "*" and user_name != None else History.msg.like("User%was not authorized%") + ), + History.created_on <= max_date if max_date != None else True, + History.created_on >= min_date if min_date != None else True, + History.created_by == changed_by if changed_by != None else True + ) + ) \ + .order_by(History.created_on.desc()).limit(lim).all() + temp = [] + for h in histories: + for method in auth_methods: + if method in h.detail: + temp.append(h) + break + histories = temp + elif (changed_by != None or max_date != None) and current_user.role.name in [ 'Administrator', 'Operator'] : # select changed by and date filters only + histories = History.query \ + .filter( + db.and_( + History.created_on <= max_date if max_date != None else True, + History.created_on >= min_date if min_date != None else True, + History.created_by == changed_by if changed_by != None else True + ) + ) \ + .order_by(History.created_on.desc()).limit(lim).all() + elif (changed_by != None or max_date != None): # special filtering for user because one user does not have access to log-ins logs + histories = base_query \ + .filter( + db.and_( + History.created_on <= max_date if max_date != None else True, + History.created_on >= min_date if min_date != None else True, + History.created_by == changed_by if changed_by != None else True + ) + ) \ + .order_by(History.created_on.desc()).limit(lim).all() + elif max_date != None: # if changed by == null and only date is applied + histories = base_query.filter( + db.and_( + History.created_on <= max_date if max_date != None else True, + History.created_on >= min_date if min_date != None else True, + ) + ).order_by(History.created_on.desc()).limit(lim).all() + else: # default view + if current_user.role.name in [ 'Administrator', 'Operator']: + histories = History.query.order_by(History.created_on.desc()).limit(lim).all() + else: + histories = db.session.query(History) \ + .join(Domain, History.domain_id == Domain.id) \ + .outerjoin(DomainUser, Domain.id == DomainUser.domain_id) \ + .outerjoin(Account, Domain.account_id == Account.id) \ + .outerjoin(AccountUser, Account.id == AccountUser.account_id) \ + .order_by(History.created_on.desc()) \ + .filter( + db.or_( + DomainUser.user_id == current_user.id, + AccountUser.user_id == current_user.id + )).limit(lim).all() + + detailedHistories = convert_histories(histories) + + # Remove dates from previous or next day that were brought over + if tzoffset != None: + if min_date != None: + min_date_split = min_date.split()[0] + if max_date != None: + max_date_split = max_date.split()[0] + for i, history_rec in enumerate(detailedHistories): + local_date = str(from_utc_to_local(int(tzoffset), history_rec.history.created_on).date()) + if (min_date != None and local_date == min_date_split) or (max_date != None and local_date == max_date_split): + detailedHistories[i] = None + + # Remove elements previously flagged as None + detailedHistories = [h for h in detailedHistories if h is not None] + + return render_template('admin_history_table.html', histories=detailedHistories, len_histories=len(detailedHistories), lim=lim) + @admin_bp.route('/setting/basic', methods=['GET']) @@ -644,7 +1259,9 @@ def setting_basic(): 'pretty_ipv6_ptr', 'dnssec_admins_only', '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', 'otp_field_enabled', 'custom_css' + 'pdns_api_timeout', 'verify_ssl_connections', 'verify_user_email', + 'delete_sso_accounts', 'otp_field_enabled', 'custom_css', 'enable_api_rr_history', 'max_history_records' + ] return render_template('admin_setting_basic.html', settings=settings) @@ -986,6 +1603,8 @@ def setting_authentication(): request.form.get('oidc_oauth_token_url')) Setting().set('oidc_oauth_authorize_url', request.form.get('oidc_oauth_authorize_url')) + Setting().set('oidc_oauth_logout_url', + request.form.get('oidc_oauth_logout_url')) Setting().set('oidc_oauth_username', request.form.get('oidc_oauth_username')) Setting().set('oidc_oauth_firstname', diff --git a/powerdnsadmin/routes/api.py b/powerdnsadmin/routes/api.py index 0623c96..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'): @@ -979,30 +1073,32 @@ def api_zone_forward(server_id, zone_id): status = resp.status_code if 200 <= status < 300: current_app.logger.debug("Request to powerdns API successful") - if request.method in ['POST', 'PATCH'] : - data = request.get_json(force=True) - for rrset_data in data['rrsets']: - history = History(msg='{0} zone {1} record of {2}'.format( - rrset_data['changetype'].lower(), rrset_data['type'], - rrset_data['name'].rstrip('.')), - detail=json.dumps(data), - created_by=g.apikey.description, - domain_id=Domain().get_id_by_name(zone_id.rstrip('.'))) + if Setting().get('enable_api_rr_history'): + if request.method in ['POST', 'PATCH'] : + data = request.get_json(force=True) + for rrset_data in data['rrsets']: + history = History(msg='{0} zone {1} record of {2}'.format( + rrset_data['changetype'].lower(), rrset_data['type'], + rrset_data['name'].rstrip('.')), + detail=json.dumps(data), + created_by=g.apikey.description, + domain_id=Domain().get_id_by_name(zone_id.rstrip('.'))) + history.add() + elif request.method == 'DELETE': + history = History(msg='Deleted zone {0}'.format(zone_id.rstrip('.')), + detail='', + created_by=g.apikey.description, + domain_id=Domain().get_id_by_name(zone_id.rstrip('.'))) + history.add() + elif request.method != 'GET': + history = History(msg='Updated zone {0}'.format(zone_id.rstrip('.')), + detail='', + created_by=g.apikey.description, + domain_id=Domain().get_id_by_name(zone_id.rstrip('.'))) history.add() - elif request.method == 'DELETE': - history = History(msg='Deleted zone {0}'.format(zone_id.rstrip('.')), - detail='', - created_by=g.apikey.description, - domain_id=Domain().get_id_by_name(zone_id.rstrip('.'))) - history.add() - elif request.method != 'GET': - history = History(msg='Updated zone {0}'.format(zone_id.rstrip('.')), - detail='', - created_by=g.apikey.description, - domain_id=Domain().get_id_by_name(zone_id.rstrip('.'))) - history.add() return resp.content, resp.status_code, resp.headers.items() + @api_bp.route('/servers/', methods=['GET', 'PUT']) @apikey_auth @apikey_is_admin @@ -1013,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() @@ -1054,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/dashboard.py b/powerdnsadmin/routes/dashboard.py index c953ec7..8cf1b12 100644 --- a/powerdnsadmin/routes/dashboard.py +++ b/powerdnsadmin/routes/dashboard.py @@ -61,7 +61,7 @@ def domains_custom(boxId): )) template = current_app.jinja_env.get_template("dashboard_domain.html") - render = template.make_module(vars={"current_user": current_user}) + render = template.make_module(vars={"current_user": current_user, "allow_user_view_history": Setting().get('allow_user_view_history')}) columns = [ Domain.name, Domain.dnssec, Domain.type, Domain.serial, Domain.master, @@ -163,19 +163,20 @@ def dashboard(): if current_user.role.name in ['Administrator', 'Operator']: domain_count = Domain.query.count() history_number = History.query.count() - history = History.query.order_by(History.created_on.desc()).limit(4) + history = History.query.order_by(History.created_on.desc()).limit(4).all() elif Setting().get('allow_user_view_history'): history = db.session.query(History) \ .join(Domain, History.domain_id == Domain.id) \ .outerjoin(DomainUser, Domain.id == DomainUser.domain_id) \ .outerjoin(Account, Domain.account_id == Account.id) \ .outerjoin(AccountUser, Account.id == AccountUser.account_id) \ + .order_by(History.created_on.desc()) \ .filter( db.or_( DomainUser.user_id == current_user.id, AccountUser.user_id == current_user.id - )).order_by(History.created_on.desc()) - history_number = history.count() + )).all() + history_number = len(history) # history.count() history = history[:4] domain_count = db.session.query(Domain) \ .outerjoin(DomainUser, Domain.id == DomainUser.domain_id) \ @@ -186,6 +187,10 @@ def dashboard(): DomainUser.user_id == current_user.id, AccountUser.user_id == current_user.id )).count() + + from .admin import convert_histories, DetailedHistory + detailedHistories = convert_histories(history) + server = Server(server_id='localhost') statistics = server.get_statistic() if statistics: @@ -202,7 +207,7 @@ def dashboard(): user_num=user_num, history_number=history_number, uptime=uptime, - histories=history, + histories=detailedHistories, show_bg_domain_button=show_bg_domain_button, pdns_version=Setting().get('pdns_version')) diff --git a/powerdnsadmin/routes/domain.py b/powerdnsadmin/routes/domain.py index 52f9159..e3b61cc 100644 --- a/powerdnsadmin/routes/domain.py +++ b/powerdnsadmin/routes/domain.py @@ -24,7 +24,8 @@ from ..models.domain_setting import DomainSetting from ..models.base import db from ..models.domain_user import DomainUser from ..models.account_user import AccountUser - +from .admin import extract_changelogs_from_a_history_entry +from ..decorators import history_access_required domain_bp = Blueprint('domain', __name__, template_folder='templates', @@ -131,7 +132,8 @@ def domain(domain_name): records=records, editable_records=editable_records, quick_edit=quick_edit, - ttl_options=ttl_options) + ttl_options=ttl_options, + current_user=current_user) @domain_bp.route('/remove', methods=['GET', 'POST']) @@ -187,6 +189,161 @@ def remove(): return render_template('domain_remove.html', domainss=domains) +@domain_bp.route('//changelog', methods=['GET']) +@login_required +@can_access_domain +@history_access_required +def changelog(domain_name): + g.user = current_user + login_manager.anonymous_user = Anonymous + domain = Domain.query.filter(Domain.name == domain_name).first() + if not domain: + abort(404) + + # Query domain's rrsets from PowerDNS API + rrsets = Record().get_rrsets(domain.name) + current_app.logger.debug("Fetched rrests: \n{}".format(pretty_json(rrsets))) + + # API server might be down, misconfigured + if not rrsets and domain.type != 'Slave': + abort(500) + + records_allow_to_edit = Setting().get_records_allow_to_edit() + records = [] + + # get all changelogs for this domain, in descening order + if current_user.role.name in [ 'Administrator', 'Operator' ]: + histories = History.query.filter(History.domain_id == domain.id).order_by(History.created_on.desc()).all() + else: + # if the user isn't an administrator or operator, + # allow_user_view_history must be enabled to get here, + # so include history for the domains for the user + histories = db.session.query(History) \ + .join(Domain, History.domain_id == Domain.id) \ + .outerjoin(DomainUser, Domain.id == DomainUser.domain_id) \ + .outerjoin(Account, Domain.account_id == Account.id) \ + .outerjoin(AccountUser, Account.id == AccountUser.account_id) \ + .order_by(History.created_on.desc()) \ + .filter( + db.and_(db.or_( + DomainUser.user_id == current_user.id, + AccountUser.user_id == current_user.id + ), + History.domain_id == domain.id + ) + ).all() + + if StrictVersion(Setting().get('pdns_version')) >= StrictVersion('4.0.0'): + for r in rrsets: + if r['type'] in records_allow_to_edit: + r_name = r['name'].rstrip('.') + + # If it is reverse zone and pretty_ipv6_ptr setting + # is enabled, we reformat the name for ipv6 records. + if Setting().get('pretty_ipv6_ptr') and r[ + 'type'] == 'PTR' and 'ip6.arpa' in r_name and '*' not in r_name: + r_name = dns.reversename.to_address( + dns.name.from_text(r_name)) + + # Create the list of records in format that + # PDA jinja2 template can understand. + index = 0 + for record in r['records']: + if (len(r['comments'])>index): + c=r['comments'][index]['content'] + else: + c='' + record_entry = RecordEntry( + name=r_name, + type=r['type'], + status='Disabled' if record['disabled'] else 'Active', + ttl=r['ttl'], + data=record['content'], + comment=c, + is_allowed_edit=True) + index += 1 + records.append(record_entry) + else: + # Unsupported version + abort(500) + + changes_set = dict() + for i in range(len(histories)): + extract_changelogs_from_a_history_entry(changes_set, histories[i], i) + if i in changes_set and len(changes_set[i]) == 0: # if empty, then remove the key + changes_set.pop(i) + return render_template('domain_changelog.html', domain=domain, allHistoryChanges=changes_set) + +""" +Returns a changelog for a specific pair of (record_name, record_type) +""" +@domain_bp.route('//changelog/-', methods=['GET']) +@login_required +@can_access_domain +@history_access_required +def record_changelog(domain_name, record_name, record_type): + + g.user = current_user + login_manager.anonymous_user = Anonymous + domain = Domain.query.filter(Domain.name == domain_name).first() + if not domain: + abort(404) + # Query domain's rrsets from PowerDNS API + rrsets = Record().get_rrsets(domain.name) + current_app.logger.debug("Fetched rrests: \n{}".format(pretty_json(rrsets))) + + # API server might be down, misconfigured + if not rrsets and domain.type != 'Slave': + abort(500) + + # get all changelogs for this domain, in descening order + if current_user.role.name in [ 'Administrator', 'Operator' ]: + histories = History.query.filter(History.domain_id == domain.id).order_by(History.created_on.desc()).all() + else: + # if the user isn't an administrator or operator, + # allow_user_view_history must be enabled to get here, + # so include history for the domains for the user + histories = db.session.query(History) \ + .join(Domain, History.domain_id == Domain.id) \ + .outerjoin(DomainUser, Domain.id == DomainUser.domain_id) \ + .outerjoin(Account, Domain.account_id == Account.id) \ + .outerjoin(AccountUser, Account.id == AccountUser.account_id) \ + .order_by(History.created_on.desc()) \ + .filter( + db.and_(db.or_( + DomainUser.user_id == current_user.id, + AccountUser.user_id == current_user.id + ), + History.domain_id == domain.id + ) + ).all() + + changes_set_of_record = dict() + for i in range(len(histories)): + extract_changelogs_from_a_history_entry(changes_set_of_record, histories[i], i, record_name, record_type) + if i in changes_set_of_record and len(changes_set_of_record[i]) == 0: # if empty, then remove the key + changes_set_of_record.pop(i) + + indexes_to_pop = [] + for change_num in changes_set_of_record: + changes_i = changes_set_of_record[change_num] + for hre in changes_i: # for each history record entry in changes_i + if 'type' in hre.add_rrest and hre.add_rrest['name'] == record_name and hre.add_rrest['type'] == record_type: + continue + elif 'type' in hre.del_rrest and hre.del_rrest['name'] == record_name and hre.del_rrest['type'] == record_type: + continue + else: + changes_set_of_record[change_num].remove(hre) + if change_num in changes_set_of_record and len(changes_set_of_record[change_num]) == 0: # if empty, then remove the key + indexes_to_pop.append(change_num) + + for i in indexes_to_pop: + changes_set_of_record.pop(i) + + return render_template('domain_changelog.html', domain=domain, allHistoryChanges=changes_set_of_record, + record_name = record_name, record_type = record_type) + + @domain_bp.route('/add', methods=['GET', 'POST']) @login_required diff --git a/powerdnsadmin/routes/index.py b/powerdnsadmin/routes/index.py index 22ade73..2c2abe5 100644 --- a/powerdnsadmin/routes/index.py +++ b/powerdnsadmin/routes/index.py @@ -43,7 +43,6 @@ index_bp = Blueprint('index', template_folder='templates', url_prefix='/') - @index_bp.before_app_first_request def register_modules(): global google @@ -398,16 +397,39 @@ def login(): session.pop('oidc_token', None) return redirect(url_for('index.login')) + #This checks if the account_name_property and account_description property were included in settings. if Setting().get('oidc_oauth_account_name_property') and Setting().get('oidc_oauth_account_description_property') and not Setting().get('autoprovisioning_oidc'): + + #Gets the name_property and description_property. name_prop = Setting().get('oidc_oauth_account_name_property') desc_prop = Setting().get('oidc_oauth_account_description_property') + + account_to_add = [] + #If the name_property and desc_property exist in me (A variable that contains all the userinfo from the IdP). if name_prop in me and desc_prop in me: - account = handle_account(me[name_prop], me[desc_prop]) - account.add_user(user) + accounts_name_prop = [me[name_prop]] if type(me[name_prop]) is not list else me[name_prop] + accounts_desc_prop = [me[desc_prop]] if type(me[desc_prop]) is not list else me[desc_prop] + + #Run on all groups the user is in by the index num. + for i in range(len(accounts_name_prop)): + description = '' + if i < len(accounts_desc_prop): + description = accounts_desc_prop[i] + account = handle_account(accounts_name_prop[i], description) + + account_to_add.append(account) user_accounts = user.get_accounts() - for ua in user_accounts: - if ua.name != account.name: - ua.remove_user(user) + + # Add accounts + for account in account_to_add: + if account not in user_accounts: + account.add_user(user) + + # Remove accounts if the setting is enabled + if Setting().get('delete_sso_accounts'): + for account in user_accounts: + if account not in account_to_add: + account.remove_user(user) if Setting().get('autoprovisioning_oidc'): urn_value=Setting().get('urn_value_oidc') @@ -643,12 +665,12 @@ def register(): if request.method == 'GET': return render_template('register.html') elif request.method == 'POST': - username = request.form['username'] - password = request.form['password'] - firstname = request.form.get('firstname') - lastname = request.form.get('lastname') - email = request.form.get('email') - rpassword = request.form.get('rpassword') + username = request.form.get('username', '').strip() + password = request.form.get('password', '') + firstname = request.form.get('firstname', '').strip() + lastname = request.form.get('lastname', '').strip() + email = request.form.get('email', '').strip() + rpassword = request.form.get('rpassword', '') if not username or not password or not email: return render_template( diff --git a/powerdnsadmin/routes/user.py b/powerdnsadmin/routes/user.py index a6abce3..6ca927b 100644 --- a/powerdnsadmin/routes/user.py +++ b/powerdnsadmin/routes/user.py @@ -41,13 +41,10 @@ def profile(): return render_template('user_profile.html') if request.method == 'POST': if session['authentication_type'] == 'LOCAL': - firstname = request.form[ - 'firstname'] if 'firstname' in request.form else '' - lastname = request.form[ - 'lastname'] if 'lastname' in request.form else '' - email = request.form['email'] if 'email' in request.form else '' - new_password = request.form[ - 'password'] if 'password' in request.form else '' + firstname = request.form.get('firstname', '').strip() + lastname = request.form.get('lastname', '').strip() + email = request.form.get('email', '').strip() + new_password = request.form.get('password', '') else: firstname = lastname = email = new_password = '' current_app.logger.warning( diff --git a/powerdnsadmin/static/assets/css/style.css b/powerdnsadmin/static/assets/css/style.css new file mode 100644 index 0000000..2ef7148 --- /dev/null +++ b/powerdnsadmin/static/assets/css/style.css @@ -0,0 +1,102 @@ +/* Customize the label (the container) */ +.container { + display: block; + position: relative; + padding-left: 50px; + margin-bottom: 12px; + left:100px; + cursor: pointer; + font-size: 22px; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + } + + /* Hide the browser's default checkbox */ + .container input { + position: absolute; + opacity: 0; + cursor: pointer; + height: 0; + width: 0; + } + + /* Create a custom checkbox */ + .checkmark { + position: absolute; + top: 0; + left: 0; + height: 25px; + width: 25px; + background-color: #eee; + } + + /* On mouse-over, add a grey background color */ + .container:hover input ~ .checkmark { + background-color: #ccc; + } + + /* When the checkbox is checked, add a blue background */ + .container input:checked ~ .checkmark { + background-color: #2196F3; + } + + /* Create the checkmark/indicator (hidden when not checked) */ + .checkmark:after { + content: ""; + position: absolute; + display: none; + } + + /* Show the checkmark when checked */ + .container input:checked ~ .checkmark:after { + display: block; + } + + /* Style the checkmark/indicator */ + .container .checkmark:after { + left: 9px; + top: 5px; + width: 5px; + height: 10px; + border: solid white; + border-width: 0 3px 3px 0; + -webkit-transform: rotate(45deg); + -ms-transform: rotate(45deg); + transform: rotate(45deg); + } + + + + .autocomplete { + /*the container must be positioned relative:*/ + position: relative; + /* display: inline-block; */ + } + .autocomplete-items { + position: absolute; + border: 1px solid #d4d4d4; + border-bottom: none; + border-top: none; + z-index: 99; + /*position the autocomplete items to be the same width as the container:*/ + top: 100%; + left: 0; + right: 0; + } + .autocomplete-items div { + padding: 10px; + cursor: pointer; + background-color: #fff; + border-bottom: 1px solid #d4d4d4; + } + .autocomplete-items div:hover { + /*when hovering an item:*/ + background-color: #e9e9e9; + } + .autocomplete-active { + /*when navigating through the items using the arrow keys:*/ + background-color: DodgerBlue !important; + color: #ffffff; + } \ No newline at end of file diff --git a/powerdnsadmin/static/custom/js/custom.js b/powerdnsadmin/static/custom/js/custom.js index 297cd96..9bd9669 100644 --- a/powerdnsadmin/static/custom/js/custom.js +++ b/powerdnsadmin/static/custom/js/custom.js @@ -53,6 +53,7 @@ function applyRecordChanges(data, domain) { var modal = $("#modal_success"); modal.find('.modal-body p').text("Applied changes successfully"); modal.modal('show'); + setTimeout(() => {window.location.reload()}, 2000); }, error : function(jqXHR, status) { 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 %} {% endblock %} {% block modals %} @@ -127,7 +507,7 @@ -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/powerdnsadmin/templates/admin_history_table.html b/powerdnsadmin/templates/admin_history_table.html new file mode 100644 index 0000000..cf31e0e --- /dev/null +++ b/powerdnsadmin/templates/admin_history_table.html @@ -0,0 +1,104 @@ + +{% import 'applied_change_macro.html' as applied_change_macro %} + + +{% if len_histories >= lim %} +

Limit of loaded history records has been reached! Only {{lim}} history records are shown.

+{% endif %} + +
+ + + + + + + + + + + {% for history in histories %} + + + + + + + + {% endfor %} + +
Changed byContentTimeDetail
{{ history.history.created_by }}{{ history.history.msg }}{{ history.history.created_on }} + +
+ + + + + \ No newline at end of file diff --git a/powerdnsadmin/templates/admin_manage_keys.html b/powerdnsadmin/templates/admin_manage_keys.html index 215c3d8..dadc2a0 100644 --- a/powerdnsadmin/templates/admin_manage_keys.html +++ b/powerdnsadmin/templates/admin_manage_keys.html @@ -35,6 +35,7 @@ Role Description Domains + Accounts Action @@ -45,6 +46,7 @@ {{ key.role.name }} {{ key.description }} {% for domain in key.domains %}{{ domain.name }}{% if not loop.last %}, {% endif %}{% endfor %} + {% for account in key.accounts %}{{ account.name }}{% if not loop.last %}, {% endif %}{% endfor %} + {% endfor %} @@ -226,11 +241,10 @@ ] }); - $(document.body).on('click', '.history-info-button', function() - { + $(document.body).on('click', '.history-info-button', function () { var modal = $("#modal_history_info"); var info = $(this).val(); - $('#modal-code-content').html(json_library.prettyPrint(info)); + $('#modal-info-content').html(info); modal.modal('show'); }); @@ -297,7 +311,7 @@
@@ -46,6 +52,9 @@ + {% if current_user.role.name in ['Administrator', 'Operator'] or SETTING.get('allow_user_view_history') %} + + {% endif %} @@ -91,6 +100,13 @@ {% endif %} + {% if current_user.role.name in ['Administrator', 'Operator'] or SETTING.get('allow_user_view_history') %} + + {% endif %} @@ -144,14 +160,33 @@ // hidden column so that we can add new records on top // regardless of whatever sorting is done. See orderFixed visible: false, + {% if current_user.role.name in ['Administrator', 'Operator'] or SETTING.get('allow_user_view_history') %} + targets: [ 9 ] + {% else %} targets: [ 8 ] + {% endif %} }, { className: "length-break", targets: [ 4, 5 ] } ], + {% if current_user.role.name in ['Administrator', 'Operator'] or SETTING.get('allow_user_view_history') %} + "orderFixed": [[9, 'asc']] + {% else %} "orderFixed": [[8, 'asc']] + {% endif %} + }); + + + function show_record_changelog(record_name, record_type, e) { + e.stopPropagation(); + window.location.href = "/domain/{{domain.name}}/changelog/" + record_name + ".-" + record_type; + } + // handle changelog button + $(document.body).on("click", ".button_changelog", function(e) { + e.stopPropagation(); + window.location.href = "/domain/{{domain.name}}/changelog"; }); // handle delete button @@ -243,7 +278,11 @@ // add new row var default_type = records_allow_edit[0] - var nRow = jQuery('#tbl_records').dataTable().fnAddData(['', default_type, 'Active', window.ttl_options[0][0], '', '', '', '', '0']); + {% if current_user.role.name in ['Administrator', 'Operator'] or SETTING.get('allow_user_view_history') %} + var nRow = jQuery('#tbl_records').dataTable().fnAddData(['', default_type, 'Active', window.ttl_options[0][0], '', '', '', '', '', '0']); + {% else %} + var nRow = jQuery('#tbl_records').dataTable().fnAddData(['', default_type, 'Active', window.ttl_options[0][0], '', '', '', '', '0']); + {% endif %} editRow($("#tbl_records").DataTable(), nRow); document.getElementById("edit-row-focus").focus(); nEditing = nRow; diff --git a/powerdnsadmin/templates/domain_changelog.html b/powerdnsadmin/templates/domain_changelog.html new file mode 100644 index 0000000..a6b530a --- /dev/null +++ b/powerdnsadmin/templates/domain_changelog.html @@ -0,0 +1,116 @@ +{% extends "base.html" %} +{% block title %}{{ domain.name | pretty_domain_name }} - {{ SITE_NAME }}{% endblock %} + +{% block dashboard_stat %} +
+

+ {% if record_name and record_type %} + Record changelog: {{ record_name}}   {{ record_type }} + {% else %} + Domain changelog: {{ domain.name | pretty_domain_name }} + {% endif %} +

+ +
+{% endblock %} + +{% import 'applied_change_macro.html' as applied_change_macro %} + +{% block content %} +
+
+
+
+
+ +
+
+ +
Comment Edit DeleteChangelog
+ + 1
+ + + + + + + + {% for applied_change in allHistoryChanges %} + + + + + + + + + + + {% endfor %} + +
Changed onChanged by
+ {{ allHistoryChanges[applied_change][0].history_entry.created_on }} + + {{allHistoryChanges[applied_change][0].history_entry.created_by }} +
+
+ {% call applied_change_macro.applied_change_template(allHistoryChanges[applied_change]) %} + {% endcall %} +
+
+
+ + + + +{% endblock %} + +{% block extrascripts %} + +{% endblock %} \ No newline at end of file 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"'