diff --git a/docs/API.md b/docs/API.md index ae52025..890f556 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,105 +1,134 @@ ### API Usage +#### Getting started with docker + 1. Run docker image docker-compose up, go to UI http://localhost:9191, at http://localhost:9191/swagger is swagger API specification 2. Click to register user, type e.g. user: admin and password: admin 3. Login to UI in settings enable allow domain creation for users, now you can create and manage domains with admin account and also ordinary users -4. Encode your user and password to base64, in our example we have user admin and password admin so in linux cmd line we type: +4. Click on the API Keys menu then click on teh "Add Key" button to add a new Administrator Key +5. Keep the base64 encoded apikey somewhere safe as it won't be available in clear anymore -``` + +#### Accessing the API + +The PDA API consists of two distinct parts: + +- The /powerdnsadmin endpoints manages PDA content (accounts, users, apikeys) and also allow domain creation/deletion +- The /server endpoints are proxying queries to the backend PowerDNS instance's API. PDA acts as a proxy managing several API Keys and permissions to the PowerDNS content. + +The requests to the API needs two headers: + +- The classic 'Content-Type: application/json' is required to all POST and PUT requests, though it's armless to use it on each call +- The authentication header to provide either the login:password basic authentication or the Api Key authentication. + +When you access the `/powerdnsadmin` endpoint, you must use the Basic Auth: + +```bash +# Encode your user and password to base64 $ echo -n 'admin:admin'|base64 YWRtaW46YWRtaW4= +# Use the ouput as your basic auth header +curl -H 'Authorization: Basic YWRtaW46YWRtaW4=' -X ``` -we use generated output in basic authentication, we authenticate as user, -with basic authentication, we can create/delete/get zone and create/delete/get/update apikeys - -creating domain: +When you access the `/server` endpoint, you must use the ApiKey +```bash +# Use the already base64 encoded key in your header +curl -H 'X-API-Key: YUdDdGhQM0tMQWV5alpJ' -X ``` + +Finally, the `/sync_domains` endpoint accepts both basic and apikey authentication + +#### Examples + +Creating domain via `/powerdnsadmin`: + +```bash curl -L -vvv -H 'Content-Type: application/json' -H 'Authorization: Basic YWRtaW46YWRtaW4=' -X POST http://localhost:9191/api/v1/pdnsadmin/zones --data '{"name": "yourdomain.com.", "kind": "NATIVE", "nameservers": ["ns1.mydomain.com."]}' ``` -creating apikey which has Administrator role, apikey can have also User role, when creating such apikey you have to specify also domain for which apikey is valid: +Creating an apikey which has the Administrator role: -``` +```bash +# Create the key curl -L -vvv -H 'Content-Type: application/json' -H 'Authorization: Basic YWRtaW46YWRtaW4=' -X POST http://localhost:9191/api/v1/pdnsadmin/apikeys --data '{"description": "masterkey","domains":[], "role": "Administrator"}' ``` +Example response (don't forget to save the plain key from the output) -call above will return response like this: - -``` -[{"description": "samekey", "domains": [], "role": {"name": "Administrator", "id": 1}, "id": 2, "plain_key": "aGCthP3KLAeyjZI"}] +```json +[ + { + "accounts": [], + "description": "masterkey", + "domains": [], + "role": { + "name": "Administrator", + "id": 1 + }, + "id": 2, + "plain_key": "aGCthP3KLAeyjZI" + } +] ``` -we take plain_key and base64 encode it, this is the only time we can get API key in plain text and save it somewhere: +We can use the apikey for all calls to PowerDNS (don't forget to specify Content-Type): -``` -$ echo -n 'aGCthP3KLAeyjZI'|base64 -YUdDdGhQM0tMQWV5alpJ -``` +Getting powerdns configuration (Administrator Key is needed): -We can use apikey for all calls specified in our API specification (it tries to follow powerdns API 1:1, only tsigkeys endpoints are not yet implemented), don't forget to specify Content-Type! - -getting powerdns configuration: - -``` +```bash curl -L -vvv -H 'Content-Type: application/json' -H 'X-API-KEY: YUdDdGhQM0tMQWV5alpJ' -X GET http://localhost:9191/api/v1/servers/localhost/config ``` -creating and updating records: +Creating and updating records: -``` +```bash curl -X PATCH -H 'Content-Type: application/json' --data '{"rrsets": [{"name": "test1.yourdomain.com.","type": "A","ttl": 86400,"changetype": "REPLACE","records": [ {"content": "192.0.2.5", "disabled": false} ]},{"name": "test2.yourdomain.com.","type": "AAAA","ttl": 86400,"changetype": "REPLACE","records": [ {"content": "2001:db8::6", "disabled": false} ]}]}' -H 'X-API-Key: YUdDdGhQM0tMQWV5alpJ' http://127.0.0.1:9191/api/v1/servers/localhost/zones/yourdomain.com. ``` -getting domain: +Getting a domain: -``` +```bash curl -L -vvv -H 'Content-Type: application/json' -H 'X-API-KEY: YUdDdGhQM0tMQWV5alpJ' -X GET http://localhost:9191/api/v1/servers/localhost/zones/yourdomain.com ``` -list zone records: +List a zone's records: -``` +```bash curl -H 'Content-Type: application/json' -H 'X-API-Key: YUdDdGhQM0tMQWV5alpJ' http://localhost:9191/api/v1/servers/localhost/zones/yourdomain.com ``` -add new record: +Add a new record: -``` +```bash curl -H 'Content-Type: application/json' -X PATCH --data '{"rrsets": [ {"name": "test.yourdomain.com.", "type": "A", "ttl": 86400, "changetype": "REPLACE", "records": [ {"content": "192.0.5.4", "disabled": false } ] } ] }' -H 'X-API-Key: YUdDdGhQM0tMQWV5alpJ' http://localhost:9191/api/v1/servers/localhost/zones/yourdomain.com | jq . ``` -update record: +Update a record: -``` +```bash curl -H 'Content-Type: application/json' -X PATCH --data '{"rrsets": [ {"name": "test.yourdomain.com.", "type": "A", "ttl": 86400, "changetype": "REPLACE", "records": [ {"content": "192.0.2.5", "disabled": false, "name": "test.yourdomain.com.", "ttl": 86400, "type": "A"}]}]}' -H 'X-API-Key: YUdDdGhQM0tMQWV5alpJ' http://localhost:9191/api/v1/servers/localhost/zones/yourdomain.com | jq . ``` -delete record: +Delete a record: -``` +```bash curl -H 'Content-Type: application/json' -X PATCH --data '{"rrsets": [ {"name": "test.yourdomain.com.", "type": "A", "ttl": 86400, "changetype": "DELETE"}]}' -H 'X-API-Key: YUdDdGhQM0tMQWV5alpJ' http://localhost:9191/api/v1/servers/localhost/zones/yourdomain.com | jq ``` ### Generate ER diagram -``` +With docker + +```bash +# Install build packages apt-get install python-dev graphviz libgraphviz-dev pkg-config -``` - -``` +# Get the required python libraries pip install graphviz mysqlclient ERAlchemy -``` - -``` +# Start the docker container docker-compose up -d -``` - -``` +# Set environment variables source .env -``` - -``` +# Generate the diagrams eralchemy -i 'mysql://${PDA_DB_USER}:${PDA_DB_PASSWORD}@'$(docker inspect powerdns-admin-mysql|jq -jr '.[0].NetworkSettings.Networks.powerdnsadmin_default.IPAddress')':3306/powerdns_admin' -o /tmp/output.pdf ``` diff --git a/migrations/versions/0967658d9c0d_add_apikey_account_mapping_table.py b/migrations/versions/0967658d9c0d_add_apikey_account_mapping_table.py new file mode 100644 index 0000000..f2c87ed --- /dev/null +++ b/migrations/versions/0967658d9c0d_add_apikey_account_mapping_table.py @@ -0,0 +1,41 @@ +"""add apikey account mapping table + +Revision ID: 0967658d9c0d +Revises: 0d3d93f1c2e0 +Create Date: 2021-11-13 22:28:46.133474 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '0967658d9c0d' +down_revision = '0d3d93f1c2e0' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('apikey_account', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('apikey_id', sa.Integer(), nullable=False), + sa.Column('account_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['account_id'], ['account.id'], ), + sa.ForeignKeyConstraint(['apikey_id'], ['apikey.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('history', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_history_created_on'), ['created_on'], unique=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('history', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_history_created_on')) + + op.drop_table('apikey_account') + # ### end Alembic commands ### diff --git a/powerdnsadmin/decorators.py b/powerdnsadmin/decorators.py index 44e545b..8cfe108 100644 --- a/powerdnsadmin/decorators.py +++ b/powerdnsadmin/decorators.py @@ -270,7 +270,12 @@ def apikey_can_access_domain(f): zone_id = kwargs.get('zone_id').rstrip(".") domain_names = [item.name for item in domains] - if zone_id not in domain_names: + accounts = 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) diff --git a/powerdnsadmin/lib/errors.py b/powerdnsadmin/lib/errors.py index 8642cf9..687f554 100644 --- a/powerdnsadmin/lib/errors.py +++ b/powerdnsadmin/lib/errors.py @@ -60,7 +60,8 @@ class ApiKeyNotUsable(StructuredException): def __init__( self, name=None, - message="Api key must have domains or have administrative role"): + message=("Api key must have domains or accounts" + " or an administrative role")): StructuredException.__init__(self) self.message = message self.name = name @@ -120,6 +121,15 @@ class AccountDeleteFail(StructuredException): self.name = name +class AccountNotExists(StructuredException): + status_code = 404 + + def __init__(self, name=None, message="Account does not exist"): + StructuredException.__init__(self) + self.message = message + self.name = name + + class UserCreateFail(StructuredException): status_code = 500 diff --git a/powerdnsadmin/lib/schema.py b/powerdnsadmin/lib/schema.py index 78d8369..e0d7efc 100644 --- a/powerdnsadmin/lib/schema.py +++ b/powerdnsadmin/lib/schema.py @@ -11,10 +11,21 @@ class RoleSchema(Schema): name = fields.String() +class AccountSummarySchema(Schema): + id = fields.Integer() + name = fields.String() + domains = fields.Embed(schema=DomainSchema, many=True) + +class ApiKeySummarySchema(Schema): + id = fields.Integer() + description = fields.String() + + class ApiKeySchema(Schema): id = fields.Integer() role = fields.Embed(schema=RoleSchema) domains = fields.Embed(schema=DomainSchema, many=True) + accounts = fields.Embed(schema=AccountSummarySchema, many=True) description = fields.String() key = fields.String() @@ -23,15 +34,11 @@ class ApiPlainKeySchema(Schema): id = fields.Integer() role = fields.Embed(schema=RoleSchema) domains = fields.Embed(schema=DomainSchema, many=True) + accounts = fields.Embed(schema=AccountSummarySchema, many=True) description = fields.String() plain_key = fields.String() -class AccountSummarySchema(Schema): - id = fields.Integer() - name = fields.String() - - class UserSchema(Schema): id = fields.Integer() username = fields.String() @@ -56,3 +63,4 @@ class AccountSchema(Schema): contact = fields.String() mail = fields.String() domains = fields.Embed(schema=DomainSchema, many=True) + apikeys = fields.Embed(schema=ApiKeySummarySchema, many=True) diff --git a/powerdnsadmin/models/__init__.py b/powerdnsadmin/models/__init__.py index 1562319..9c009f2 100644 --- a/powerdnsadmin/models/__init__.py +++ b/powerdnsadmin/models/__init__.py @@ -8,6 +8,7 @@ from .account_user import AccountUser from .server import Server from .history import History from .api_key import ApiKey +from .api_key_account import ApiKeyAccount from .setting import Setting from .domain import Domain from .domain_setting import DomainSetting diff --git a/powerdnsadmin/models/account.py b/powerdnsadmin/models/account.py index ad46ef9..4d08fc1 100644 --- a/powerdnsadmin/models/account.py +++ b/powerdnsadmin/models/account.py @@ -17,6 +17,9 @@ class Account(db.Model): contact = db.Column(db.String(128)) mail = db.Column(db.String(128)) domains = db.relationship("Domain", back_populates="account") + apikeys = db.relationship("ApiKey", + secondary="apikey_account", + back_populates="accounts") def __init__(self, name=None, description=None, contact=None, mail=None): self.name = name diff --git a/powerdnsadmin/models/api_key.py b/powerdnsadmin/models/api_key.py index cb05890..89b9fc1 100644 --- a/powerdnsadmin/models/api_key.py +++ b/powerdnsadmin/models/api_key.py @@ -3,10 +3,10 @@ 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,14 +16,18 @@ 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) @@ -54,7 +58,7 @@ class ApiKey(db.Model): db.session.rollback() raise e - def update(self, role_name=None, description=None, domains=None): + def update(self, role_name=None, description=None, domains=None, accounts=None): try: if role_name: role = Role.query.filter(Role.name == role_name).first() @@ -63,12 +67,18 @@ class ApiKey(db.Model): if description: self.description = description - if domains: + if domains is not None: domain_object_list = Domain.query \ .filter(Domain.name.in_(domains)) \ .all() self.domains[:] = domain_object_list + if accounts is not None: + account_object_list = Account.query \ + .filter(Account.name.in_(accounts)) \ + .all() + self.accounts[:] = account_object_list + db.session.commit() except Exception as e: msg_str = 'Update of apikey failed. Error: {0}' @@ -121,3 +131,12 @@ class ApiKey(db.Model): raise Exception("Unauthorized") return apikey + + def associate_account(self, account): + return True + + def dissociate_account(self, account): + return True + + def get_accounts(self): + return True diff --git a/powerdnsadmin/models/api_key_account.py b/powerdnsadmin/models/api_key_account.py new file mode 100644 index 0000000..904f335 --- /dev/null +++ b/powerdnsadmin/models/api_key_account.py @@ -0,0 +1,20 @@ +from .base import db + + +class ApiKeyAccount(db.Model): + __tablename__ = 'apikey_account' + id = db.Column(db.Integer, primary_key=True) + apikey_id = db.Column(db.Integer, + db.ForeignKey('apikey.id'), + nullable=False) + account_id = db.Column(db.Integer, + db.ForeignKey('account.id'), + nullable=False) + db.UniqueConstraint('apikey_id', 'account_id', name='uniq_apikey_account') + + def __init__(self, apikey_id, account_id): + self.apikey_id = apikey_id + self.account_id = account_id + + def __repr__(self): + return ''.format(self.apikey_id, self.account_id) diff --git a/powerdnsadmin/routes/admin.py b/powerdnsadmin/routes/admin.py index f754ad0..f308d6c 100644 --- a/powerdnsadmin/routes/admin.py +++ b/powerdnsadmin/routes/admin.py @@ -40,7 +40,7 @@ old_state: dictionary with "disabled" and "content" keys. {"disabled" : False, " new_state: similarly change_type: "addition" or "deletion" or "status" for status change or "unchanged" for no change -Note: A change in "content", is considered a deletion and recreation of the same record, +Note: A change in "content", is considered a deletion and recreation of the same record, holding the new content value. """ def get_record_changes(del_rrest, add_rrest): @@ -57,12 +57,12 @@ def get_record_changes(del_rrest, add_rrest): {"disabled":a['disabled'],"content":a['content']}, "status") ) break - + if not exists: # deletion changeSet.append( ({"disabled":d['disabled'],"content":d['content']}, None, "deletion") ) - + for a in addSet: # get the additions exists = False for d in delSet: @@ -78,7 +78,7 @@ def get_record_changes(del_rrest, add_rrest): exists = False for c in changeSet: if c[1] != None and c[1]["content"] == a['content']: - exists = True + exists = True break if not exists: changeSet.append( ( {"disabled":a['disabled'], "content":a['content']}, {"disabled":a['disabled'], "content":a['content']}, "unchanged") ) @@ -123,7 +123,7 @@ def extract_changelogs_from_a_history_entry(out_changes, history_entry, change_n if change_num not in out_changes: out_changes[change_num] = [] out_changes[change_num].append(HistoryRecordEntry(history_entry, del_rrest, [], "-")) - + # only used for changelog per record if record_name != None and record_type != None: # then get only the records with the specific (record_name, record_type) tuple @@ -172,7 +172,7 @@ class HistoryRecordEntry: if add_rrest['ttl'] != del_rrest['ttl']: self.changed_fields.append("ttl") self.changeSet = get_record_changes(del_rrest, add_rrest) - + def toDict(self): @@ -300,6 +300,7 @@ def edit_user(user_username=None): @operator_role_required def edit_key(key_id=None): domains = Domain.query.all() + accounts = Account.query.all() roles = Role.query.all() apikey = None create = True @@ -316,6 +317,7 @@ def edit_key(key_id=None): return render_template('admin_edit_key.html', key=apikey, domains=domains, + accounts=accounts, roles=roles, create=create) @@ -323,14 +325,21 @@ def edit_key(key_id=None): fdata = request.form description = fdata['description'] role = fdata.getlist('key_role')[0] - doamin_list = fdata.getlist('key_multi_domain') + domain_list = fdata.getlist('key_multi_domain') + account_list = fdata.getlist('key_multi_account') # Create new apikey if create: - domain_obj_list = Domain.query.filter(Domain.name.in_(doamin_list)).all() + if role == "User": + domain_obj_list = Domain.query.filter(Domain.name.in_(domain_list)).all() + account_obj_list = Account.query.filter(Account.name.in_(account_list)).all() + else: + account_obj_list, domain_obj_list = [], [] + apikey = ApiKey(desc=description, role_name=role, - domains=domain_obj_list) + domains=domain_obj_list, + accounts=account_obj_list) try: apikey.create() except Exception as e: @@ -344,7 +353,9 @@ def edit_key(key_id=None): # Update existing apikey else: try: - apikey.update(role,description,doamin_list) + if role != "User": + domain_list, account_list = [], [] + apikey.update(role,description,domain_list, account_list) history_message = "Updated API key {0}".format(apikey.id) except Exception as e: current_app.logger.error('Error: {0}'.format(e)) @@ -354,14 +365,16 @@ def edit_key(key_id=None): 'key': apikey.id, 'role': apikey.role.name, 'description': apikey.description, - 'domain_acl': [domain.name for domain in apikey.domains] + 'domains': [domain.name for domain in apikey.domains], + 'accounts': [a.name for a in apikey.accounts] }), created_by=current_user.username) history.add() - + return render_template('admin_edit_key.html', key=apikey, domains=domains, + accounts=accounts, roles=roles, create=create, plain_key=plain_key) @@ -390,7 +403,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)) @@ -744,7 +757,7 @@ class DetailedHistory(): self.history = history self.detailed_msg = "" self.change_set = change_set - + if history.detail is None: self.detailed_msg = "" # if 'Create account' in history.msg: @@ -758,16 +771,16 @@ class DetailedHistory(): if 'domain_type' in detail_dict.keys() and 'account_id' in detail_dict.keys(): # this is a domain creation self.detailed_msg = """
Domain type:{0}
Account:{1}
- """.format(detail_dict['domain_type'], + """.format(detail_dict['domain_type'], Account.get_name_by_id(self=None, account_id=detail_dict['account_id']) if detail_dict['account_id'] != "0" else "None") elif 'authenticator' in detail_dict.keys(): # this is a user authentication self.detailed_msg = """ - + @@ -812,18 +825,23 @@ class DetailedHistory():
-
Authenticator Type:
Users with access to this domain{0}
Number of users:{1}
""".format(str(detail_dict['user_has_access']).replace("]","").replace("[", ""), len((detail_dict['user_has_access']))) elif 'Created API key' in history.msg or 'Updated API key' in history.msg: + domains = detail_dict['domains' if 'domains' in detail_dict.keys() else 'domain_acl'] + accounts = detail_dict['accounts'] if 'accounts' in detail_dict.keys() else 'None' self.detailed_msg = """ - + - + +
Key: {0}
Key: {0}
Role:{1}
Description:{2}
Accessible domains with this API key:{3}
Accounts bound to this API key:{3}
Accessible domains with this API key:{4}
- """.format(detail_dict['key'], detail_dict['role'], detail_dict['description'], str(detail_dict['domain_acl']).replace("]","").replace("[", "")) + """.format(detail_dict['key'], detail_dict['role'], detail_dict['description'], + str(accounts).replace("]","").replace("[", ""), + str(domains).replace("]","").replace("[", "")) elif 'Update type for domain' in history.msg: self.detailed_msg = """ - +
Domain: {0}
Domain: {0}
Domain type:{1}
Masters:{2}
@@ -831,7 +849,7 @@ class DetailedHistory(): elif 'Delete API key' in history.msg: self.detailed_msg = """ - + @@ -840,7 +858,7 @@ class DetailedHistory(): elif 'reverse' in history.msg: self.detailed_msg = """
Key: {0}
Key: {0}
Role:{1}
Description:{2}
Accessible domains with this API key:{3}
- +
Domain Type: {0}
Domain Type: {0}
Domain Master IPs:{1}
""".format(detail_dict['domain_type'], detail_dict['domain_master_ips']) @@ -895,7 +913,7 @@ def history(): }), 500) - if request.method == 'GET': + if request.method == 'GET': doms = accounts = users = "" if current_user.role.name in [ 'Administrator', 'Operator']: all_domain_names = Domain.query.all() @@ -903,7 +921,7 @@ def history(): all_user_names = User.query.all() - + for d in all_domain_names: doms += d.name + " " for acc in all_account_names: @@ -931,9 +949,9 @@ def history(): AccountUser.user_id == current_user.id )).all() - + all_user_names = [] - for a in all_account_names: + for a in all_account_names: temp = db.session.query(User) \ .join(AccountUser, AccountUser.user_id == User.id) \ .outerjoin(Account, Account.id == AccountUser.account_id) \ @@ -951,11 +969,11 @@ def history(): for d in all_domain_names: doms += d.name + " " - + for a in all_account_names: accounts += a.name + " " for u in all_user_names: - users += u.username + " " + users += u.username + " " return render_template('admin_history.html', all_domain_names=doms, all_account_names=accounts, all_usernames=users) # local_offset is the offset of the utc to the local time @@ -1005,7 +1023,7 @@ def history_table(): # ajax call data if current_user.role.name in [ 'Administrator', 'Operator' ]: base_query = History.query else: - # if the user isn't an administrator or operator, + # if the user isn't an administrator or operator, # allow_user_view_history must be enabled to get here, # so include history for the domains for the user base_query = db.session.query(History) \ @@ -1020,7 +1038,7 @@ def history_table(): # ajax call data )) domain_name = request.args.get('domain_name_filter') if request.args.get('domain_name_filter') != None \ - and len(request.args.get('domain_name_filter')) != 0 else None + and len(request.args.get('domain_name_filter')) != 0 else None account_name = request.args.get('account_name_filter') if request.args.get('account_name_filter') != None \ and len(request.args.get('account_name_filter')) != 0 else None user_name = request.args.get('auth_name_filter') if request.args.get('auth_name_filter') != None \ @@ -1217,7 +1235,7 @@ def setting_basic(): 'allow_user_create_domain', 'allow_user_remove_domain', 'allow_user_view_history', 'bg_domain_updates', 'site_name', 'session_timeout', 'warn_session_timeout', 'ttl_options', 'pdns_api_timeout', 'verify_ssl_connections', 'verify_user_email', - 'delete_sso_accounts', 'otp_field_enabled', 'custom_css', 'enable_api_rr_history', 'max_history_records' + 'delete_sso_accounts', 'otp_field_enabled', 'custom_css', 'enable_api_rr_history', 'max_history_records' ] diff --git a/powerdnsadmin/routes/api.py b/powerdnsadmin/routes/api.py index ccbc460..448144b 100644 --- a/powerdnsadmin/routes/api.py +++ b/powerdnsadmin/routes/api.py @@ -21,7 +21,7 @@ from ..lib.errors import ( DomainNotExists, DomainAlreadyExists, DomainAccessForbidden, RequestIsNotJSON, ApiKeyCreateFail, ApiKeyNotUsable, NotEnoughPrivileges, AccountCreateFail, AccountUpdateFail, AccountDeleteFail, - AccountCreateDuplicate, + AccountCreateDuplicate, AccountNotExists, UserCreateFail, UserCreateDuplicate, UserUpdateFail, UserDeleteFail, UserUpdateFailEmail, ) @@ -307,6 +307,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 +318,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 +334,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 +361,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 +384,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 +498,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 +516,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 +529,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 +584,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 +613,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 +622,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)) @@ -856,7 +922,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 +942,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) @@ -1055,8 +1121,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/swagger-spec.yaml b/powerdnsadmin/swagger-spec.yaml index 6c7e575..24592f9 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,12 @@ definitions: type: string description: The name for this account (unique, immutable) readOnly: false + domains: + type: array + description: The list of domains owned by this account + readOnly: true + items: + $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 %}