From 62b48d90c462238cd48bd7c70736dc0da583abee Mon Sep 17 00:00:00 2001 From: Juan Marulanda <86245530+jmaruland@users.noreply.github.com> Date: Wed, 13 Dec 2023 07:02:34 -0500 Subject: [PATCH] Added create service method (#619) * Added created service method * added function to create new principal * Refactor authentication test methods * fixed precommit issues * add service account from context * Migrate to add 'write:prinicipals' to default admin role. * Apply new "write:principals" scope protection * Docstring clarifications Co-authored-by: Padraic Shafer <76011594+padraic-shafer@users.noreply.github.com> * Improve error handling on unknown role. * Use briefer name; rely on namespace to distinguish. * Fix sign of error handling --------- Co-authored-by: Thomas Morris Co-authored-by: Dan Allan Co-authored-by: Padraic Shafer <76011594+padraic-shafer@users.noreply.github.com> Co-authored-by: Dan Allan --- tiled/_tests/test_authentication.py | 53 ++++++++++--------- tiled/authn_database/core.py | 14 ++++- ...32e_add_write_principals_scope_to_admin.py | 47 ++++++++++++++++ tiled/client/context.py | 46 ++++++++++++++++ tiled/scopes.py | 3 ++ tiled/server/authentication.py | 35 ++++++++++++ tiled/server/schemas.py | 2 +- 7 files changed, 172 insertions(+), 28 deletions(-) create mode 100644 tiled/authn_database/migrations/versions/769180ce732e_add_write_principals_scope_to_admin.py diff --git a/tiled/_tests/test_authentication.py b/tiled/_tests/test_authentication.py index 50a79b8b0..4b0d1d2fe 100644 --- a/tiled/_tests/test_authentication.py +++ b/tiled/_tests/test_authentication.py @@ -539,9 +539,8 @@ def test_admin_api_key_any_principal( context.authenticate(username="alice") principal_uuid = principals_context["uuid"][username] - api_key = _create_api_key_other_principal( - context=context, uuid=principal_uuid, scopes=scopes - ) + api_key_info = context.admin.create_api_key(principal_uuid, scopes=scopes) + api_key = api_key_info["secret"] assert api_key context.logout() @@ -553,6 +552,27 @@ def test_admin_api_key_any_principal( context.http_client.get(resource).raise_for_status() +def test_admin_create_service_principal(enter_password, principals_context): + """ + Admin can create service accounts with API keys. + """ + with principals_context["context"] as context: + # Log in as Alice, create and use API key after logout + with enter_password("secret1"): + context.authenticate(username="alice") + + assert context.whoami()["type"] == "user" + + principal_info = context.admin.create_service_principal(role="user") + principal_uuid = principal_info["uuid"] + + service_api_key_info = context.admin.create_api_key(principal_uuid) + context.logout() + + context.api_key = service_api_key_info["secret"] + assert context.whoami()["type"] == "service" + + def test_admin_api_key_any_principal_exceeds_scopes(enter_password, principals_context): """ Admin cannot create API key that exceeds scopes for another principal. @@ -564,11 +584,9 @@ def test_admin_api_key_any_principal_exceeds_scopes(enter_password, principals_c principal_uuid = principals_context["uuid"]["bob"] with fail_with_status_code(400) as fail_info: - _create_api_key_other_principal( - context=context, uuid=principal_uuid, scopes=["read:principals"] - ) - fail_message = " must be a subset of the principal's scopes " - assert fail_message in fail_info.response.text + context.admin.create_api_key(principal_uuid, scopes=["read:principals"]) + fail_message = " must be a subset of the principal's scopes " + assert fail_message in fail_info.value.response.text context.logout() @@ -584,9 +602,7 @@ def test_api_key_any_principal(enter_password, principals_context, username): principal_uuid = principals_context["uuid"][username] with fail_with_status_code(401): - _create_api_key_other_principal( - context=context, uuid=principal_uuid, scopes=["read:metadata"] - ) + context.admin.create_api_key(principal_uuid, scopes=["read:metadata"]) def test_api_key_bypass_scopes(enter_password, principals_context): @@ -619,18 +635,3 @@ def test_api_key_bypass_scopes(enter_password, principals_context): context.http_client.get( resource, params=query_params ).raise_for_status() - - -def _create_api_key_other_principal(context, uuid, scopes=None): - """ - Return api_key or raise error. - """ - response = context.http_client.post( - f"/api/v1/auth/principal/{uuid}/apikey", - json={"expires_in": None, "scopes": scopes or []}, - ) - response.raise_for_status() - api_key_info = response.json() - api_key = api_key_info["secret"] - - return api_key diff --git a/tiled/authn_database/core.py b/tiled/authn_database/core.py index d90b0acfd..efabb4ae9 100644 --- a/tiled/authn_database/core.py +++ b/tiled/authn_database/core.py @@ -12,9 +12,10 @@ # This is the alembic revision ID of the database revision # required by this version of Tiled. -REQUIRED_REVISION = "c7bd2573716d" +REQUIRED_REVISION = "769180ce732e" # This is list of all valid revisions (from current to oldest). ALL_REVISIONS = [ + "769180ce732e", "c7bd2573716d", "4a9dfaba4a98", "56809bcbfcb0", @@ -49,6 +50,7 @@ async def create_default_roles(db): "write:data", "admin:apikeys", "read:principals", + "write:principals", "metrics", ], ), @@ -113,6 +115,16 @@ async def create_user(db, identity_provider, id): return refreshed_principal +async def create_service(db, role): + role_ = (await db.execute(select(Role).filter(Role.name == role))).scalar() + if role_ is None: + raise ValueError(f"Role named {role!r} is not found") + principal = Principal(type="service", roles=[role_]) + db.add(principal) + await db.commit() + return principal + + async def lookup_valid_session(db, session_id): if isinstance(session_id, int): # Old versions of tiled used an integer sid. diff --git a/tiled/authn_database/migrations/versions/769180ce732e_add_write_principals_scope_to_admin.py b/tiled/authn_database/migrations/versions/769180ce732e_add_write_principals_scope_to_admin.py new file mode 100644 index 000000000..4448297dc --- /dev/null +++ b/tiled/authn_database/migrations/versions/769180ce732e_add_write_principals_scope_to_admin.py @@ -0,0 +1,47 @@ +"""Add 'write:principals' scope to admin + +Revision ID: 769180ce732e +Revises: c7bd2573716d +Create Date: 2023-12-12 17:57:56.388145 + +""" +from alembic import op +from sqlalchemy.orm.session import Session + +from tiled.authn_database.orm import Role + +# revision identifiers, used by Alembic. +revision = "769180ce732e" +down_revision = "c7bd2573716d" +branch_labels = None +depends_on = None + + +SCOPE = "write:principals" + + +def upgrade(): + """ + Add 'write:principals' scope to default 'admin' Role. + """ + connection = op.get_bind() + with Session(bind=connection) as db: + role = db.query(Role).filter(Role.name == "admin").first() + scopes = role.scopes.copy() + scopes.append(SCOPE) + role.scopes = scopes + db.commit() + + +def downgrade(): + """ + Remove new scopes from Roles, if present. + """ + connection = op.get_bind() + with Session(bind=connection) as db: + role = db.query(Role).filter(Role.name == "admin").first() + scopes = role.scopes.copy() + if SCOPE in scopes: + scopes.remove(SCOPE) + role.scopes = scopes + db.commit() diff --git a/tiled/client/context.py b/tiled/client/context.py index 8a7bbda15..66288bd61 100644 --- a/tiled/client/context.py +++ b/tiled/client/context.py @@ -765,6 +765,52 @@ def show_principal(self, uuid): self.context.http_client.get(f"{self.base_url}/auth/principal/{uuid}") ).json() + def create_api_key(self, uuid, scopes=None, expires_in=None, note=None): + """ + Generate a new API key for another user or service. + + Parameters + ---------- + uuid : str + Identify the principal -- the user or service + scopes : Optional[List[str]] + Restrict the access available to the API key by listing specific scopes. + By default, this will have the same access as the principal. + expires_in : Optional[int] + Number of seconds until API key expires. If None, + it will never expire or it will have the maximum lifetime + allowed by the server. + note : Optional[str] + Description (for humans). + """ + return handle_error( + self.context.http_client.post( + f"{self.base_url}/auth/principal/{uuid}/apikey", + headers={"Accept": MSGPACK_MIME_TYPE}, + json={"scopes": scopes, "expires_in": expires_in, "note": note}, + ) + ).json() + + def create_service_principal( + self, + role, + ): + """ + Generate a new service principal. + + Parameters + ---------- + role : str + Specify the role (e.g. user or admin) + """ + return handle_error( + self.context.http_client.post( + f"{self.base_url}/auth/principal", + headers={"Accept": MSGPACK_MIME_TYPE}, + params={"role": role}, + ) + ).json() + class CannotPrompt(Exception): pass diff --git a/tiled/scopes.py b/tiled/scopes.py index ea62e9dab..36d438c22 100644 --- a/tiled/scopes.py +++ b/tiled/scopes.py @@ -14,4 +14,7 @@ "read:principals": { "description": "Read list of all users and services and their attributes." }, + "write:principals": { + "description": "Edit list of all users and services and their attributes." + }, } diff --git a/tiled/server/authentication.py b/tiled/server/authentication.py index 7760d4b08..0d30e982b 100644 --- a/tiled/server/authentication.py +++ b/tiled/server/authentication.py @@ -43,6 +43,7 @@ from ..authn_database import orm from ..authn_database.connection_pool import get_database_session from ..authn_database.core import ( + create_service, create_user, latest_principal_activity, lookup_valid_api_key, @@ -823,6 +824,40 @@ async def principal_list( return json_or_msgpack(request, principals) +@base_authentication_router.post( + "/principal", + response_model=schemas.Principal, +) +async def create_service_principal( + request: Request, + principal=Security(get_current_principal, scopes=["write:principals"]), + db=Depends(get_database_session), + role: str = Query(...), +): + "Create a principal for a service account." + + principal_orm = await create_service(db, role) + + # Relaod to select Principal and Identiies. + fully_loaded_principal_orm = ( + await db.execute( + select(orm.Principal) + .options( + selectinload(orm.Principal.identities), + selectinload(orm.Principal.roles), + selectinload(orm.Principal.api_keys), + selectinload(orm.Principal.sessions), + ) + .filter(orm.Principal.id == principal_orm.id) + ) + ).scalar() + + principal = schemas.Principal.from_orm(fully_loaded_principal_orm).dict() + request.state.endpoint = "auth" + + return json_or_msgpack(request, principal) + + @base_authentication_router.get( "/principal/{uuid}", response_model=schemas.Principal, diff --git a/tiled/server/schemas.py b/tiled/server/schemas.py index ab18f0526..40258fa5f 100644 --- a/tiled/server/schemas.py +++ b/tiled/server/schemas.py @@ -288,7 +288,7 @@ class About(pydantic.BaseModel): class PrincipalType(str, enum.Enum): user = "user" - service = "service" # TODO Add support for services. + service = "service" class Identity(pydantic.BaseModel, orm_mode=True):