Skip to content

Commit 62b48d9

Browse files
jmarulandThomas Morrisdanielballanpadraic-shafer
authored
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 <thomasmorris@princeton.edu> Co-authored-by: Dan Allan <dallan@bnl.gov> Co-authored-by: Padraic Shafer <76011594+padraic-shafer@users.noreply.github.com> Co-authored-by: Dan Allan <daniel.b.allan@gmail.com>
1 parent 5984ad1 commit 62b48d9

File tree

7 files changed

+172
-28
lines changed

7 files changed

+172
-28
lines changed

tiled/_tests/test_authentication.py

Lines changed: 27 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -539,9 +539,8 @@ def test_admin_api_key_any_principal(
539539
context.authenticate(username="alice")
540540

541541
principal_uuid = principals_context["uuid"][username]
542-
api_key = _create_api_key_other_principal(
543-
context=context, uuid=principal_uuid, scopes=scopes
544-
)
542+
api_key_info = context.admin.create_api_key(principal_uuid, scopes=scopes)
543+
api_key = api_key_info["secret"]
545544
assert api_key
546545
context.logout()
547546

@@ -553,6 +552,27 @@ def test_admin_api_key_any_principal(
553552
context.http_client.get(resource).raise_for_status()
554553

555554

555+
def test_admin_create_service_principal(enter_password, principals_context):
556+
"""
557+
Admin can create service accounts with API keys.
558+
"""
559+
with principals_context["context"] as context:
560+
# Log in as Alice, create and use API key after logout
561+
with enter_password("secret1"):
562+
context.authenticate(username="alice")
563+
564+
assert context.whoami()["type"] == "user"
565+
566+
principal_info = context.admin.create_service_principal(role="user")
567+
principal_uuid = principal_info["uuid"]
568+
569+
service_api_key_info = context.admin.create_api_key(principal_uuid)
570+
context.logout()
571+
572+
context.api_key = service_api_key_info["secret"]
573+
assert context.whoami()["type"] == "service"
574+
575+
556576
def test_admin_api_key_any_principal_exceeds_scopes(enter_password, principals_context):
557577
"""
558578
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
564584

565585
principal_uuid = principals_context["uuid"]["bob"]
566586
with fail_with_status_code(400) as fail_info:
567-
_create_api_key_other_principal(
568-
context=context, uuid=principal_uuid, scopes=["read:principals"]
569-
)
570-
fail_message = " must be a subset of the principal's scopes "
571-
assert fail_message in fail_info.response.text
587+
context.admin.create_api_key(principal_uuid, scopes=["read:principals"])
588+
fail_message = " must be a subset of the principal's scopes "
589+
assert fail_message in fail_info.value.response.text
572590
context.logout()
573591

574592

@@ -584,9 +602,7 @@ def test_api_key_any_principal(enter_password, principals_context, username):
584602

585603
principal_uuid = principals_context["uuid"][username]
586604
with fail_with_status_code(401):
587-
_create_api_key_other_principal(
588-
context=context, uuid=principal_uuid, scopes=["read:metadata"]
589-
)
605+
context.admin.create_api_key(principal_uuid, scopes=["read:metadata"])
590606

591607

592608
def test_api_key_bypass_scopes(enter_password, principals_context):
@@ -619,18 +635,3 @@ def test_api_key_bypass_scopes(enter_password, principals_context):
619635
context.http_client.get(
620636
resource, params=query_params
621637
).raise_for_status()
622-
623-
624-
def _create_api_key_other_principal(context, uuid, scopes=None):
625-
"""
626-
Return api_key or raise error.
627-
"""
628-
response = context.http_client.post(
629-
f"/api/v1/auth/principal/{uuid}/apikey",
630-
json={"expires_in": None, "scopes": scopes or []},
631-
)
632-
response.raise_for_status()
633-
api_key_info = response.json()
634-
api_key = api_key_info["secret"]
635-
636-
return api_key

tiled/authn_database/core.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@
1212

1313
# This is the alembic revision ID of the database revision
1414
# required by this version of Tiled.
15-
REQUIRED_REVISION = "c7bd2573716d"
15+
REQUIRED_REVISION = "769180ce732e"
1616
# This is list of all valid revisions (from current to oldest).
1717
ALL_REVISIONS = [
18+
"769180ce732e",
1819
"c7bd2573716d",
1920
"4a9dfaba4a98",
2021
"56809bcbfcb0",
@@ -49,6 +50,7 @@ async def create_default_roles(db):
4950
"write:data",
5051
"admin:apikeys",
5152
"read:principals",
53+
"write:principals",
5254
"metrics",
5355
],
5456
),
@@ -113,6 +115,16 @@ async def create_user(db, identity_provider, id):
113115
return refreshed_principal
114116

115117

118+
async def create_service(db, role):
119+
role_ = (await db.execute(select(Role).filter(Role.name == role))).scalar()
120+
if role_ is None:
121+
raise ValueError(f"Role named {role!r} is not found")
122+
principal = Principal(type="service", roles=[role_])
123+
db.add(principal)
124+
await db.commit()
125+
return principal
126+
127+
116128
async def lookup_valid_session(db, session_id):
117129
if isinstance(session_id, int):
118130
# Old versions of tiled used an integer sid.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""Add 'write:principals' scope to admin
2+
3+
Revision ID: 769180ce732e
4+
Revises: c7bd2573716d
5+
Create Date: 2023-12-12 17:57:56.388145
6+
7+
"""
8+
from alembic import op
9+
from sqlalchemy.orm.session import Session
10+
11+
from tiled.authn_database.orm import Role
12+
13+
# revision identifiers, used by Alembic.
14+
revision = "769180ce732e"
15+
down_revision = "c7bd2573716d"
16+
branch_labels = None
17+
depends_on = None
18+
19+
20+
SCOPE = "write:principals"
21+
22+
23+
def upgrade():
24+
"""
25+
Add 'write:principals' scope to default 'admin' Role.
26+
"""
27+
connection = op.get_bind()
28+
with Session(bind=connection) as db:
29+
role = db.query(Role).filter(Role.name == "admin").first()
30+
scopes = role.scopes.copy()
31+
scopes.append(SCOPE)
32+
role.scopes = scopes
33+
db.commit()
34+
35+
36+
def downgrade():
37+
"""
38+
Remove new scopes from Roles, if present.
39+
"""
40+
connection = op.get_bind()
41+
with Session(bind=connection) as db:
42+
role = db.query(Role).filter(Role.name == "admin").first()
43+
scopes = role.scopes.copy()
44+
if SCOPE in scopes:
45+
scopes.remove(SCOPE)
46+
role.scopes = scopes
47+
db.commit()

tiled/client/context.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -765,6 +765,52 @@ def show_principal(self, uuid):
765765
self.context.http_client.get(f"{self.base_url}/auth/principal/{uuid}")
766766
).json()
767767

768+
def create_api_key(self, uuid, scopes=None, expires_in=None, note=None):
769+
"""
770+
Generate a new API key for another user or service.
771+
772+
Parameters
773+
----------
774+
uuid : str
775+
Identify the principal -- the user or service
776+
scopes : Optional[List[str]]
777+
Restrict the access available to the API key by listing specific scopes.
778+
By default, this will have the same access as the principal.
779+
expires_in : Optional[int]
780+
Number of seconds until API key expires. If None,
781+
it will never expire or it will have the maximum lifetime
782+
allowed by the server.
783+
note : Optional[str]
784+
Description (for humans).
785+
"""
786+
return handle_error(
787+
self.context.http_client.post(
788+
f"{self.base_url}/auth/principal/{uuid}/apikey",
789+
headers={"Accept": MSGPACK_MIME_TYPE},
790+
json={"scopes": scopes, "expires_in": expires_in, "note": note},
791+
)
792+
).json()
793+
794+
def create_service_principal(
795+
self,
796+
role,
797+
):
798+
"""
799+
Generate a new service principal.
800+
801+
Parameters
802+
----------
803+
role : str
804+
Specify the role (e.g. user or admin)
805+
"""
806+
return handle_error(
807+
self.context.http_client.post(
808+
f"{self.base_url}/auth/principal",
809+
headers={"Accept": MSGPACK_MIME_TYPE},
810+
params={"role": role},
811+
)
812+
).json()
813+
768814

769815
class CannotPrompt(Exception):
770816
pass

tiled/scopes.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,7 @@
1414
"read:principals": {
1515
"description": "Read list of all users and services and their attributes."
1616
},
17+
"write:principals": {
18+
"description": "Edit list of all users and services and their attributes."
19+
},
1720
}

tiled/server/authentication.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
from ..authn_database import orm
4444
from ..authn_database.connection_pool import get_database_session
4545
from ..authn_database.core import (
46+
create_service,
4647
create_user,
4748
latest_principal_activity,
4849
lookup_valid_api_key,
@@ -823,6 +824,40 @@ async def principal_list(
823824
return json_or_msgpack(request, principals)
824825

825826

827+
@base_authentication_router.post(
828+
"/principal",
829+
response_model=schemas.Principal,
830+
)
831+
async def create_service_principal(
832+
request: Request,
833+
principal=Security(get_current_principal, scopes=["write:principals"]),
834+
db=Depends(get_database_session),
835+
role: str = Query(...),
836+
):
837+
"Create a principal for a service account."
838+
839+
principal_orm = await create_service(db, role)
840+
841+
# Relaod to select Principal and Identiies.
842+
fully_loaded_principal_orm = (
843+
await db.execute(
844+
select(orm.Principal)
845+
.options(
846+
selectinload(orm.Principal.identities),
847+
selectinload(orm.Principal.roles),
848+
selectinload(orm.Principal.api_keys),
849+
selectinload(orm.Principal.sessions),
850+
)
851+
.filter(orm.Principal.id == principal_orm.id)
852+
)
853+
).scalar()
854+
855+
principal = schemas.Principal.from_orm(fully_loaded_principal_orm).dict()
856+
request.state.endpoint = "auth"
857+
858+
return json_or_msgpack(request, principal)
859+
860+
826861
@base_authentication_router.get(
827862
"/principal/{uuid}",
828863
response_model=schemas.Principal,

tiled/server/schemas.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,7 @@ class About(pydantic.BaseModel):
288288

289289
class PrincipalType(str, enum.Enum):
290290
user = "user"
291-
service = "service" # TODO Add support for services.
291+
service = "service"
292292

293293

294294
class Identity(pydantic.BaseModel, orm_mode=True):

0 commit comments

Comments
 (0)