Skip to content

Commit

Permalink
Feat/index record alias api (#248)
Browse files Browse the repository at this point in the history
* Add ability to create, update and delete aliases for GUIDs. Aliases can be used to query the root endpoint for a record instead of that record's GUID. Aliases are associated with one GUID only.

* API uses create and update Arborist permissions

* Change IndexRecordAlias table -- add unique constraint to alias name column
  • Loading branch information
em-ingram authored Nov 7, 2019
1 parent f815606 commit d0f5410
Show file tree
Hide file tree
Showing 9 changed files with 1,296 additions and 194 deletions.
18 changes: 18 additions & 0 deletions docs/aliases.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Aliases for Indexd records

The alias feature in `indexd` allows a client to associate an alias with a document and then retrieve the document by that alias.

There are currently two implementations of an alias system in the codebase, one of which is deprecated. The original alias system (the `/alias` endpoint) was deprecated
because it aliases records by hash instead of by GUID (See https://github.com/uc-cdis/indexd/issues/173). It was replaced by the new alias system (the `/index/{GUID}/aliases` endpoint)
in 11/2019.

## How the current alias system works (`/index/{GUID}/alias` endpoint)

The current alias system allows the client to associate an alias (a text string)
with a document's GUID. (In the indexd codebase, GUIDs are also referred to as `did`s.) An alias cannot be associated with more than one GUID. Once a client has associated an alias with a record, the record can be retrieved by the alias on the root endpoint (`/{alias}`).

**Aliases do not carry over to new versions of a resource**. When a new version of a resource is created with `POST /index/{GUID}`, the new version has a different GUID than
the old version. Aliases are associated with GUIDs, and the old version's aliases do not carry over to the new version's GUID. It is the client's responsibility to migrate aliases
to new versions of a resource if this is the behavior they want.

> NOTE: The current alias system is implemented in `indexd/index/blueprint.py` and uses the `index_record_alias` table. Confusingly, the current alias system is **not** implemented in `/indexd/alias` and does **not** use the `alias_record` table -- these are from the deprecated original alias system.
3 changes: 2 additions & 1 deletion indexd/dos/blueprint.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ def get_dos_record(record):
"""
try:
ret = blueprint.index_driver.get(record)
ret["alias"] = blueprint.index_driver.get_aliases_for_did(record)
# record may be a baseID or a DID / GUID. If record is a baseID, ret["did"] is the latest GUID for that record.
ret["alias"] = blueprint.index_driver.get_aliases_for_did(ret["did"])
except IndexNoRecordFound:
try:
ret = blueprint.index_driver.get_by_alias(record)
Expand Down
81 changes: 81 additions & 0 deletions indexd/index/blueprint.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,17 @@

from .schema import PUT_RECORD_SCHEMA
from .schema import POST_RECORD_SCHEMA
from .schema import RECORD_ALIAS_SCHEMA

from .errors import NoRecordFound
from .errors import MultipleRecordsFound
from .errors import RevisionMismatch
from .errors import UnhealthyCheck

from cdislogging import get_logger

logger = get_logger("indexd/index blueprint", log_level="info")

blueprint = flask.Blueprint("index", __name__)

blueprint.config = dict()
Expand Down Expand Up @@ -375,6 +380,82 @@ def add_index_record_version(record):
return flask.jsonify(ret), 200


@blueprint.route("/index/<path:record>/aliases", methods=["GET"])
def get_aliases(record):
"""
Get all aliases associated with this DID / GUID
"""
# error handling done in driver
aliases = blueprint.index_driver.get_aliases_for_did(record)

aliases_payload = {"aliases": [{"value": alias} for alias in aliases]}
return flask.jsonify(aliases_payload), 200


@blueprint.route("/index/<path:record>/aliases", methods=["POST"])
def append_aliases(record):
"""
Append one or more aliases to aliases already associated with this
DID / GUID, if any.
"""
# we set force=True so that if MIME type of request is not application/JSON,
# get_json will still throw a UserError.
aliases_json = flask.request.get_json(force=True)
try:
jsonschema.validate(aliases_json, RECORD_ALIAS_SCHEMA)
except jsonschema.ValidationError as err:
logger.warn(f"Bad request body:\n{err}")
raise UserError(err)

aliases = [record["value"] for record in aliases_json["aliases"]]

# authorization and error handling done in driver
blueprint.index_driver.append_aliases_for_did(aliases, record)

aliases = blueprint.index_driver.get_aliases_for_did(record)
aliases_payload = {"aliases": [{"value": alias} for alias in aliases]}
return flask.jsonify(aliases_payload), 200


@blueprint.route("/index/<path:record>/aliases", methods=["PUT"])
def replace_aliases(record):
"""
Replace all aliases associated with this DID / GUID
"""
# we set force=True so that if MIME type of request is not application/JSON,
# get_json will still throw a UserError.
aliases_json = flask.request.get_json(force=True)
try:
jsonschema.validate(aliases_json, RECORD_ALIAS_SCHEMA)
except jsonschema.ValidationError as err:
logger.warn(f"Bad request body:\n{err}")
raise UserError(err)

aliases = [record["value"] for record in aliases_json["aliases"]]

# authorization and error handling done in driver
blueprint.index_driver.replace_aliases_for_did(aliases, record)

aliases_payload = {"aliases": [{"value": alias} for alias in aliases]}
return flask.jsonify(aliases_payload), 200


@blueprint.route("/index/<path:record>/aliases", methods=["DELETE"])
def delete_all_aliases(record):
# authorization and error handling done in driver
blueprint.index_driver.delete_all_aliases_for_did(record)

return flask.jsonify("Aliases deleted successfully"), 200


@blueprint.route("/index/<path:record>/aliases/<path:alias>", methods=["DELETE"])
def delete_one_alias(record, alias):
# authorization and error handling done in driver
blueprint.index_driver.delete_one_alias_for_did(alias, record)

return flask.jsonify("Aliases deleted successfully"), 200


@blueprint.route("/index/<path:record>/versions", methods=["GET"])
def get_all_index_record_versions(record):
"""
Expand Down
177 changes: 175 additions & 2 deletions indexd/index/drivers/alchemy.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound

from indexd import auth
from indexd.errors import UserError
from indexd.errors import UserError, AuthError
from indexd.index.driver import IndexDriverABC
from indexd.index.errors import (
MultipleRecordsFound,
Expand Down Expand Up @@ -145,7 +145,7 @@ class IndexRecordAlias(Base):
__tablename__ = "index_record_alias"

did = Column(String, ForeignKey("index_record.did"), primary_key=True)
name = Column(String, primary_key=True)
name = Column(String, primary_key=True, unique=True)

__table_args__ = (
Index("index_record_alias_idx", "did"),
Expand Down Expand Up @@ -256,6 +256,14 @@ def create_urls_metadata(urls_metadata, record, session):
session.add(IndexRecordUrlMetadata(url=url, key=k, value=v, did=record.did))


def get_record_if_exists(did, session):
"""
Searches for a record with this did and returns it.
If no record found, returns None.
"""
return session.query(IndexRecord).filter(IndexRecord.did == did).first()


class SQLAlchemyIndexDriver(IndexDriverABC):
"""
SQLAlchemy implementation of index driver.
Expand Down Expand Up @@ -764,9 +772,167 @@ def get_aliases_for_did(self, did):
Gets the aliases for a did
"""
with self.session as session:
self.logger.info(f"Trying to get all aliases for did {did}...")

index_record = get_record_if_exists(did, session)
if index_record is None:
self.logger.warn(f"No record found for did {did}")
raise NoRecordFound(did)

query = session.query(IndexRecordAlias).filter(IndexRecordAlias.did == did)
return [i.name for i in query]

def append_aliases_for_did(self, aliases, did):
"""
Append one or more aliases to aliases already associated with one DID / GUID.
"""
with self.session as session:
self.logger.info(
f"Trying to append new aliases {aliases} to aliases for did {did}..."
)

index_record = get_record_if_exists(did, session)
if index_record is None:
self.logger.warn(f"No record found for did {did}")
raise NoRecordFound(did)

# authorization
try:
resources = [u.resource for u in index_record.authz]
auth.authorize("update", resources)
except AuthError as err:
self.logger.warn(
f"Auth error while appending aliases to did {did}: User not authorized to update one or more of these resources: {resources}"
)
raise err

# add new aliases
index_record_aliases = [
IndexRecordAlias(did=did, name=alias) for alias in aliases
]
try:
session.add_all(index_record_aliases)
session.commit()
except IntegrityError as err:
# One or more aliases in request were non-unique
self.logger.warn(
f"One or more aliases in request already associated with this or another GUID: {aliases}"
)
raise UserError(
f"One or more aliases in request already associated with this or another GUID: {aliases}"
)

def replace_aliases_for_did(self, aliases, did):
"""
Replace all aliases for one DID / GUID with new aliases.
"""
with self.session as session:
self.logger.info(
f"Trying to replace aliases for did {did} with new aliases {aliases}..."
)

index_record = get_record_if_exists(did, session)
if index_record is None:
self.logger.warn(f"No record found for did {did}")
raise NoRecordFound(did)

# authorization
try:
resources = [u.resource for u in index_record.authz]
auth.authorize("update", resources)
except AuthError as err:
self.logger.warn(
f"Auth error while replacing aliases for did {did}: User not authorized to update one or more of these resources: {resources}"
)
raise err

try:
# delete this GUID's aliases
session.query(IndexRecordAlias).filter(
IndexRecordAlias.did == did
).delete(synchronize_session="evaluate")
# add new aliases
index_record_aliases = [
IndexRecordAlias(did=did, name=alias) for alias in aliases
]
session.add_all(index_record_aliases)
session.commit()
self.logger.info(
f"Replaced aliases for did {did} with new aliases {aliases}"
)
except IntegrityError:
# One or more aliases in request were non-unique
self.logger.warn(
f"One or more aliases in request already associated with another GUID: {aliases}"
)
raise UserError(
f"One or more aliases in request already associated with another GUID: {aliases}"
)

def delete_all_aliases_for_did(self, did):
"""
Delete all of this DID / GUID's aliases.
"""
with self.session as session:
self.logger.info(f"Trying to delete all aliases for did {did}...")

index_record = get_record_if_exists(did, session)
if index_record is None:
self.logger.warn(f"No record found for did {did}")
raise NoRecordFound(did)

# authorization
try:
resources = [u.resource for u in index_record.authz]
auth.authorize("delete", resources)
except AuthError as err:
self.logger.warn(
f"Auth error while deleting all aliases for did {did}: User not authorized to delete one or more of these resources: {resources}"
)
raise err

# delete all aliases
session.query(IndexRecordAlias).filter(IndexRecordAlias.did == did).delete(
synchronize_session="evaluate"
)

self.logger.info(f"Deleted all aliases for did {did}.")

def delete_one_alias_for_did(self, alias, did):
"""
Delete one of this DID / GUID's aliases.
"""
with self.session as session:
self.logger.info(f"Trying to delete alias {alias} for did {did}...")

index_record = get_record_if_exists(did, session)
if index_record is None:
self.logger.warn(f"No record found for did {did}")
raise NoRecordFound(did)

# authorization
try:
resources = [u.resource for u in index_record.authz]
auth.authorize("delete", resources)
except AuthError as err:
self.logger.warn(
f"Auth error deleting alias {alias} for did {did}: User not authorized to delete one or more of these resources: {resources}"
)
raise err

# delete just this alias
num_rows_deleted = (
session.query(IndexRecordAlias)
.filter(IndexRecordAlias.did == did, IndexRecordAlias.name == alias)
.delete(synchronize_session="evaluate")
)

if num_rows_deleted == 0:
self.logger.warn(f"No alias {alias} found for did {did}")
raise NoRecordFound(alias)

self.logger.info(f"Deleted alias {alias} for did {did}.")

def get(self, did):
"""
Gets a record given the record id or baseid.
Expand Down Expand Up @@ -1255,6 +1421,12 @@ def migrate_12(session, **kwargs):
)


def migrate_13(session, **kwargs):
session.execute(
"ALTER TABLE {} ADD UNIQUE ( name )".format(IndexRecordAlias.__tablename__)
)


# ordered schema migration functions that the index should correspond to
# CURRENT_SCHEMA_VERSION - 1 when it's written
SCHEMA_MIGRATION_FUNCTIONS = [
Expand All @@ -1270,5 +1442,6 @@ def migrate_12(session, **kwargs):
migrate_10,
migrate_11,
migrate_12,
migrate_13,
]
CURRENT_SCHEMA_VERSION = len(SCHEMA_MIGRATION_FUNCTIONS)
13 changes: 13 additions & 0 deletions indexd/index/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,16 @@
"urls_metadata": {"type": "object"},
},
}

RECORD_ALIAS_SCHEMA = {
"$schema": "http://json-schema.org/schema#",
"type": "object",
"additionalProperties": False,
"description": "Aliases that can be used in place of an Index record's DID",
"properties": {
"aliases": {
"type": "array",
"items": {"type": "object", "properties": {"value": {"type": "string"}}},
}
},
}
Loading

0 comments on commit d0f5410

Please sign in to comment.