Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added create service method #619

Merged
merged 11 commits into from
Dec 13, 2023
53 changes: 27 additions & 26 deletions tiled/_tests/test_authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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.
Expand All @@ -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()


Expand All @@ -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):
Expand Down Expand Up @@ -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
14 changes: 13 additions & 1 deletion tiled/authn_database/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -49,6 +50,7 @@ async def create_default_roles(db):
"write:data",
"admin:apikeys",
"read:principals",
"write:principals",
"metrics",
],
),
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
46 changes: 46 additions & 0 deletions tiled/client/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions tiled/scopes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
},
}
35 changes: 35 additions & 0 deletions tiled/server/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(...),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For a future PR, should we consider accepting this parameter from Query or Body?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My thinking was:

  • The role name will never be large (i.e. it will fit in the URL always).
  • The role name is not sensitive (i.e. not a credential).
  • Query params are simple.

I guess JSON bodies are also simple, though. Is there a particular driving use case?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking only for flexibility, but I agree that there is not a strong case for changing at the moment.

):
"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,
Expand Down
2 changes: 1 addition & 1 deletion tiled/server/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down