Skip to content

Commit

Permalink
Added create service method (#619)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>

* Improve error handling on unknown role.

* Use briefer name; rely on namespace to distinguish.

* Fix sign of error handling

---------

Co-authored-by: Thomas Morris <[email protected]>
Co-authored-by: Dan Allan <[email protected]>
Co-authored-by: Padraic Shafer <[email protected]>
Co-authored-by: Dan Allan <[email protected]>
  • Loading branch information
5 people authored Dec 13, 2023
1 parent 5984ad1 commit 62b48d9
Show file tree
Hide file tree
Showing 7 changed files with 172 additions and 28 deletions.
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(...),
):
"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

0 comments on commit 62b48d9

Please sign in to comment.