From ec056d9164412bde7e2df1db04a89ef319558da9 Mon Sep 17 00:00:00 2001 From: pdmurray Date: Mon, 28 Oct 2024 15:22:17 -0700 Subject: [PATCH 01/18] Initial work --- .../conda_store_server/_internal/orm.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/conda-store-server/conda_store_server/_internal/orm.py b/conda-store-server/conda_store_server/_internal/orm.py index adfffed88..555320d92 100644 --- a/conda-store-server/conda_store_server/_internal/orm.py +++ b/conda-store-server/conda_store_server/_internal/orm.py @@ -817,3 +817,23 @@ def new_session_factory( session_factory = sessionmaker(bind=engine) return session_factory + +class User(Base): + """User which contains permissions to namespaces and environments.""" + id = Column(Integer, primary_key=True) + name = Column(Unicode, unique=True) + permissions = relationship("UserPermission", back_populates="user") + +class UserPermission(Base): + """Table which defines the permissions a User has for an namespace/environment. + + Intended to replace NamespaceRoleMapping and NamespaceRoleMappingV2. + """ + id = Column(Integer, primary_key=True) + user_id = ForeignKey("user.id") + namespace_id = ForeignKey("namespace.id") + role = + +class Role(Base): + id = Column(Integer, primary_key=True) + role = Column(Enum(schema.), default=schema.BuildStatus.QUEUED) From 575482c2e44863b9f553e3cf815a71a021da71b9 Mon Sep 17 00:00:00 2001 From: pdmurray Date: Fri, 1 Nov 2024 17:40:55 -0700 Subject: [PATCH 02/18] Finish Role, UserMapping, and User implementations --- .../conda_store_server/_internal/orm.py | 23 +++++--- .../conda_store_server/_internal/schema.py | 54 ++++++++++++++++++- 2 files changed, 70 insertions(+), 7 deletions(-) diff --git a/conda-store-server/conda_store_server/_internal/orm.py b/conda-store-server/conda_store_server/_internal/orm.py index 555320d92..9b7c189bb 100644 --- a/conda-store-server/conda_store_server/_internal/orm.py +++ b/conda-store-server/conda_store_server/_internal/orm.py @@ -818,22 +818,33 @@ def new_session_factory( session_factory = sessionmaker(bind=engine) return session_factory + +class Role(Base): + """The role of a user for a namespace/environment.""" + + id = Column(Integer, primary_key=True) + name = Column(Enum(schema.Role), default=schema.BuildStatus.QUEUED) + + class User(Base): """User which contains permissions to namespaces and environments.""" + id = Column(Integer, primary_key=True) name = Column(Unicode, unique=True) permissions = relationship("UserPermission", back_populates="user") + class UserPermission(Base): - """Table which defines the permissions a User has for an namespace/environment. + """The permissions a User has for an namespace/environment. + + Maps a namespace/environment matching rule to a `Role` for the matching + namespaces/environments. Intended to replace NamespaceRoleMapping and NamespaceRoleMappingV2. """ + id = Column(Integer, primary_key=True) user_id = ForeignKey("user.id") namespace_id = ForeignKey("namespace.id") - role = - -class Role(Base): - id = Column(Integer, primary_key=True) - role = Column(Enum(schema.), default=schema.BuildStatus.QUEUED) + role_id = Column(Integer, ForeignKey("role.id")) + role = relationship(Role) diff --git a/conda-store-server/conda_store_server/_internal/schema.py b/conda-store-server/conda_store_server/_internal/schema.py index 96dd735d6..eb398e82c 100644 --- a/conda-store-server/conda_store_server/_internal/schema.py +++ b/conda-store-server/conda_store_server/_internal/schema.py @@ -1,6 +1,7 @@ # Copyright (c) conda-store development team. All rights reserved. # Use of this source code is governed by a BSD-style # license that can be found in the LICENSE file. +from __future__ import annotations import datetime import enum @@ -8,7 +9,8 @@ import os import re import sys -from typing import Any, Callable, Dict, List, Optional, TypeAlias, Union +import warnings +from typing import Any, Callable, Dict, List, Optional, Tuple, TypeAlias, Union from conda_lock.lockfile.v1.models import Lockfile from pydantic import BaseModel, Field, ValidationError, constr, validator @@ -39,6 +41,56 @@ def _datetime_factory(offset: datetime.timedelta): RoleBindings: TypeAlias = Dict[constr(regex=ARN_ALLOWED), List[str]] +@functools.total_ordering +class Role(enum.Enum): + """The role determines the permissions of a user for a namespace/env. + + Role members can be looked up by their name, or by their (rank, name) + tuples, e.g. + + >>> Role('admin') + + >>> Role((2, 'admin')) + + """ + + VIEWER = (0, "viewer") + EDITOR = (1, "editor") + ADMIN = (2, "admin") + + @classmethod + def _missing_(cls, value: str | Tuple[int, str]): + if isinstance(value, str): + if value.lower() == "developer": + warnings.warn( + ( + "'developer' is a deprecated alias for 'editor' and " + "will be removed in a future verison." + ), + DeprecationWarning, + ) + return cls.EDITOR + + for member in Role: + if member.value[1] == value.lower(): + return member + + return None + + # If the value passed is a tuple, just search the list of members + for member in Role: + if member == value: + return member + + return None + + def __eq__(self, other: Role): + return self.value[0] == other.value[0] + + def __ge__(self, other: Role): + return self.value[0] >= other.value[0] + + class Permissions(enum.Enum): """Permissions map to conda-store actions""" From 252e20bd547779a82313bbd1f91c22e0d0f48e1a Mon Sep 17 00:00:00 2001 From: pdmurray Date: Fri, 1 Nov 2024 20:51:53 -0700 Subject: [PATCH 03/18] Start adding endpoints --- .../conda_store_server/_internal/schema.py | 5 ++++ .../_internal/server/dependencies.py | 25 +++++++++++++++++-- .../_internal/server/views/api.py | 16 +++++++++++- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/conda-store-server/conda_store_server/_internal/schema.py b/conda-store-server/conda_store_server/_internal/schema.py index eb398e82c..400ce60b8 100644 --- a/conda-store-server/conda_store_server/_internal/schema.py +++ b/conda-store-server/conda_store_server/_internal/schema.py @@ -821,3 +821,8 @@ class APIPutSetting(APIResponse): # GET /api/v1/usage/ class APIGetUsage(APIResponse): data: Dict[str, Dict[str, Any]] + + +# PUT /api/v1/user/ +class APIPutUser(APIAckResponse): + pass diff --git a/conda-store-server/conda_store_server/_internal/server/dependencies.py b/conda-store-server/conda_store_server/_internal/server/dependencies.py index 84c04fd54..25695a12f 100644 --- a/conda-store-server/conda_store_server/_internal/server/dependencies.py +++ b/conda-store-server/conda_store_server/_internal/server/dependencies.py @@ -4,6 +4,9 @@ from fastapi import Depends, Request +from conda_store_server._internal import schema +from conda_store_server.server.auth import Authentication + async def get_conda_store(request: Request): return request.state.conda_store @@ -13,11 +16,29 @@ async def get_server(request: Request): return request.state.server -async def get_auth(request: Request): +async def get_auth(request: Request) -> Authentication: return request.state.authentication -async def get_entity(request: Request, auth=Depends(get_auth)): +async def get_entity( + request: Request, + auth: Authentication = Depends(get_auth), +) -> schema.AuthenticationToken: + """Get the token representing the user who made the request. + + Parameters + ---------- + auth : auth.Authentication + Authentication instance + request : Request + Raw starlette request + + Returns + ------- + str + A string containing the encoded + + """ return auth.authenticate_request(request) diff --git a/conda-store-server/conda_store_server/_internal/server/views/api.py b/conda-store-server/conda_store_server/_internal/server/views/api.py index e55df4462..321fa6f9c 100644 --- a/conda-store-server/conda_store_server/_internal/server/views/api.py +++ b/conda-store-server/conda_store_server/_internal/server/views/api.py @@ -14,7 +14,10 @@ from conda_store_server import __version__, api, app from conda_store_server._internal import orm, schema, utils from conda_store_server._internal.environment import filter_environments -from conda_store_server._internal.schema import AuthenticationToken, Permissions +from conda_store_server._internal.schema import ( + AuthenticationToken, + Permissions, +) from conda_store_server._internal.server import dependencies from conda_store_server.server.auth import Authentication @@ -1483,3 +1486,14 @@ async def api_put_settings( "data": None, "message": f"global setting keys {list(data.keys())} updated", } + + +@router_api.put( + "/user/", + response_model=schema.APIPutUser, +) +async def api_put_user( + request: Request, + auth: Authentication = Depends(dependencies.get_auth), +): + auth.authenticate_request(request) From d06b5deb91da3f15b366276c12678d8770dd5473 Mon Sep 17 00:00:00 2001 From: pdmurray Date: Mon, 4 Nov 2024 16:10:12 -0800 Subject: [PATCH 04/18] Add tests for schema.Role --- .../tests/_internal/test_schema.py | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 conda-store-server/tests/_internal/test_schema.py diff --git a/conda-store-server/tests/_internal/test_schema.py b/conda-store-server/tests/_internal/test_schema.py new file mode 100644 index 000000000..a4c3d753e --- /dev/null +++ b/conda-store-server/tests/_internal/test_schema.py @@ -0,0 +1,47 @@ +import pytest + +from conda_store_server._internal import schema + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("Viewer", schema.Role.VIEWER), + ("editor", schema.Role.EDITOR), + ("ADMIN", schema.Role.ADMIN), + ((0, "viewer"), schema.Role.VIEWER), + ((1, "editor"), schema.Role.EDITOR), + ((2, "admin"), schema.Role.ADMIN), + ], +) +def test_valid_role(value, expected): + """Test that valid Role values instantiate correctly.""" + assert schema.Role(value) == expected + + +@pytest.mark.parametrize( + ("value"), + [ + ("foo"), + (2, "viewer"), + ], +) +def test_invalid_role(value): + """Test that invalid Role values raise an exception.""" + with pytest.Raises(ValueError): + schema.Role(value) + + +def test_deprecated_role(): + """Test that 'developer' is a deprecated alias to 'editor'.""" + with pytest.deprecated_call(): + assert schema.Role("developer") == schema.Role.EDITOR + + +def test_role_rankings(): + """Test that Role object comparisons work as intended.""" + assert schema.Role.VIEWER < schema.Role.EDITOR < schema.Role.ADMIN + assert schema.Role.ADMIN > schema.Role.EDITOR > schema.Role.VIEWER + assert schema.Role.VIEWER == schema.Role.VIEWER + assert schema.Role.EDITOR == schema.Role.EDITOR + assert schema.Role.ADMIN == schema.Role.ADMIN From 8b2764076c1f9ed8deb2a9d398742fe164253894 Mon Sep 17 00:00:00 2001 From: pdmurray Date: Mon, 4 Nov 2024 16:26:50 -0800 Subject: [PATCH 05/18] Add hashing for schema.Role type --- .../conda_store_server/_internal/schema.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/conda-store-server/conda_store_server/_internal/schema.py b/conda-store-server/conda_store_server/_internal/schema.py index 400ce60b8..0754c502e 100644 --- a/conda-store-server/conda_store_server/_internal/schema.py +++ b/conda-store-server/conda_store_server/_internal/schema.py @@ -90,6 +90,15 @@ def __eq__(self, other: Role): def __ge__(self, other: Role): return self.value[0] >= other.value[0] + def __hash__(self): + """Compute the hash of the Role. + + Required because objects which define __eq__ do not automatically + define __hash__, which is required for this class to be used as an + Enum column type with sqlalchemy. + """ + return hash(self.value) + class Permissions(enum.Enum): """Permissions map to conda-store actions""" From 5bfec3803118845860a966a048a30e1c3e19de53 Mon Sep 17 00:00:00 2001 From: pdmurray Date: Mon, 4 Nov 2024 16:33:25 -0800 Subject: [PATCH 06/18] Add __tablename__ to make sqlalchemy happy --- conda-store-server/conda_store_server/_internal/orm.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/conda-store-server/conda_store_server/_internal/orm.py b/conda-store-server/conda_store_server/_internal/orm.py index 9b7c189bb..c3f068a53 100644 --- a/conda-store-server/conda_store_server/_internal/orm.py +++ b/conda-store-server/conda_store_server/_internal/orm.py @@ -822,6 +822,8 @@ def new_session_factory( class Role(Base): """The role of a user for a namespace/environment.""" + __tablename__ = "role" + id = Column(Integer, primary_key=True) name = Column(Enum(schema.Role), default=schema.BuildStatus.QUEUED) @@ -829,6 +831,8 @@ class Role(Base): class User(Base): """User which contains permissions to namespaces and environments.""" + __tablename__ = "user" + id = Column(Integer, primary_key=True) name = Column(Unicode, unique=True) permissions = relationship("UserPermission", back_populates="user") @@ -843,6 +847,8 @@ class UserPermission(Base): Intended to replace NamespaceRoleMapping and NamespaceRoleMappingV2. """ + __tablename__ = "userpermission" + id = Column(Integer, primary_key=True) user_id = ForeignKey("user.id") namespace_id = ForeignKey("namespace.id") From 78bdbc82b553d2be8cf008330289cf683e1fba4c Mon Sep 17 00:00:00 2001 From: pdmurray Date: Mon, 4 Nov 2024 16:49:34 -0800 Subject: [PATCH 07/18] Fix tests --- .../conda_store_server/_internal/schema.py | 2 +- .../_internal/server/dependencies.py | 10 +++------- conda-store-server/tests/_internal/test_schema.py | 2 +- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/conda-store-server/conda_store_server/_internal/schema.py b/conda-store-server/conda_store_server/_internal/schema.py index 0754c502e..906b9f5c6 100644 --- a/conda-store-server/conda_store_server/_internal/schema.py +++ b/conda-store-server/conda_store_server/_internal/schema.py @@ -79,7 +79,7 @@ def _missing_(cls, value: str | Tuple[int, str]): # If the value passed is a tuple, just search the list of members for member in Role: - if member == value: + if member.value == value: return member return None diff --git a/conda-store-server/conda_store_server/_internal/server/dependencies.py b/conda-store-server/conda_store_server/_internal/server/dependencies.py index 25695a12f..ace05fa09 100644 --- a/conda-store-server/conda_store_server/_internal/server/dependencies.py +++ b/conda-store-server/conda_store_server/_internal/server/dependencies.py @@ -1,12 +1,8 @@ # Copyright (c) conda-store development team. All rights reserved. # Use of this source code is governed by a BSD-style # license that can be found in the LICENSE file. - from fastapi import Depends, Request -from conda_store_server._internal import schema -from conda_store_server.server.auth import Authentication - async def get_conda_store(request: Request): return request.state.conda_store @@ -16,14 +12,14 @@ async def get_server(request: Request): return request.state.server -async def get_auth(request: Request) -> Authentication: +async def get_auth(request: Request): return request.state.authentication async def get_entity( request: Request, - auth: Authentication = Depends(get_auth), -) -> schema.AuthenticationToken: + auth=Depends(get_auth), +): """Get the token representing the user who made the request. Parameters diff --git a/conda-store-server/tests/_internal/test_schema.py b/conda-store-server/tests/_internal/test_schema.py index a4c3d753e..2b7455fb5 100644 --- a/conda-store-server/tests/_internal/test_schema.py +++ b/conda-store-server/tests/_internal/test_schema.py @@ -28,7 +28,7 @@ def test_valid_role(value, expected): ) def test_invalid_role(value): """Test that invalid Role values raise an exception.""" - with pytest.Raises(ValueError): + with pytest.raises(ValueError): schema.Role(value) From 26060e1c84522b04c7914abefeaf194fac9df5c1 Mon Sep 17 00:00:00 2001 From: pdmurray Date: Thu, 7 Nov 2024 13:40:35 -0800 Subject: [PATCH 08/18] Working on adding new user to the database --- .../conda_store_server/_internal/orm.py | 24 +++++++++---------- .../conda_store_server/_internal/schema.py | 5 ---- .../_internal/server/views/api.py | 15 ++++-------- conda-store-server/conda_store_server/api.py | 20 +++++++++++++++- .../conda_store_server/server/auth.py | 24 ++++++++++++++++++- 5 files changed, 58 insertions(+), 30 deletions(-) diff --git a/conda-store-server/conda_store_server/_internal/orm.py b/conda-store-server/conda_store_server/_internal/orm.py index c3f068a53..58a67334a 100644 --- a/conda-store-server/conda_store_server/_internal/orm.py +++ b/conda-store-server/conda_store_server/_internal/orm.py @@ -828,16 +828,6 @@ class Role(Base): name = Column(Enum(schema.Role), default=schema.BuildStatus.QUEUED) -class User(Base): - """User which contains permissions to namespaces and environments.""" - - __tablename__ = "user" - - id = Column(Integer, primary_key=True) - name = Column(Unicode, unique=True) - permissions = relationship("UserPermission", back_populates="user") - - class UserPermission(Base): """The permissions a User has for an namespace/environment. @@ -850,7 +840,17 @@ class UserPermission(Base): __tablename__ = "userpermission" id = Column(Integer, primary_key=True) - user_id = ForeignKey("user.id") - namespace_id = ForeignKey("namespace.id") + namespace_id = Column(Integer, ForeignKey("namespace.id")) role_id = Column(Integer, ForeignKey("role.id")) role = relationship(Role) + + +class User(Base): + """User which contains permissions to namespaces and environments.""" + + __tablename__ = "user" + + id = Column(Integer, primary_key=True) + name = Column(Unicode, unique=True) + permissions = relationship(UserPermission) + permissions_id = Column(Integer, ForeignKey('userpermission.id')) diff --git a/conda-store-server/conda_store_server/_internal/schema.py b/conda-store-server/conda_store_server/_internal/schema.py index 906b9f5c6..5e83e6b67 100644 --- a/conda-store-server/conda_store_server/_internal/schema.py +++ b/conda-store-server/conda_store_server/_internal/schema.py @@ -830,8 +830,3 @@ class APIPutSetting(APIResponse): # GET /api/v1/usage/ class APIGetUsage(APIResponse): data: Dict[str, Dict[str, Any]] - - -# PUT /api/v1/user/ -class APIPutUser(APIAckResponse): - pass diff --git a/conda-store-server/conda_store_server/_internal/server/views/api.py b/conda-store-server/conda_store_server/_internal/server/views/api.py index 321fa6f9c..bcd1e2d7f 100644 --- a/conda-store-server/conda_store_server/_internal/server/views/api.py +++ b/conda-store-server/conda_store_server/_internal/server/views/api.py @@ -248,6 +248,10 @@ async def api_post_token( detail="Requested expiration of token is greater than current permissions", ) + + with conda_store.get_db() as db: + + return { "status": "ok", "data": {"token": auth.authentication.encrypt_token(new_entity)}, @@ -1486,14 +1490,3 @@ async def api_put_settings( "data": None, "message": f"global setting keys {list(data.keys())} updated", } - - -@router_api.put( - "/user/", - response_model=schema.APIPutUser, -) -async def api_put_user( - request: Request, - auth: Authentication = Depends(dependencies.get_auth), -): - auth.authenticate_request(request) diff --git a/conda-store-server/conda_store_server/api.py b/conda-store-server/conda_store_server/api.py index 328d6bae4..3ddb2d9ef 100644 --- a/conda-store-server/conda_store_server/api.py +++ b/conda-store-server/conda_store_server/api.py @@ -4,7 +4,8 @@ from __future__ import annotations import re -from typing import Any, Dict, List, Union +import uuid +from typing import Any, Dict, List, Optional, Union from sqlalchemy import distinct, func, null, or_ from sqlalchemy.orm import Query, aliased, session @@ -823,3 +824,20 @@ def set_kvstore_key_values(db, prefix: str, d: Dict[str, Any], update: bool = Tr elif update: record.value = value db.commit() + + +def set_new_user( + db: session.Session, + token: schema.AuthenticationToken, + username: Optional[str] = None, +): + # Parse the token into a set of namespace/environment permissions + user_permissions = [] + + # Add the user with the given permissions + db.add( + orm.User( + name=username if username else uuid.uuid4(), + permissions=user_permissions, + ) + ) diff --git a/conda-store-server/conda_store_server/server/auth.py b/conda-store-server/conda_store_server/server/auth.py index d2ee4a43a..ebacfdc21 100644 --- a/conda-store-server/conda_store_server/server/auth.py +++ b/conda-store-server/conda_store_server/server/auth.py @@ -572,7 +572,29 @@ def post_logout_method(self, request: Request, next: Optional[str] = None): response.set_cookie(self.cookie_name, "", domain=self.cookie_domain, expires=0) return response - def authenticate_request(self, request: Request, require=False): + def authenticate_request( + self, + request: Request, + require: bool = False + ) -> Optional[schema.AuthenticationToken]: + """Authenticate a request. + + Parameters + ---------- + request : Request + Web request to authenticate + require : bool + Require that there be a token in either the request's 'Authorization' + header or in the request cookies. If such a token exists, it must be able to + be decrypted or parsed as a valid schema.AuthenticationToken; if no token + exists or the token isn't valid, a 401 will be returned if this argument is + True. + + Returns + ------- + Optional[schema.AuthenticationToken] + User authentication token (if present), else None + """ if hasattr(request.state, "entity"): pass # only authenticate once elif request.cookies.get(self.cookie_name): From 310aecad27cc8cee3e0e81b4e23191af6f7b7da6 Mon Sep 17 00:00:00 2001 From: pdmurray Date: Thu, 7 Nov 2024 22:55:02 -0800 Subject: [PATCH 09/18] Add Role.max_role; add a function to add a new user --- .../conda_store_server/_internal/orm.py | 9 ++-- .../conda_store_server/_internal/schema.py | 44 ++++++++++++++---- .../_internal/server/views/api.py | 5 +- conda-store-server/conda_store_server/api.py | 21 ++++++++- .../conda_store_server/server/auth.py | 46 ++++++++++++++----- .../tests/_internal/test_schema.py | 38 ++++++++++++--- 6 files changed, 127 insertions(+), 36 deletions(-) diff --git a/conda-store-server/conda_store_server/_internal/orm.py b/conda-store-server/conda_store_server/_internal/orm.py index 58a67334a..d5d3e2aa4 100644 --- a/conda-store-server/conda_store_server/_internal/orm.py +++ b/conda-store-server/conda_store_server/_internal/orm.py @@ -840,9 +840,9 @@ class UserPermission(Base): __tablename__ = "userpermission" id = Column(Integer, primary_key=True) - namespace_id = Column(Integer, ForeignKey("namespace.id")) - role_id = Column(Integer, ForeignKey("role.id")) - role = relationship(Role) + environment = relationship(Environment) + environment_id = Column(Integer, ForeignKey("environment.id")) + role = Column(Enum(schema.Role), default=schema.Role.NONE) class User(Base): @@ -852,5 +852,4 @@ class User(Base): id = Column(Integer, primary_key=True) name = Column(Unicode, unique=True) - permissions = relationship(UserPermission) - permissions_id = Column(Integer, ForeignKey('userpermission.id')) + permissions = relationship(UserPermission, back_populates="user") diff --git a/conda-store-server/conda_store_server/_internal/schema.py b/conda-store-server/conda_store_server/_internal/schema.py index 5e83e6b67..d4874102e 100644 --- a/conda-store-server/conda_store_server/_internal/schema.py +++ b/conda-store-server/conda_store_server/_internal/schema.py @@ -10,7 +10,18 @@ import re import sys import warnings -from typing import Any, Callable, Dict, List, Optional, Tuple, TypeAlias, Union +from typing import ( + Any, + Callable, + Dict, + Iterable, + List, + Optional, + Set, + Tuple, + TypeAlias, + Union, +) from conda_lock.lockfile.v1.models import Lockfile from pydantic import BaseModel, Field, ValidationError, constr, validator @@ -38,7 +49,7 @@ def _datetime_factory(offset: datetime.timedelta): # Authentication Schema ######################### -RoleBindings: TypeAlias = Dict[constr(regex=ARN_ALLOWED), List[str]] +RoleBindings: TypeAlias = Dict[constr(regex=ARN_ALLOWED), Set[str]] @functools.total_ordering @@ -49,14 +60,15 @@ class Role(enum.Enum): tuples, e.g. >>> Role('admin') - - >>> Role((2, 'admin')) - + + >>> Role((3, 'admin')) + """ - VIEWER = (0, "viewer") - EDITOR = (1, "editor") - ADMIN = (2, "admin") + NONE = (0, "none") + VIEWER = (1, "viewer") + EDITOR = (2, "editor") + ADMIN = (3, "admin") @classmethod def _missing_(cls, value: str | Tuple[int, str]): @@ -99,6 +111,22 @@ def __hash__(self): """ return hash(self.value) + @classmethod + def max_role(cls, objects: Iterable[Union[str, Tuple[int, str]]]) -> Role: + """Return the highest role for an iterable of role values. + + Parameters + ---------- + objects : Iterable[Union[str, Tuple[int, str]]] + Objects to find the highest Role of + + Returns + ------- + Role + Highest role of all the objects + """ + return cls(max(cls(obj).value for obj in objects)) + class Permissions(enum.Enum): """Permissions map to conda-store actions""" diff --git a/conda-store-server/conda_store_server/_internal/server/views/api.py b/conda-store-server/conda_store_server/_internal/server/views/api.py index bcd1e2d7f..d6d7ba3de 100644 --- a/conda-store-server/conda_store_server/_internal/server/views/api.py +++ b/conda-store-server/conda_store_server/_internal/server/views/api.py @@ -221,7 +221,7 @@ async def api_post_token( conda_store=Depends(dependencies.get_conda_store), auth=Depends(dependencies.get_auth), entity=Depends(dependencies.get_entity), -): +) -> schema.APIPostToken: if entity is None: entity = schema.AuthenticationToken( exp=datetime.datetime.now(tz=datetime.timezone.utc) @@ -248,9 +248,8 @@ async def api_post_token( detail="Requested expiration of token is greater than current permissions", ) - with conda_store.get_db() as db: - + api.set_new_user(db=db, token=entity, permissions=auth.entity_bindings(entity)) return { "status": "ok", diff --git a/conda-store-server/conda_store_server/api.py b/conda-store-server/conda_store_server/api.py index 3ddb2d9ef..42ca1fa79 100644 --- a/conda-store-server/conda_store_server/api.py +++ b/conda-store-server/conda_store_server/api.py @@ -826,13 +826,29 @@ def set_kvstore_key_values(db, prefix: str, d: Dict[str, Any], update: bool = Tr db.commit() -def set_new_user( +def add_new_user( db: session.Session, token: schema.AuthenticationToken, + role_bindings: schema.RoleBindings, username: Optional[str] = None, ): - # Parse the token into a set of namespace/environment permissions + # Parse the token into a set of namespace/environment roles. + # Only use the maximum role in the set of RoleBindings, since + # that's what determines the permissions for the namespace/ + # environment. + all_envs = db.query(orm.Environment).join(orm.Namespace) + user_permissions = [] + for pattern, roles in role_bindings.items(): + max_role = schema.Role.max_role(roles) + + for environment in filter_environments( + query=all_envs, + role_bindings={pattern: roles}, + ).all(): + user_permissions.append( + orm.UserPermission(environment=environment, role=max_role) + ) # Add the user with the given permissions db.add( @@ -841,3 +857,4 @@ def set_new_user( permissions=user_permissions, ) ) + db.commit() diff --git a/conda-store-server/conda_store_server/server/auth.py b/conda-store-server/conda_store_server/server/auth.py index ebacfdc21..b3094894c 100644 --- a/conda-store-server/conda_store_server/server/auth.py +++ b/conda-store-server/conda_store_server/server/auth.py @@ -80,7 +80,10 @@ class RBACAuthorizationBackend(LoggingConfigurable): config=True, ) - def _database_role_bindings_v1(self, entity: schema.AuthenticationToken): + def _database_role_bindings_v1( + self, + entity: schema.AuthenticationToken, + ) -> schema.RoleBindings: with self.authentication_db() as db: result = db.execute( text( @@ -101,7 +104,10 @@ def _database_role_bindings_v1(self, entity: schema.AuthenticationToken): return db_role_mappings - def _database_role_bindings_v2(self, entity: schema.AuthenticationToken): + def _database_role_bindings_v2( + self, + entity: schema.AuthenticationToken, + ) -> schema.RoleBindings: def _convert_namespace_to_entity_arn(namespace): return f"{namespace}/*" @@ -278,8 +284,9 @@ def is_arn_subset(arn_1: str, arn_2: str): return (arn_1_matches_arn_2 and arn_2_matches_arn_1) or arn_2_matches_arn_1 def get_entity_bindings( - self, entity: schema.AuthenticationToken - ) -> Set[schema.Permissions]: + self, + entity: schema.AuthenticationToken, + ) -> schema.RoleBindings: authenticated = entity is not None entity_role_bindings = {} if entity is None else entity.role_bindings @@ -329,7 +336,11 @@ def get_entity_binding_permissions(self, entity: schema.AuthenticationToken): for entity_arn, entity_roles in entity_bindings.items() } - def get_entity_permissions(self, entity: schema.AuthenticationToken, arn: str): + def get_entity_permissions( + self, + entity: schema.AuthenticationToken, + arn: str, + ) -> Set[schema.Permissions]: """Get set of permissions for given ARN given AUTHENTICATION state and entity_bindings @@ -345,7 +356,11 @@ def get_entity_permissions(self, entity: schema.AuthenticationToken, arn: str): permissions = permissions | set(entity_permissions) return permissions - def is_subset_entity_permissions(self, entity, new_entity): + def is_subset_entity_permissions( + self, + entity: schema.AuthenticationToken, + new_entity: schema.AuthenticationToken, + ) -> bool: """Determine if new_entity_bindings is a strict subset of entity_bindings This feature is required to allow authenticated entitys to @@ -368,13 +383,19 @@ def is_subset_entity_permissions(self, entity, new_entity): return True def authorize( - self, entity: schema.AuthenticationToken, arn: str, required_permissions + self, + entity: schema.AuthenticationToken, + arn: str, + required_permissions: Set[schema.Permissions], ): return required_permissions <= self.get_entity_permissions( entity=entity, arn=arn ) - def database_role_bindings(self, entity: schema.AuthenticationToken): + def database_role_bindings( + self, + entity: schema.AuthenticationToken, + ) -> schema.RoleBindings: # This method can be reached from the router_ui via filter_environments. # Since the UI routes are not versioned, we don't know which API version # the client might be using. So we rely on the role_mappings_version @@ -573,9 +594,7 @@ def post_logout_method(self, request: Request, next: Optional[str] = None): return response def authenticate_request( - self, - request: Request, - require: bool = False + self, request: Request, require: bool = False ) -> Optional[schema.AuthenticationToken]: """Authenticate a request. @@ -623,7 +642,10 @@ def authenticate_request( ) return request.state.entity - def entity_bindings(self, entity: schema.AuthenticationToken): + def entity_bindings( + self, + entity: schema.AuthenticationToken, + ) -> schema.RoleBindings: return self.authorization.get_entity_bindings(entity) def authorize_request(self, request: Request, arn, permissions, require=False): diff --git a/conda-store-server/tests/_internal/test_schema.py b/conda-store-server/tests/_internal/test_schema.py index 2b7455fb5..0269ca43a 100644 --- a/conda-store-server/tests/_internal/test_schema.py +++ b/conda-store-server/tests/_internal/test_schema.py @@ -6,12 +6,14 @@ @pytest.mark.parametrize( ("value", "expected"), [ + ("NoNe", schema.Role.NONE), ("Viewer", schema.Role.VIEWER), ("editor", schema.Role.EDITOR), ("ADMIN", schema.Role.ADMIN), - ((0, "viewer"), schema.Role.VIEWER), - ((1, "editor"), schema.Role.EDITOR), - ((2, "admin"), schema.Role.ADMIN), + ((0, "none"), schema.Role.NONE), + ((1, "viewer"), schema.Role.VIEWER), + ((2, "editor"), schema.Role.EDITOR), + ((3, "admin"), schema.Role.ADMIN), ], ) def test_valid_role(value, expected): @@ -23,7 +25,7 @@ def test_valid_role(value, expected): ("value"), [ ("foo"), - (2, "viewer"), + (5, "viewer"), ], ) def test_invalid_role(value): @@ -40,8 +42,32 @@ def test_deprecated_role(): def test_role_rankings(): """Test that Role object comparisons work as intended.""" - assert schema.Role.VIEWER < schema.Role.EDITOR < schema.Role.ADMIN - assert schema.Role.ADMIN > schema.Role.EDITOR > schema.Role.VIEWER + assert ( + schema.Role.NONE < schema.Role.VIEWER < schema.Role.EDITOR < schema.Role.ADMIN + ) + assert ( + schema.Role.ADMIN > schema.Role.EDITOR > schema.Role.VIEWER > schema.Role.NONE + ) + assert schema.Role.NONE == schema.Role.NONE assert schema.Role.VIEWER == schema.Role.VIEWER assert schema.Role.EDITOR == schema.Role.EDITOR assert schema.Role.ADMIN == schema.Role.ADMIN + + +@pytest.mark.parametrize( + ("roles", "expected"), + [ + (["none"], schema.Role.NONE), + (["none", "editor"], schema.Role.EDITOR), + (["none", "viewer"], schema.Role.VIEWER), + (["viewer", "editor"], schema.Role.EDITOR), + (["viewer", "editor", "admin"], schema.Role.ADMIN), + (["viewer", "admin"], schema.Role.ADMIN), + (["editor", "admin"], schema.Role.ADMIN), + (["viewer"], schema.Role.VIEWER), + (["editor", "editor"], schema.Role.EDITOR), + ], +) +def test_max_role(roles, expected): + """Test that the max_role returns the highest Role.""" + assert schema.Role.max_role(roles) == expected From 68480258413a4978a05cefe2f5057481ba31fc08 Mon Sep 17 00:00:00 2001 From: pdmurray Date: Fri, 8 Nov 2024 10:39:24 -0800 Subject: [PATCH 10/18] Improve `Role.max_role` --- .../conda_store_server/_internal/schema.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/conda-store-server/conda_store_server/_internal/schema.py b/conda-store-server/conda_store_server/_internal/schema.py index d4874102e..d306d6319 100644 --- a/conda-store-server/conda_store_server/_internal/schema.py +++ b/conda-store-server/conda_store_server/_internal/schema.py @@ -112,7 +112,7 @@ def __hash__(self): return hash(self.value) @classmethod - def max_role(cls, objects: Iterable[Union[str, Tuple[int, str]]]) -> Role: + def max_role(cls, objects: Iterable[Union[Role, str, Tuple[int, str]]]) -> Role: """Return the highest role for an iterable of role values. Parameters @@ -125,7 +125,13 @@ def max_role(cls, objects: Iterable[Union[str, Tuple[int, str]]]) -> Role: Role Highest role of all the objects """ - return cls(max(cls(obj).value for obj in objects)) + roles = [] + for obj in objects: + if isinstance(obj, cls): + roles.append(obj) + else: + roles.append(cls(obj)) + return max(roles) class Permissions(enum.Enum): From 380aebb65c16a485f891662ce84ec9b0a0b3550a Mon Sep 17 00:00:00 2001 From: pdmurray Date: Fri, 8 Nov 2024 13:32:50 -0800 Subject: [PATCH 11/18] Continue --- .../conda_store_server/_internal/orm.py | 9 - .../conda_store_server/_internal/schema.py | 1 + .../conda_store_server/_internal/utils.py | 20 ++ conda-store-server/conda_store_server/api.py | 177 ++++++++++++++++-- .../conda_store_server/server/auth.py | 51 ++++- 5 files changed, 228 insertions(+), 30 deletions(-) diff --git a/conda-store-server/conda_store_server/_internal/orm.py b/conda-store-server/conda_store_server/_internal/orm.py index d5d3e2aa4..0e987908a 100644 --- a/conda-store-server/conda_store_server/_internal/orm.py +++ b/conda-store-server/conda_store_server/_internal/orm.py @@ -819,15 +819,6 @@ def new_session_factory( return session_factory -class Role(Base): - """The role of a user for a namespace/environment.""" - - __tablename__ = "role" - - id = Column(Integer, primary_key=True) - name = Column(Enum(schema.Role), default=schema.BuildStatus.QUEUED) - - class UserPermission(Base): """The permissions a User has for an namespace/environment. diff --git a/conda-store-server/conda_store_server/_internal/schema.py b/conda-store-server/conda_store_server/_internal/schema.py index d306d6319..58e7a9151 100644 --- a/conda-store-server/conda_store_server/_internal/schema.py +++ b/conda-store-server/conda_store_server/_internal/schema.py @@ -162,6 +162,7 @@ class AuthenticationToken(BaseModel): ) primary_namespace: str = "default" role_bindings: RoleBindings = {} + user_name: str ########################## diff --git a/conda-store-server/conda_store_server/_internal/utils.py b/conda-store-server/conda_store_server/_internal/utils.py index 1ad717202..2fbfbefa1 100644 --- a/conda-store-server/conda_store_server/_internal/utils.py +++ b/conda-store-server/conda_store_server/_internal/utils.py @@ -11,6 +11,8 @@ import subprocess import sys import time +import warnings +from functools import wraps from typing import AnyStr from filelock import FileLock @@ -187,3 +189,21 @@ def compile_arn_sql_like( re.sub(r"\*", "%", match.group(1)), re.sub(r"\*", "%", match.group(2)), ) + + +def user_deprecation(f): + """Deprecation wrapper for functions that may be altered by adding Users to the database.""" + + @wraps(f) + def wrapped(*args, **kwargs): + warnings.warn( + ( + "Possibly deprecated by addition of User to db; see" + " https://github.com/conda-incubator/conda-store/issues/930" + ), + DeprecationWarning, + stacklevel=2, + ) + return f(*args, **kwargs) + + return wrapped diff --git a/conda-store-server/conda_store_server/api.py b/conda-store-server/conda_store_server/api.py index 42ca1fa79..ecf592edb 100644 --- a/conda-store-server/conda_store_server/api.py +++ b/conda-store-server/conda_store_server/api.py @@ -5,9 +5,9 @@ import re import uuid -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, Iterable, List, Optional, Union -from sqlalchemy import distinct, func, null, or_ +from sqlalchemy import and_, distinct, func, null, or_ from sqlalchemy.orm import Query, aliased, session from conda_store_server._internal import conda_utils, orm, schema, utils @@ -826,29 +826,27 @@ def set_kvstore_key_values(db, prefix: str, d: Dict[str, Any], update: bool = Tr db.commit() -def add_new_user( +def add_user( db: session.Session, - token: schema.AuthenticationToken, + username: str, role_bindings: schema.RoleBindings, - username: Optional[str] = None, ): - # Parse the token into a set of namespace/environment roles. - # Only use the maximum role in the set of RoleBindings, since - # that's what determines the permissions for the namespace/ - # environment. - all_envs = db.query(orm.Environment).join(orm.Namespace) + """Add a new user to the database. - user_permissions = [] - for pattern, roles in role_bindings.items(): - max_role = schema.Role.max_role(roles) + Parses the role_bindings to set the role bindings of the user in the database. Only + use the maximum role in the set of RoleBindings, since that's what determines the + permissions for the namespace/environment. - for environment in filter_environments( - query=all_envs, - role_bindings={pattern: roles}, - ).all(): - user_permissions.append( - orm.UserPermission(environment=environment, role=max_role) - ) + Parameters + ---------- + db : session.Session + Database to add the user to + username : str + Username of the new user + role_bindings : schema.RoleBindings + Role bindings to apply to the new user + """ + add_user_permissions(db, role_bindings) # Add the user with the given permissions db.add( @@ -858,3 +856,142 @@ def add_new_user( ) ) db.commit() + + +def get_user( + db: session.Session, user_name: Optional[str] = None, user_id: Optional[int] = None +) -> orm.User | None: + """Get a specific user from the database. + + Parameters + ---------- + db : session.Session + Database to search for the user + user_name : Optional[str] + Username of the user; if unspecified, use the user_id + user_id : Optional[int] + ID of the user; if unspecified, use the username + + Returns + ------- + orm.User | None + The user, if present in the database, else None + """ + filters = [] + if user_name: + filters.append(orm.User.name == user_name) + if user_id: + filters.append(orm.User.id == user_id) + + return db.query(orm.User).filter(and_(*filters)).first() + + +def update_user( + db: session.Session, + user_id: Optional[int] = None, + user_name: Optional[str] = None, + new_username: Optional[str] = None, + new_user_permissions: Optional[ + Union[Iterable[orm.UserPermission], schema.RoleBindings] + ] = None, +): + """Update a user's entry in the database. + + Parameters + ---------- + db : session.Session + Database where the user entry lives + user_id : Optional[int] + User ID to update; if unspecified, the user_name is used + user_name : Optional[str] + User name to update; if unspecified, the user_id is used + new_username : Optional[str] + New username to apply to the user + new_user_permissions : Optional[orm.UserPermission] + New user permissions to apply to the user + """ + user = get_user(user_id, user_name) + if not user: + if user_id: + if user_name: + raise ValueError( + f"No user with User.id == {user_id} and User.name == {user_name} found." + ) + raise ValueError(f"No user with User.id == {user_id} found.") + raise ValueError(f"No user with User.name == {user_name} found.") + + if new_username: + user.name = new_username + + if new_user_permissions: + user.permissions.delete() + add_user_permissions(new_user_permissions) + user.permissions = new_user_permissions + + +def add_user_permissions( + db: session.Session, + user_permissions: Union[Iterable[orm.UserPermission], schema.RoleBindings], +) -> List[orm.UserPermission]: + """Add a set of role bindings to the database as UserPermission entries. + + Parameters + ---------- + db : session.Session + Database to add user permissions + user_permissions : Union[Iterable[orm.UserPermission], schema.RoleBindings] + Role bindings to add to the database + + Returns + ------- + List[orm.UserPermission] + A list of the UserPermissions added to the database + """ + user_permissions = _role_bindings_to_user_permissions(db, user_permissions) + db.add_all(user_permissions) + db.commit() + return user_permissions + + +def _role_bindings_to_user_permissions( + db: session.Session, + role_bindings: Union[Iterable[orm.UserPermission], schema.RoleBindings], +) -> List[orm.UserPermission]: + """Given some RoleBindings, generate UserPermission objects for each related environment. + + Parameters + ---------- + db : session.Session + Database containing environments + role_bindings : Union[Iterable[orm.UserPermission], schema.RoleBindings] + Role bindings which may or may not have access to the environments + + If this is a list of UserPermission objects, do nothing. + + Returns + ------- + List[orm.UserPermission] + A list containing a UserPermission for each environment that matches a role binding + """ + all_envs = db.query(orm.Environment).join(orm.Namespace) + + if isinstance(role_bindings, schema.RoleBindings): + user_permissions = [] + for pattern, roles in role_bindings.items(): + max_role = schema.Role.max_role(roles) + + for environment in filter_environments( + query=all_envs, + role_bindings={pattern: roles}, + ).all(): + user_permissions.append( + orm.UserPermission( + environment=environment, + role=max_role, + ) + ) + + return user_permissions + + else: + return list(role_bindings) diff --git a/conda-store-server/conda_store_server/server/auth.py b/conda-store-server/conda_store_server/server/auth.py index b3094894c..be6ba6fa4 100644 --- a/conda-store-server/conda_store_server/server/auth.py +++ b/conda-store-server/conda_store_server/server/auth.py @@ -80,6 +80,7 @@ class RBACAuthorizationBackend(LoggingConfigurable): config=True, ) + @utils.user_deprecation def _database_role_bindings_v1( self, entity: schema.AuthenticationToken, @@ -104,6 +105,7 @@ def _database_role_bindings_v1( return db_role_mappings + @utils.user_deprecation def _database_role_bindings_v2( self, entity: schema.AuthenticationToken, @@ -283,6 +285,7 @@ def is_arn_subset(arn_1: str, arn_2: str): ) return (arn_1_matches_arn_2 and arn_2_matches_arn_1) or arn_2_matches_arn_1 + @utils.user_deprecation def get_entity_bindings( self, entity: schema.AuthenticationToken, @@ -304,6 +307,7 @@ def get_entity_bindings( **entity_role_bindings, } + @utils.user_deprecation def convert_roles_to_permissions( self, roles: Iterable[str] ) -> Set[schema.Permissions]: @@ -329,6 +333,7 @@ def convert_roles_to_permissions( permissions = permissions | role_mappings return permissions + @utils.user_deprecation def get_entity_binding_permissions(self, entity: schema.AuthenticationToken): entity_bindings = self.get_entity_bindings(entity) return { @@ -336,6 +341,7 @@ def get_entity_binding_permissions(self, entity: schema.AuthenticationToken): for entity_arn, entity_roles in entity_bindings.items() } + @utils.user_deprecation def get_entity_permissions( self, entity: schema.AuthenticationToken, @@ -356,6 +362,7 @@ def get_entity_permissions( permissions = permissions | set(entity_permissions) return permissions + @utils.user_deprecation def is_subset_entity_permissions( self, entity: schema.AuthenticationToken, @@ -382,6 +389,7 @@ def is_subset_entity_permissions( return False return True + @utils.user_deprecation def authorize( self, entity: schema.AuthenticationToken, @@ -392,6 +400,7 @@ def authorize( entity=entity, arn=arn ) + @utils.user_deprecation def database_role_bindings( self, entity: schema.AuthenticationToken, @@ -562,13 +571,49 @@ async def post_login_method( response: Response, next: Optional[str] = None, templates=Depends(dependencies.get_templates), - ): + ) -> Response: + """Handle post-login tasks. + + After successful authentication: + + - Check the database for a User entry; if it doesn't exist, + make one + - Redirect the user to display the environments + - Set the cookie with the encrypted token + + + Parameters + ---------- + templates : + + request : Request + POST request that was sent to `/login/` to log the user in + response : Response + Response to return to the requestor + next : Optional[str] + Next page to redirect the user to from the login screen + + Returns + ------- + Response + Response to return to the user. Contains the URL redirection and + the authenticated and encrypted token + """ authentication_token = await self.authenticate(request) if authentication_token is None: raise HTTPException( status_code=403, detail="Invalid authentication credentials" ) + # After user logs in, ensure that they have an entry in the database + with self.authentication_db() as db: + if not api.get_user(authentication_token.username): + api.add_user( + db=db, + username=authentication_token.username, + role_bindings=authentication_token.role_bindings, + ) + request.session["next"] = next or request.session.get("next") redirect_url = request.session.pop("next") or str( request.url_for("ui_list_environments") @@ -642,6 +687,7 @@ def authenticate_request( ) return request.state.entity + @utils.user_deprecation def entity_bindings( self, entity: schema.AuthenticationToken, @@ -665,6 +711,7 @@ def authorize_request(self, request: Request, arn, permissions, require=False): return request.state.authorized + @utils.user_deprecation def filter_builds(self, entity, query): cases = [] for entity_arn, entity_roles in self.entity_bindings(entity).items(): @@ -687,6 +734,7 @@ def filter_builds(self, entity, query): .filter(or_(*cases)) ) + @utils.user_deprecation def filter_environments( self, entity: schema.AuthenticationToken, query: Query ) -> Query: @@ -695,6 +743,7 @@ def filter_environments( self.entity_bindings(entity), ) + @utils.user_deprecation def filter_namespaces(self, entity, query): cases = [] for entity_arn, entity_roles in self.entity_bindings(entity).items(): From aa3240f6a56908e1e8adb2da2c93780452ff60fb Mon Sep 17 00:00:00 2001 From: pdmurray Date: Fri, 8 Nov 2024 18:31:57 -0800 Subject: [PATCH 12/18] New tables added; db migrations next --- .../conda_store_server/_internal/orm.py | 4 +- .../conda_store_server/_internal/schema.py | 7 ++ .../_internal/server/dependencies.py | 5 +- .../_internal/server/views/api.py | 27 +++-- conda-store-server/conda_store_server/api.py | 114 +++++++++--------- .../conda_store_server/server/auth.py | 22 ++-- conda-store-server/pyproject.toml | 1 - 7 files changed, 99 insertions(+), 81 deletions(-) diff --git a/conda-store-server/conda_store_server/_internal/orm.py b/conda-store-server/conda_store_server/_internal/orm.py index 0e987908a..1bb5c95cf 100644 --- a/conda-store-server/conda_store_server/_internal/orm.py +++ b/conda-store-server/conda_store_server/_internal/orm.py @@ -833,6 +833,8 @@ class UserPermission(Base): id = Column(Integer, primary_key=True) environment = relationship(Environment) environment_id = Column(Integer, ForeignKey("environment.id")) + user = relationship("User", back_populates="permissions") + user_id = Column(Integer, ForeignKey("user.id")) role = Column(Enum(schema.Role), default=schema.Role.NONE) @@ -843,4 +845,4 @@ class User(Base): id = Column(Integer, primary_key=True) name = Column(Unicode, unique=True) - permissions = relationship(UserPermission, back_populates="user") + permissions = relationship("UserPermission", back_populates="user") diff --git a/conda-store-server/conda_store_server/_internal/schema.py b/conda-store-server/conda_store_server/_internal/schema.py index 58e7a9151..df1eb7b17 100644 --- a/conda-store-server/conda_store_server/_internal/schema.py +++ b/conda-store-server/conda_store_server/_internal/schema.py @@ -50,6 +50,7 @@ def _datetime_factory(offset: datetime.timedelta): ######################### RoleBindings: TypeAlias = Dict[constr(regex=ARN_ALLOWED), Set[str]] +"""RoleBindings map env/namespace regexes to permissions for those envs/namespaces.""" @functools.total_ordering @@ -865,3 +866,9 @@ class APIPutSetting(APIResponse): # GET /api/v1/usage/ class APIGetUsage(APIResponse): data: Dict[str, Dict[str, Any]] + + +# POST /login/ +class APILoginRequest(BaseModel): + username: str + password: str diff --git a/conda-store-server/conda_store_server/_internal/server/dependencies.py b/conda-store-server/conda_store_server/_internal/server/dependencies.py index ace05fa09..72796b0b3 100644 --- a/conda-store-server/conda_store_server/_internal/server/dependencies.py +++ b/conda-store-server/conda_store_server/_internal/server/dependencies.py @@ -31,9 +31,8 @@ async def get_entity( Returns ------- - str - A string containing the encoded - + Optional[schema.AuthenticationToken] + An authenticated token, if present; otherwise None """ return auth.authenticate_request(request) diff --git a/conda-store-server/conda_store_server/_internal/server/views/api.py b/conda-store-server/conda_store_server/_internal/server/views/api.py index d6d7ba3de..076dbabd4 100644 --- a/conda-store-server/conda_store_server/_internal/server/views/api.py +++ b/conda-store-server/conda_store_server/_internal/server/views/api.py @@ -13,7 +13,6 @@ from conda_store_server import __version__, api, app from conda_store_server._internal import orm, schema, utils -from conda_store_server._internal.environment import filter_environments from conda_store_server._internal.schema import ( AuthenticationToken, Permissions, @@ -685,16 +684,19 @@ async def api_list_environments( """ with conda_store.get_db() as db: - if jwt: - # Fetch the environments visible to the supplied token - role_bindings = auth.entity_bindings( - AuthenticationToken.parse_obj(auth.authentication.decrypt_token(jwt)) - ) - else: - role_bindings = None + # if jwt: + # # Fetch the environments visible to the supplied token + # role_bindings = auth.entity_bindings( + # AuthenticationToken.parse_obj(auth.authentication.decrypt_token(jwt)) + # ) + # else: + # role_bindings = None + + user = api.get_user(db, user_name=entity.user_name) orm_environments = api.list_environments( db, + user=user, search=search, namespace=namespace, name=name, @@ -702,14 +704,13 @@ async def api_list_environments( packages=packages, artifact=artifact, show_soft_deleted=False, - role_bindings=role_bindings, ) # Filter by environments that the user who made the query has access to - orm_environments = filter_environments( - query=orm_environments, - role_bindings=auth.entity_bindings(entity), - ) + # orm_environments = filter_environments( + # query=orm_environments, + # role_bindings=auth.entity_bindings(entity), + # ) return paginated_api_response( orm_environments, diff --git a/conda-store-server/conda_store_server/api.py b/conda-store-server/conda_store_server/api.py index ecf592edb..262258499 100644 --- a/conda-store-server/conda_store_server/api.py +++ b/conda-store-server/conda_store_server/api.py @@ -7,7 +7,7 @@ import uuid from typing import Any, Dict, Iterable, List, Optional, Union -from sqlalchemy import and_, distinct, func, null, or_ +from sqlalchemy import and_, distinct, exists, func, null, or_ from sqlalchemy.orm import Query, aliased, session from conda_store_server._internal import conda_utils, orm, schema, utils @@ -278,6 +278,7 @@ def delete_namespace(db, name: str = None, id: int = None): def list_environments( db: session.Session, + user: orm.User, namespace: str = None, name: str = None, status: schema.BuildStatus = None, @@ -285,7 +286,6 @@ def list_environments( artifact: schema.BuildArtifactType = None, search: str = None, show_soft_deleted: bool = False, - role_bindings: schema.RoleBindings | None = None, ) -> Query: """Retrieve all environments managed by conda-store. @@ -309,15 +309,6 @@ def list_environments( show_soft_deleted : bool If specified, filter by environments which have a null value for the deleted_on attribute - role_bindings : schema.RoleBindings | None - If specified, filter by only the environments the given role_bindings - have read, write, or admin access to. This should be the same object as - the role bindings in conda_store_config.py, for example: - - { - "*/*": ['admin'], - ... - } Returns ------- @@ -367,10 +358,26 @@ def list_environments( .having(func.count() == len(packages)) ) - if role_bindings: - # Any entity binding is sufficient permissions to view an environment; - # no entity binding will hide the environment - query = filter_environments(query, role_bindings) + if user: + breakpoint() + query = ( + query.join( + orm.UserPermission, + orm.UserPermission.environment_id == orm.Environment.id, + ) + .join(orm.User, orm.User == user) + .filter( + and_( + orm.UserPermission.role > schema.Role.NONE, + orm.UserPermission in user.permissions, + ) + ) + ) + + # if role_bindings: + # # Any entity binding is sufficient permissions to view an environment; + # # no entity binding will hide the environment + # query = filter_environments(query, role_bindings) return query @@ -828,7 +835,7 @@ def set_kvstore_key_values(db, prefix: str, d: Dict[str, Any], update: bool = Tr def add_user( db: session.Session, - username: str, + user_name: str, role_bindings: schema.RoleBindings, ): """Add a new user to the database. @@ -841,17 +848,21 @@ def add_user( ---------- db : session.Session Database to add the user to - username : str + user_name : str Username of the new user role_bindings : schema.RoleBindings Role bindings to apply to the new user """ - add_user_permissions(db, role_bindings) + if db.query(exists().filter(orm.User.name == user_name)): + raise ValueError("Username '{user_name}' already exists in the database.") + + user_permissions = create_user_permissions(db, role_bindings) + add_user_permissions(db, user_permissions) # Add the user with the given permissions db.add( orm.User( - name=username if username else uuid.uuid4(), + name=user_name if user_name else uuid.uuid4(), permissions=user_permissions, ) ) @@ -925,13 +936,20 @@ def update_user( if new_user_permissions: user.permissions.delete() - add_user_permissions(new_user_permissions) + + if isinstance(new_user_permissions, schema.RoleBindings): + new_user_permissions = create_user_permissions(db, new_user_permissions) + + add_user_permissions(db, new_user_permissions) + user.permissions = new_user_permissions + db.commit() + def add_user_permissions( db: session.Session, - user_permissions: Union[Iterable[orm.UserPermission], schema.RoleBindings], + user_permissions: Iterable[orm.UserPermission], ) -> List[orm.UserPermission]: """Add a set of role bindings to the database as UserPermission entries. @@ -939,59 +957,47 @@ def add_user_permissions( ---------- db : session.Session Database to add user permissions - user_permissions : Union[Iterable[orm.UserPermission], schema.RoleBindings] + user_permissions : Iterable[orm.UserPermission] Role bindings to add to the database - - Returns - ------- - List[orm.UserPermission] - A list of the UserPermissions added to the database """ - user_permissions = _role_bindings_to_user_permissions(db, user_permissions) db.add_all(user_permissions) db.commit() - return user_permissions -def _role_bindings_to_user_permissions( +def create_user_permissions( db: session.Session, - role_bindings: Union[Iterable[orm.UserPermission], schema.RoleBindings], + role_bindings: schema.RoleBindings, ) -> List[orm.UserPermission]: - """Given some RoleBindings, generate UserPermission objects for each related environment. + """Generate UserPermission objects for each environment targeted by a role binding. + + These are not added to the database - see add_user_permissions. Parameters ---------- db : session.Session Database containing environments - role_bindings : Union[Iterable[orm.UserPermission], schema.RoleBindings] + role_bindings : schema.RoleBindings Role bindings which may or may not have access to the environments - If this is a list of UserPermission objects, do nothing. - Returns ------- List[orm.UserPermission] A list containing a UserPermission for each environment that matches a role binding """ all_envs = db.query(orm.Environment).join(orm.Namespace) - - if isinstance(role_bindings, schema.RoleBindings): - user_permissions = [] - for pattern, roles in role_bindings.items(): - max_role = schema.Role.max_role(roles) - - for environment in filter_environments( - query=all_envs, - role_bindings={pattern: roles}, - ).all(): - user_permissions.append( - orm.UserPermission( - environment=environment, - role=max_role, - ) + user_permissions = [] + for pattern, roles in role_bindings.items(): + max_role = schema.Role.max_role(roles) + + for environment in filter_environments( + query=all_envs, + role_bindings={pattern: roles}, + ).all(): + user_permissions.append( + orm.UserPermission( + environment=environment, + role=max_role, ) + ) - return user_permissions - - else: - return list(role_bindings) + return user_permissions diff --git a/conda-store-server/conda_store_server/server/auth.py b/conda-store-server/conda_store_server/server/auth.py index be6ba6fa4..45371494c 100644 --- a/conda-store-server/conda_store_server/server/auth.py +++ b/conda-store-server/conda_store_server/server/auth.py @@ -535,12 +535,16 @@ def routes(self): ("/logout/", "post", self.post_logout_method), ] - async def authenticate(self, request: Request): + async def authenticate( + self, + request: schema.APILoginRequest, + ) -> schema.AuthenticationToken: return schema.AuthenticationToken( primary_namespace="default", role_bindings={ "*/*": ["admin"], }, + user_name=request.username, ) def get_login_method( @@ -567,7 +571,7 @@ async def _post_login_method_response(self, redirect_url: str): async def post_login_method( self, - request: Request, + request: schema.APILoginRequest, response: Response, next: Optional[str] = None, templates=Depends(dependencies.get_templates), @@ -586,7 +590,7 @@ async def post_login_method( ---------- templates : - request : Request + request : schema.APILoginRequest POST request that was sent to `/login/` to log the user in response : Response Response to return to the requestor @@ -607,10 +611,10 @@ async def post_login_method( # After user logs in, ensure that they have an entry in the database with self.authentication_db() as db: - if not api.get_user(authentication_token.username): + if not api.get_user(db, user_name=authentication_token.user_name): api.add_user( db=db, - username=authentication_token.username, + user_name=authentication_token.user_name, role_bindings=authentication_token.role_bindings, ) @@ -776,17 +780,17 @@ class DummyAuthentication(Authentication): # login_html = Unicode() - async def authenticate(self, request: Request): + async def authenticate(self, request: schema.APILoginRequest): """Checks against a global password if it's been set. If not, allow any user/pass combo""" - data = await request.json() - if self.password and data.get("password") != self.password: + if self.password and request.password != self.password: return None return schema.AuthenticationToken( - primary_namespace=data["username"], + primary_namespace=request.username, role_bindings={ "*/*": ["admin"], }, + user_name=request.username, ) diff --git a/conda-store-server/pyproject.toml b/conda-store-server/pyproject.toml index 318466b03..7ae6d7f46 100644 --- a/conda-store-server/pyproject.toml +++ b/conda-store-server/pyproject.toml @@ -138,7 +138,6 @@ exclude = [ [tool.ruff.lint] ignore = [ "E501", # line-length - "ANN001", # missing-type-function-argument "ANN002", # missing-type-args "ANN003", # missing-type-kwargs From e84dfa9052ecdfc086011c114083a41f019e250a Mon Sep 17 00:00:00 2001 From: pdmurray Date: Sun, 10 Nov 2024 00:22:57 -0800 Subject: [PATCH 13/18] Fix alembic path; add database migration for user table --- .../_internal/alembic/README | 5 - .../{_internal => }/alembic.ini | 6 +- .../conda_store_server/alembic/README.md | 22 +++ .../{_internal => }/alembic/env.py | 0 .../{_internal => }/alembic/script.py.mako | 6 +- .../03c839888c82_add_canceled_status.py | 2 - .../versions/0f7e23ff24ee_add_worker.py | 2 - ...split_conda_package_into_conda_package_.py | 2 - .../30b37e725c32_add_build_key_version.py | 2 - .../versions/48be4072fe58_initial_schema.py | 2 - .../versions/57cd11b949d5_add_installer.py | 2 - ...adding_container_registry_value_to_enum.py | 2 - .../versions/6ebda11d1c42_add_user_table.py | 127 ++++++++++++++++++ .../771180018e1b_add_v2_role_mappings.py | 2 - ...d63a091aff8_add_environment_description.py | 2 - .../abd7248d5327_adding_a_settings_table.py | 2 - .../versions/b387747ca9b7_role_mapping.py | 2 - .../versions/bf065abf375b_lockfile_spec.py | 2 - .../versions/d78e9889566a_add_status_info.py | 2 - .../versions/e17b4cc6e086_add_build_hash.py | 2 - 20 files changed, 155 insertions(+), 39 deletions(-) delete mode 100644 conda-store-server/conda_store_server/_internal/alembic/README rename conda-store-server/conda_store_server/{_internal => }/alembic.ini (91%) create mode 100644 conda-store-server/conda_store_server/alembic/README.md rename conda-store-server/conda_store_server/{_internal => }/alembic/env.py (100%) rename conda-store-server/conda_store_server/{_internal => }/alembic/script.py.mako (74%) rename conda-store-server/conda_store_server/{_internal => }/alembic/versions/03c839888c82_add_canceled_status.py (99%) rename conda-store-server/conda_store_server/{_internal => }/alembic/versions/0f7e23ff24ee_add_worker.py (99%) rename conda-store-server/conda_store_server/{_internal => }/alembic/versions/16f65805dc8f_split_conda_package_into_conda_package_.py (99%) rename conda-store-server/conda_store_server/{_internal => }/alembic/versions/30b37e725c32_add_build_key_version.py (99%) rename conda-store-server/conda_store_server/{_internal => }/alembic/versions/48be4072fe58_initial_schema.py (99%) rename conda-store-server/conda_store_server/{_internal => }/alembic/versions/57cd11b949d5_add_installer.py (99%) rename conda-store-server/conda_store_server/{_internal => }/alembic/versions/5ad723de2abd_adding_container_registry_value_to_enum.py (99%) create mode 100644 conda-store-server/conda_store_server/alembic/versions/6ebda11d1c42_add_user_table.py rename conda-store-server/conda_store_server/{_internal => }/alembic/versions/771180018e1b_add_v2_role_mappings.py (99%) rename conda-store-server/conda_store_server/{_internal => }/alembic/versions/8d63a091aff8_add_environment_description.py (99%) rename conda-store-server/conda_store_server/{_internal => }/alembic/versions/abd7248d5327_adding_a_settings_table.py (99%) rename conda-store-server/conda_store_server/{_internal => }/alembic/versions/b387747ca9b7_role_mapping.py (99%) rename conda-store-server/conda_store_server/{_internal => }/alembic/versions/bf065abf375b_lockfile_spec.py (99%) rename conda-store-server/conda_store_server/{_internal => }/alembic/versions/d78e9889566a_add_status_info.py (99%) rename conda-store-server/conda_store_server/{_internal => }/alembic/versions/e17b4cc6e086_add_build_hash.py (99%) diff --git a/conda-store-server/conda_store_server/_internal/alembic/README b/conda-store-server/conda_store_server/_internal/alembic/README deleted file mode 100644 index 40700a0c1..000000000 --- a/conda-store-server/conda_store_server/_internal/alembic/README +++ /dev/null @@ -1,5 +0,0 @@ -// Copyright (c) conda-store development team. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -Generic single-database configuration. diff --git a/conda-store-server/conda_store_server/_internal/alembic.ini b/conda-store-server/conda_store_server/alembic.ini similarity index 91% rename from conda-store-server/conda_store_server/_internal/alembic.ini rename to conda-store-server/conda_store_server/alembic.ini index 1e9af61c2..0680b809c 100644 --- a/conda-store-server/conda_store_server/_internal/alembic.ini +++ b/conda-store-server/conda_store_server/alembic.ini @@ -5,11 +5,11 @@ # A generic, single database configuration. [alembic] -script_location = {alembic_dir} -sqlalchemy.url = {db_url} +script_location = alembic +# sqlalchemy.url = {db_url} # script_location = alembic -# sqlalchemy.url = postgresql+psycopg2://postgres:password@localhost:5432/conda-store +sqlalchemy.url = postgresql+psycopg2://postgres:password@localhost:5432/conda-store # template used to generate migration files # file_template = %%(rev)s_%%(slug)s diff --git a/conda-store-server/conda_store_server/alembic/README.md b/conda-store-server/conda_store_server/alembic/README.md new file mode 100644 index 000000000..0ded2180b --- /dev/null +++ b/conda-store-server/conda_store_server/alembic/README.md @@ -0,0 +1,22 @@ +# Alembic + +## Generating a database migration + +If the database needs to be changed, `alembic` can be used to generate the migration scripts to do +so. This is usually painless, and follows these steps: + +1. Make changes to the database models in + `conda-store-server/conda_store_server/_internal/orm.py`. +2. `alembic` will examine a running `conda-store` database and compare the + tables it finds there with changes to tables it finds in `orm.py` in your + feature branch. To do this, you need a running `conda-store` database from + the `main` branch, so clone the `conda-store` somewhere else on disk. +3. `cd` to the `main` branch in the _other_ copy of `conda-store`, and start + all the services with `docker compose up --build`. This will get a local + `conda-store` instance up and running, including the database that `alembic` + needs. +4. In another terminal, `cd` to your working branch and run `alembic revision + --autogenerate -m "`. + `alembic` will compare the running `postgres` database you started in step 3 + with what it finds in your working branch, and create a database migration. +5. If the migration looks good, add and commit it to the repository. diff --git a/conda-store-server/conda_store_server/_internal/alembic/env.py b/conda-store-server/conda_store_server/alembic/env.py similarity index 100% rename from conda-store-server/conda_store_server/_internal/alembic/env.py rename to conda-store-server/conda_store_server/alembic/env.py diff --git a/conda-store-server/conda_store_server/_internal/alembic/script.py.mako b/conda-store-server/conda_store_server/alembic/script.py.mako similarity index 74% rename from conda-store-server/conda_store_server/_internal/alembic/script.py.mako rename to conda-store-server/conda_store_server/alembic/script.py.mako index ac4bee237..34b973937 100644 --- a/conda-store-server/conda_store_server/_internal/alembic/script.py.mako +++ b/conda-store-server/conda_store_server/alembic/script.py.mako @@ -1,6 +1,6 @@ -// Copyright (c) conda-store development team. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. +# Copyright (c) conda-store development team. All rights reserved. +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file. """${message} diff --git a/conda-store-server/conda_store_server/_internal/alembic/versions/03c839888c82_add_canceled_status.py b/conda-store-server/conda_store_server/alembic/versions/03c839888c82_add_canceled_status.py similarity index 99% rename from conda-store-server/conda_store_server/_internal/alembic/versions/03c839888c82_add_canceled_status.py rename to conda-store-server/conda_store_server/alembic/versions/03c839888c82_add_canceled_status.py index 55b5132bb..d17888037 100644 --- a/conda-store-server/conda_store_server/_internal/alembic/versions/03c839888c82_add_canceled_status.py +++ b/conda-store-server/conda_store_server/alembic/versions/03c839888c82_add_canceled_status.py @@ -11,10 +11,8 @@ """ import sqlalchemy as sa - from alembic import op - # revision identifiers, used by Alembic. revision = "03c839888c82" down_revision = "57cd11b949d5" diff --git a/conda-store-server/conda_store_server/_internal/alembic/versions/0f7e23ff24ee_add_worker.py b/conda-store-server/conda_store_server/alembic/versions/0f7e23ff24ee_add_worker.py similarity index 99% rename from conda-store-server/conda_store_server/_internal/alembic/versions/0f7e23ff24ee_add_worker.py rename to conda-store-server/conda_store_server/alembic/versions/0f7e23ff24ee_add_worker.py index 4f6f048d3..ff64b7cd7 100644 --- a/conda-store-server/conda_store_server/_internal/alembic/versions/0f7e23ff24ee_add_worker.py +++ b/conda-store-server/conda_store_server/alembic/versions/0f7e23ff24ee_add_worker.py @@ -11,10 +11,8 @@ """ import sqlalchemy as sa - from alembic import op - # revision identifiers, used by Alembic. revision = "0f7e23ff24ee" down_revision = "771180018e1b" diff --git a/conda-store-server/conda_store_server/_internal/alembic/versions/16f65805dc8f_split_conda_package_into_conda_package_.py b/conda-store-server/conda_store_server/alembic/versions/16f65805dc8f_split_conda_package_into_conda_package_.py similarity index 99% rename from conda-store-server/conda_store_server/_internal/alembic/versions/16f65805dc8f_split_conda_package_into_conda_package_.py rename to conda-store-server/conda_store_server/alembic/versions/16f65805dc8f_split_conda_package_into_conda_package_.py index bb7098671..7dd067271 100644 --- a/conda-store-server/conda_store_server/_internal/alembic/versions/16f65805dc8f_split_conda_package_into_conda_package_.py +++ b/conda-store-server/conda_store_server/alembic/versions/16f65805dc8f_split_conda_package_into_conda_package_.py @@ -11,10 +11,8 @@ """ import sqlalchemy as sa - from alembic import op - # revision identifiers, used by Alembic. revision = "16f65805dc8f" down_revision = "5ad723de2abd" diff --git a/conda-store-server/conda_store_server/_internal/alembic/versions/30b37e725c32_add_build_key_version.py b/conda-store-server/conda_store_server/alembic/versions/30b37e725c32_add_build_key_version.py similarity index 99% rename from conda-store-server/conda_store_server/_internal/alembic/versions/30b37e725c32_add_build_key_version.py rename to conda-store-server/conda_store_server/alembic/versions/30b37e725c32_add_build_key_version.py index 56286a5dc..97926ab3f 100644 --- a/conda-store-server/conda_store_server/_internal/alembic/versions/30b37e725c32_add_build_key_version.py +++ b/conda-store-server/conda_store_server/alembic/versions/30b37e725c32_add_build_key_version.py @@ -11,10 +11,8 @@ """ import sqlalchemy as sa - from alembic import op - # revision identifiers, used by Alembic. revision = "30b37e725c32" down_revision = "d78e9889566a" diff --git a/conda-store-server/conda_store_server/_internal/alembic/versions/48be4072fe58_initial_schema.py b/conda-store-server/conda_store_server/alembic/versions/48be4072fe58_initial_schema.py similarity index 99% rename from conda-store-server/conda_store_server/_internal/alembic/versions/48be4072fe58_initial_schema.py rename to conda-store-server/conda_store_server/alembic/versions/48be4072fe58_initial_schema.py index 262bb73c6..38500a065 100644 --- a/conda-store-server/conda_store_server/_internal/alembic/versions/48be4072fe58_initial_schema.py +++ b/conda-store-server/conda_store_server/alembic/versions/48be4072fe58_initial_schema.py @@ -11,10 +11,8 @@ """ import sqlalchemy as sa - from alembic import op - # revision identifiers, used by Alembic. revision = "48be4072fe58" down_revision = None diff --git a/conda-store-server/conda_store_server/_internal/alembic/versions/57cd11b949d5_add_installer.py b/conda-store-server/conda_store_server/alembic/versions/57cd11b949d5_add_installer.py similarity index 99% rename from conda-store-server/conda_store_server/_internal/alembic/versions/57cd11b949d5_add_installer.py rename to conda-store-server/conda_store_server/alembic/versions/57cd11b949d5_add_installer.py index 677a0f9d9..ecf557896 100644 --- a/conda-store-server/conda_store_server/_internal/alembic/versions/57cd11b949d5_add_installer.py +++ b/conda-store-server/conda_store_server/alembic/versions/57cd11b949d5_add_installer.py @@ -11,10 +11,8 @@ """ import sqlalchemy as sa - from alembic import op - # revision identifiers, used by Alembic. revision = "57cd11b949d5" down_revision = "0f7e23ff24ee" diff --git a/conda-store-server/conda_store_server/_internal/alembic/versions/5ad723de2abd_adding_container_registry_value_to_enum.py b/conda-store-server/conda_store_server/alembic/versions/5ad723de2abd_adding_container_registry_value_to_enum.py similarity index 99% rename from conda-store-server/conda_store_server/_internal/alembic/versions/5ad723de2abd_adding_container_registry_value_to_enum.py rename to conda-store-server/conda_store_server/alembic/versions/5ad723de2abd_adding_container_registry_value_to_enum.py index 1f173facb..b7d651d20 100644 --- a/conda-store-server/conda_store_server/_internal/alembic/versions/5ad723de2abd_adding_container_registry_value_to_enum.py +++ b/conda-store-server/conda_store_server/alembic/versions/5ad723de2abd_adding_container_registry_value_to_enum.py @@ -11,10 +11,8 @@ """ import sqlalchemy as sa - from alembic import op - # revision identifiers, used by Alembic. revision = "5ad723de2abd" down_revision = "8d63a091aff8" diff --git a/conda-store-server/conda_store_server/alembic/versions/6ebda11d1c42_add_user_table.py b/conda-store-server/conda_store_server/alembic/versions/6ebda11d1c42_add_user_table.py new file mode 100644 index 000000000..144f6d3b2 --- /dev/null +++ b/conda-store-server/conda_store_server/alembic/versions/6ebda11d1c42_add_user_table.py @@ -0,0 +1,127 @@ +# Copyright (c) conda-store development team. All rights reserved. +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file. + +"""add user table + +Revision ID: 6ebda11d1c42 +Revises: bf065abf375b +Create Date: 2024-11-10 00:13:08.157178 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "6ebda11d1c42" +down_revision = "bf065abf375b" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "user", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.Unicode(), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + op.create_table( + "userpermission", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("environment_id", sa.Integer(), nullable=True), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.Column( + "role", + sa.Enum("NONE", "VIEWER", "EDITOR", "ADMIN", name="role"), + nullable=True, + ), + sa.ForeignKeyConstraint( + ["environment_id"], + ["environment.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["user.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.alter_column( + "build", + "status", + existing_type=sa.VARCHAR(length=9), + type_=sa.Enum( + "QUEUED", "BUILDING", "COMPLETED", "FAILED", "CANCELED", name="buildstatus" + ), + existing_nullable=True, + ) + op.alter_column( + "build_artifact", + "artifact_type", + existing_type=sa.VARCHAR(length=21), + type_=sa.Enum( + "DIRECTORY", + "LOCKFILE", + "LOGS", + "YAML", + "CONDA_PACK", + "DOCKER_BLOB", + "DOCKER_MANIFEST", + "CONTAINER_REGISTRY", + "CONSTRUCTOR_INSTALLER", + name="buildartifacttype", + ), + existing_nullable=False, + ) + op.alter_column( + "keyvaluestore", "prefix", existing_type=sa.VARCHAR(), nullable=True + ) + op.alter_column("keyvaluestore", "key", existing_type=sa.VARCHAR(), nullable=True) + op.drop_index("ix_keyvaluestore_prefix", table_name="keyvaluestore") + op.create_unique_constraint("_prefix_key_uc", "keyvaluestore", ["prefix", "key"]) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint("_prefix_key_uc", "keyvaluestore", type_="unique") + op.create_index( + "ix_keyvaluestore_prefix", "keyvaluestore", ["prefix"], unique=False + ) + op.alter_column("keyvaluestore", "key", existing_type=sa.VARCHAR(), nullable=False) + op.alter_column( + "keyvaluestore", "prefix", existing_type=sa.VARCHAR(), nullable=False + ) + op.alter_column( + "build_artifact", + "artifact_type", + existing_type=sa.Enum( + "DIRECTORY", + "LOCKFILE", + "LOGS", + "YAML", + "CONDA_PACK", + "DOCKER_BLOB", + "DOCKER_MANIFEST", + "CONTAINER_REGISTRY", + "CONSTRUCTOR_INSTALLER", + name="buildartifacttype", + ), + type_=sa.VARCHAR(length=21), + existing_nullable=False, + ) + op.alter_column( + "build", + "status", + existing_type=sa.Enum( + "QUEUED", "BUILDING", "COMPLETED", "FAILED", "CANCELED", name="buildstatus" + ), + type_=sa.VARCHAR(length=9), + existing_nullable=True, + ) + op.drop_table("userpermission") + op.drop_table("user") + # ### end Alembic commands ### diff --git a/conda-store-server/conda_store_server/_internal/alembic/versions/771180018e1b_add_v2_role_mappings.py b/conda-store-server/conda_store_server/alembic/versions/771180018e1b_add_v2_role_mappings.py similarity index 99% rename from conda-store-server/conda_store_server/_internal/alembic/versions/771180018e1b_add_v2_role_mappings.py rename to conda-store-server/conda_store_server/alembic/versions/771180018e1b_add_v2_role_mappings.py index ed081467c..6b62eb052 100644 --- a/conda-store-server/conda_store_server/_internal/alembic/versions/771180018e1b_add_v2_role_mappings.py +++ b/conda-store-server/conda_store_server/alembic/versions/771180018e1b_add_v2_role_mappings.py @@ -11,10 +11,8 @@ """ import sqlalchemy as sa - from alembic import op - # revision identifiers, used by Alembic. revision = "771180018e1b" down_revision = "30b37e725c32" diff --git a/conda-store-server/conda_store_server/_internal/alembic/versions/8d63a091aff8_add_environment_description.py b/conda-store-server/conda_store_server/alembic/versions/8d63a091aff8_add_environment_description.py similarity index 99% rename from conda-store-server/conda_store_server/_internal/alembic/versions/8d63a091aff8_add_environment_description.py rename to conda-store-server/conda_store_server/alembic/versions/8d63a091aff8_add_environment_description.py index 680d7e6ee..4e1d54c94 100644 --- a/conda-store-server/conda_store_server/_internal/alembic/versions/8d63a091aff8_add_environment_description.py +++ b/conda-store-server/conda_store_server/alembic/versions/8d63a091aff8_add_environment_description.py @@ -11,10 +11,8 @@ """ import sqlalchemy as sa - from alembic import op - # revision identifiers, used by Alembic. revision = "8d63a091aff8" down_revision = "48be4072fe58" diff --git a/conda-store-server/conda_store_server/_internal/alembic/versions/abd7248d5327_adding_a_settings_table.py b/conda-store-server/conda_store_server/alembic/versions/abd7248d5327_adding_a_settings_table.py similarity index 99% rename from conda-store-server/conda_store_server/_internal/alembic/versions/abd7248d5327_adding_a_settings_table.py rename to conda-store-server/conda_store_server/alembic/versions/abd7248d5327_adding_a_settings_table.py index 853f1f370..b28de842d 100644 --- a/conda-store-server/conda_store_server/_internal/alembic/versions/abd7248d5327_adding_a_settings_table.py +++ b/conda-store-server/conda_store_server/alembic/versions/abd7248d5327_adding_a_settings_table.py @@ -11,10 +11,8 @@ """ import sqlalchemy as sa - from alembic import op - # revision identifiers, used by Alembic. revision = "abd7248d5327" down_revision = "16f65805dc8f" diff --git a/conda-store-server/conda_store_server/_internal/alembic/versions/b387747ca9b7_role_mapping.py b/conda-store-server/conda_store_server/alembic/versions/b387747ca9b7_role_mapping.py similarity index 99% rename from conda-store-server/conda_store_server/_internal/alembic/versions/b387747ca9b7_role_mapping.py rename to conda-store-server/conda_store_server/alembic/versions/b387747ca9b7_role_mapping.py index 868926c66..82dfa8aab 100644 --- a/conda-store-server/conda_store_server/_internal/alembic/versions/b387747ca9b7_role_mapping.py +++ b/conda-store-server/conda_store_server/alembic/versions/b387747ca9b7_role_mapping.py @@ -11,10 +11,8 @@ """ import sqlalchemy as sa - from alembic import op - # revision identifiers, used by Alembic. revision = "b387747ca9b7" down_revision = "abd7248d5327" diff --git a/conda-store-server/conda_store_server/_internal/alembic/versions/bf065abf375b_lockfile_spec.py b/conda-store-server/conda_store_server/alembic/versions/bf065abf375b_lockfile_spec.py similarity index 99% rename from conda-store-server/conda_store_server/_internal/alembic/versions/bf065abf375b_lockfile_spec.py rename to conda-store-server/conda_store_server/alembic/versions/bf065abf375b_lockfile_spec.py index 6e4a4c961..f0629fcb6 100644 --- a/conda-store-server/conda_store_server/_internal/alembic/versions/bf065abf375b_lockfile_spec.py +++ b/conda-store-server/conda_store_server/alembic/versions/bf065abf375b_lockfile_spec.py @@ -11,10 +11,8 @@ """ import sqlalchemy as sa - from alembic import op - # revision identifiers, used by Alembic. revision = "bf065abf375b" down_revision = "e17b4cc6e086" diff --git a/conda-store-server/conda_store_server/_internal/alembic/versions/d78e9889566a_add_status_info.py b/conda-store-server/conda_store_server/alembic/versions/d78e9889566a_add_status_info.py similarity index 99% rename from conda-store-server/conda_store_server/_internal/alembic/versions/d78e9889566a_add_status_info.py rename to conda-store-server/conda_store_server/alembic/versions/d78e9889566a_add_status_info.py index 72304f358..22cb7431d 100644 --- a/conda-store-server/conda_store_server/_internal/alembic/versions/d78e9889566a_add_status_info.py +++ b/conda-store-server/conda_store_server/alembic/versions/d78e9889566a_add_status_info.py @@ -11,10 +11,8 @@ """ import sqlalchemy as sa - from alembic import op - # revision identifiers, used by Alembic. revision = "d78e9889566a" down_revision = "b387747ca9b7" diff --git a/conda-store-server/conda_store_server/_internal/alembic/versions/e17b4cc6e086_add_build_hash.py b/conda-store-server/conda_store_server/alembic/versions/e17b4cc6e086_add_build_hash.py similarity index 99% rename from conda-store-server/conda_store_server/_internal/alembic/versions/e17b4cc6e086_add_build_hash.py rename to conda-store-server/conda_store_server/alembic/versions/e17b4cc6e086_add_build_hash.py index 46566bef8..ed42ac0e2 100644 --- a/conda-store-server/conda_store_server/_internal/alembic/versions/e17b4cc6e086_add_build_hash.py +++ b/conda-store-server/conda_store_server/alembic/versions/e17b4cc6e086_add_build_hash.py @@ -11,10 +11,8 @@ """ import sqlalchemy as sa - from alembic import op - # revision identifiers, used by Alembic. revision = "e17b4cc6e086" down_revision = "03c839888c82" From 0b2ef8fa1fd9ab725d96a6810bbd8b1bd3576beb Mon Sep 17 00:00:00 2001 From: pdmurray Date: Mon, 11 Nov 2024 15:53:35 -0800 Subject: [PATCH 14/18] Move alembic back under _internal to make dbutil.py happy --- .../conda_store_server/{ => _internal}/alembic.ini | 0 .../conda_store_server/{ => _internal}/alembic/README.md | 0 .../conda_store_server/{ => _internal}/alembic/env.py | 2 +- .../conda_store_server/{ => _internal}/alembic/script.py.mako | 0 .../alembic/versions/03c839888c82_add_canceled_status.py | 0 .../{ => _internal}/alembic/versions/0f7e23ff24ee_add_worker.py | 0 .../16f65805dc8f_split_conda_package_into_conda_package_.py | 0 .../alembic/versions/30b37e725c32_add_build_key_version.py | 0 .../alembic/versions/48be4072fe58_initial_schema.py | 0 .../alembic/versions/57cd11b949d5_add_installer.py | 0 .../5ad723de2abd_adding_container_registry_value_to_enum.py | 0 .../alembic/versions/6ebda11d1c42_add_user_table.py | 0 .../alembic/versions/771180018e1b_add_v2_role_mappings.py | 0 .../versions/8d63a091aff8_add_environment_description.py | 0 .../alembic/versions/abd7248d5327_adding_a_settings_table.py | 0 .../alembic/versions/b387747ca9b7_role_mapping.py | 0 .../alembic/versions/bf065abf375b_lockfile_spec.py | 0 .../alembic/versions/d78e9889566a_add_status_info.py | 0 .../alembic/versions/e17b4cc6e086_add_build_hash.py | 0 19 files changed, 1 insertion(+), 1 deletion(-) rename conda-store-server/conda_store_server/{ => _internal}/alembic.ini (100%) rename conda-store-server/conda_store_server/{ => _internal}/alembic/README.md (100%) rename conda-store-server/conda_store_server/{ => _internal}/alembic/env.py (97%) rename conda-store-server/conda_store_server/{ => _internal}/alembic/script.py.mako (100%) rename conda-store-server/conda_store_server/{ => _internal}/alembic/versions/03c839888c82_add_canceled_status.py (100%) rename conda-store-server/conda_store_server/{ => _internal}/alembic/versions/0f7e23ff24ee_add_worker.py (100%) rename conda-store-server/conda_store_server/{ => _internal}/alembic/versions/16f65805dc8f_split_conda_package_into_conda_package_.py (100%) rename conda-store-server/conda_store_server/{ => _internal}/alembic/versions/30b37e725c32_add_build_key_version.py (100%) rename conda-store-server/conda_store_server/{ => _internal}/alembic/versions/48be4072fe58_initial_schema.py (100%) rename conda-store-server/conda_store_server/{ => _internal}/alembic/versions/57cd11b949d5_add_installer.py (100%) rename conda-store-server/conda_store_server/{ => _internal}/alembic/versions/5ad723de2abd_adding_container_registry_value_to_enum.py (100%) rename conda-store-server/conda_store_server/{ => _internal}/alembic/versions/6ebda11d1c42_add_user_table.py (100%) rename conda-store-server/conda_store_server/{ => _internal}/alembic/versions/771180018e1b_add_v2_role_mappings.py (100%) rename conda-store-server/conda_store_server/{ => _internal}/alembic/versions/8d63a091aff8_add_environment_description.py (100%) rename conda-store-server/conda_store_server/{ => _internal}/alembic/versions/abd7248d5327_adding_a_settings_table.py (100%) rename conda-store-server/conda_store_server/{ => _internal}/alembic/versions/b387747ca9b7_role_mapping.py (100%) rename conda-store-server/conda_store_server/{ => _internal}/alembic/versions/bf065abf375b_lockfile_spec.py (100%) rename conda-store-server/conda_store_server/{ => _internal}/alembic/versions/d78e9889566a_add_status_info.py (100%) rename conda-store-server/conda_store_server/{ => _internal}/alembic/versions/e17b4cc6e086_add_build_hash.py (100%) diff --git a/conda-store-server/conda_store_server/alembic.ini b/conda-store-server/conda_store_server/_internal/alembic.ini similarity index 100% rename from conda-store-server/conda_store_server/alembic.ini rename to conda-store-server/conda_store_server/_internal/alembic.ini diff --git a/conda-store-server/conda_store_server/alembic/README.md b/conda-store-server/conda_store_server/_internal/alembic/README.md similarity index 100% rename from conda-store-server/conda_store_server/alembic/README.md rename to conda-store-server/conda_store_server/_internal/alembic/README.md diff --git a/conda-store-server/conda_store_server/alembic/env.py b/conda-store-server/conda_store_server/_internal/alembic/env.py similarity index 97% rename from conda-store-server/conda_store_server/alembic/env.py rename to conda-store-server/conda_store_server/_internal/alembic/env.py index 80a4f298e..0256f4d24 100644 --- a/conda-store-server/conda_store_server/alembic/env.py +++ b/conda-store-server/conda_store_server/_internal/alembic/env.py @@ -27,7 +27,7 @@ import sys # noqa E402 -sys.path.append(str(pathlib.Path(__file__).parent.parent.parent)) +sys.path.append(str(pathlib.Path(__file__).parent.parent.parent.parent)) from conda_store_server._internal.orm import Base # noqa E402 diff --git a/conda-store-server/conda_store_server/alembic/script.py.mako b/conda-store-server/conda_store_server/_internal/alembic/script.py.mako similarity index 100% rename from conda-store-server/conda_store_server/alembic/script.py.mako rename to conda-store-server/conda_store_server/_internal/alembic/script.py.mako diff --git a/conda-store-server/conda_store_server/alembic/versions/03c839888c82_add_canceled_status.py b/conda-store-server/conda_store_server/_internal/alembic/versions/03c839888c82_add_canceled_status.py similarity index 100% rename from conda-store-server/conda_store_server/alembic/versions/03c839888c82_add_canceled_status.py rename to conda-store-server/conda_store_server/_internal/alembic/versions/03c839888c82_add_canceled_status.py diff --git a/conda-store-server/conda_store_server/alembic/versions/0f7e23ff24ee_add_worker.py b/conda-store-server/conda_store_server/_internal/alembic/versions/0f7e23ff24ee_add_worker.py similarity index 100% rename from conda-store-server/conda_store_server/alembic/versions/0f7e23ff24ee_add_worker.py rename to conda-store-server/conda_store_server/_internal/alembic/versions/0f7e23ff24ee_add_worker.py diff --git a/conda-store-server/conda_store_server/alembic/versions/16f65805dc8f_split_conda_package_into_conda_package_.py b/conda-store-server/conda_store_server/_internal/alembic/versions/16f65805dc8f_split_conda_package_into_conda_package_.py similarity index 100% rename from conda-store-server/conda_store_server/alembic/versions/16f65805dc8f_split_conda_package_into_conda_package_.py rename to conda-store-server/conda_store_server/_internal/alembic/versions/16f65805dc8f_split_conda_package_into_conda_package_.py diff --git a/conda-store-server/conda_store_server/alembic/versions/30b37e725c32_add_build_key_version.py b/conda-store-server/conda_store_server/_internal/alembic/versions/30b37e725c32_add_build_key_version.py similarity index 100% rename from conda-store-server/conda_store_server/alembic/versions/30b37e725c32_add_build_key_version.py rename to conda-store-server/conda_store_server/_internal/alembic/versions/30b37e725c32_add_build_key_version.py diff --git a/conda-store-server/conda_store_server/alembic/versions/48be4072fe58_initial_schema.py b/conda-store-server/conda_store_server/_internal/alembic/versions/48be4072fe58_initial_schema.py similarity index 100% rename from conda-store-server/conda_store_server/alembic/versions/48be4072fe58_initial_schema.py rename to conda-store-server/conda_store_server/_internal/alembic/versions/48be4072fe58_initial_schema.py diff --git a/conda-store-server/conda_store_server/alembic/versions/57cd11b949d5_add_installer.py b/conda-store-server/conda_store_server/_internal/alembic/versions/57cd11b949d5_add_installer.py similarity index 100% rename from conda-store-server/conda_store_server/alembic/versions/57cd11b949d5_add_installer.py rename to conda-store-server/conda_store_server/_internal/alembic/versions/57cd11b949d5_add_installer.py diff --git a/conda-store-server/conda_store_server/alembic/versions/5ad723de2abd_adding_container_registry_value_to_enum.py b/conda-store-server/conda_store_server/_internal/alembic/versions/5ad723de2abd_adding_container_registry_value_to_enum.py similarity index 100% rename from conda-store-server/conda_store_server/alembic/versions/5ad723de2abd_adding_container_registry_value_to_enum.py rename to conda-store-server/conda_store_server/_internal/alembic/versions/5ad723de2abd_adding_container_registry_value_to_enum.py diff --git a/conda-store-server/conda_store_server/alembic/versions/6ebda11d1c42_add_user_table.py b/conda-store-server/conda_store_server/_internal/alembic/versions/6ebda11d1c42_add_user_table.py similarity index 100% rename from conda-store-server/conda_store_server/alembic/versions/6ebda11d1c42_add_user_table.py rename to conda-store-server/conda_store_server/_internal/alembic/versions/6ebda11d1c42_add_user_table.py diff --git a/conda-store-server/conda_store_server/alembic/versions/771180018e1b_add_v2_role_mappings.py b/conda-store-server/conda_store_server/_internal/alembic/versions/771180018e1b_add_v2_role_mappings.py similarity index 100% rename from conda-store-server/conda_store_server/alembic/versions/771180018e1b_add_v2_role_mappings.py rename to conda-store-server/conda_store_server/_internal/alembic/versions/771180018e1b_add_v2_role_mappings.py diff --git a/conda-store-server/conda_store_server/alembic/versions/8d63a091aff8_add_environment_description.py b/conda-store-server/conda_store_server/_internal/alembic/versions/8d63a091aff8_add_environment_description.py similarity index 100% rename from conda-store-server/conda_store_server/alembic/versions/8d63a091aff8_add_environment_description.py rename to conda-store-server/conda_store_server/_internal/alembic/versions/8d63a091aff8_add_environment_description.py diff --git a/conda-store-server/conda_store_server/alembic/versions/abd7248d5327_adding_a_settings_table.py b/conda-store-server/conda_store_server/_internal/alembic/versions/abd7248d5327_adding_a_settings_table.py similarity index 100% rename from conda-store-server/conda_store_server/alembic/versions/abd7248d5327_adding_a_settings_table.py rename to conda-store-server/conda_store_server/_internal/alembic/versions/abd7248d5327_adding_a_settings_table.py diff --git a/conda-store-server/conda_store_server/alembic/versions/b387747ca9b7_role_mapping.py b/conda-store-server/conda_store_server/_internal/alembic/versions/b387747ca9b7_role_mapping.py similarity index 100% rename from conda-store-server/conda_store_server/alembic/versions/b387747ca9b7_role_mapping.py rename to conda-store-server/conda_store_server/_internal/alembic/versions/b387747ca9b7_role_mapping.py diff --git a/conda-store-server/conda_store_server/alembic/versions/bf065abf375b_lockfile_spec.py b/conda-store-server/conda_store_server/_internal/alembic/versions/bf065abf375b_lockfile_spec.py similarity index 100% rename from conda-store-server/conda_store_server/alembic/versions/bf065abf375b_lockfile_spec.py rename to conda-store-server/conda_store_server/_internal/alembic/versions/bf065abf375b_lockfile_spec.py diff --git a/conda-store-server/conda_store_server/alembic/versions/d78e9889566a_add_status_info.py b/conda-store-server/conda_store_server/_internal/alembic/versions/d78e9889566a_add_status_info.py similarity index 100% rename from conda-store-server/conda_store_server/alembic/versions/d78e9889566a_add_status_info.py rename to conda-store-server/conda_store_server/_internal/alembic/versions/d78e9889566a_add_status_info.py diff --git a/conda-store-server/conda_store_server/alembic/versions/e17b4cc6e086_add_build_hash.py b/conda-store-server/conda_store_server/_internal/alembic/versions/e17b4cc6e086_add_build_hash.py similarity index 100% rename from conda-store-server/conda_store_server/alembic/versions/e17b4cc6e086_add_build_hash.py rename to conda-store-server/conda_store_server/_internal/alembic/versions/e17b4cc6e086_add_build_hash.py From 45386b8209ddc6655d62cf3af6af4f4e49c6be3e Mon Sep 17 00:00:00 2001 From: pdmurray Date: Mon, 11 Nov 2024 16:51:48 -0800 Subject: [PATCH 15/18] Fix database migrations; User and UserPermissions are now part of db --- .../conda_store_server/_internal/alembic.ini | 7 +- .../versions/6ebda11d1c42_add_user_table.py | 127 ------------------ .../versions/98e16d86ffb0_add_user_table.py | 45 +++++++ .../conda_store_server/_internal/dbutil.py | 56 ++++++++ 4 files changed, 103 insertions(+), 132 deletions(-) delete mode 100644 conda-store-server/conda_store_server/_internal/alembic/versions/6ebda11d1c42_add_user_table.py create mode 100644 conda-store-server/conda_store_server/_internal/alembic/versions/98e16d86ffb0_add_user_table.py diff --git a/conda-store-server/conda_store_server/_internal/alembic.ini b/conda-store-server/conda_store_server/_internal/alembic.ini index 0680b809c..174013105 100644 --- a/conda-store-server/conda_store_server/_internal/alembic.ini +++ b/conda-store-server/conda_store_server/_internal/alembic.ini @@ -5,11 +5,8 @@ # A generic, single database configuration. [alembic] -script_location = alembic -# sqlalchemy.url = {db_url} - -# script_location = alembic -sqlalchemy.url = postgresql+psycopg2://postgres:password@localhost:5432/conda-store +script_location = {alembic_dir} +sqlalchemy.url = {db_url} # template used to generate migration files # file_template = %%(rev)s_%%(slug)s diff --git a/conda-store-server/conda_store_server/_internal/alembic/versions/6ebda11d1c42_add_user_table.py b/conda-store-server/conda_store_server/_internal/alembic/versions/6ebda11d1c42_add_user_table.py deleted file mode 100644 index 144f6d3b2..000000000 --- a/conda-store-server/conda_store_server/_internal/alembic/versions/6ebda11d1c42_add_user_table.py +++ /dev/null @@ -1,127 +0,0 @@ -# Copyright (c) conda-store development team. All rights reserved. -# Use of this source code is governed by a BSD-style -# license that can be found in the LICENSE file. - -"""add user table - -Revision ID: 6ebda11d1c42 -Revises: bf065abf375b -Create Date: 2024-11-10 00:13:08.157178 - -""" - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision = "6ebda11d1c42" -down_revision = "bf065abf375b" -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table( - "user", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("name", sa.Unicode(), nullable=True), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("name"), - ) - op.create_table( - "userpermission", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("environment_id", sa.Integer(), nullable=True), - sa.Column("user_id", sa.Integer(), nullable=True), - sa.Column( - "role", - sa.Enum("NONE", "VIEWER", "EDITOR", "ADMIN", name="role"), - nullable=True, - ), - sa.ForeignKeyConstraint( - ["environment_id"], - ["environment.id"], - ), - sa.ForeignKeyConstraint( - ["user_id"], - ["user.id"], - ), - sa.PrimaryKeyConstraint("id"), - ) - op.alter_column( - "build", - "status", - existing_type=sa.VARCHAR(length=9), - type_=sa.Enum( - "QUEUED", "BUILDING", "COMPLETED", "FAILED", "CANCELED", name="buildstatus" - ), - existing_nullable=True, - ) - op.alter_column( - "build_artifact", - "artifact_type", - existing_type=sa.VARCHAR(length=21), - type_=sa.Enum( - "DIRECTORY", - "LOCKFILE", - "LOGS", - "YAML", - "CONDA_PACK", - "DOCKER_BLOB", - "DOCKER_MANIFEST", - "CONTAINER_REGISTRY", - "CONSTRUCTOR_INSTALLER", - name="buildartifacttype", - ), - existing_nullable=False, - ) - op.alter_column( - "keyvaluestore", "prefix", existing_type=sa.VARCHAR(), nullable=True - ) - op.alter_column("keyvaluestore", "key", existing_type=sa.VARCHAR(), nullable=True) - op.drop_index("ix_keyvaluestore_prefix", table_name="keyvaluestore") - op.create_unique_constraint("_prefix_key_uc", "keyvaluestore", ["prefix", "key"]) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_constraint("_prefix_key_uc", "keyvaluestore", type_="unique") - op.create_index( - "ix_keyvaluestore_prefix", "keyvaluestore", ["prefix"], unique=False - ) - op.alter_column("keyvaluestore", "key", existing_type=sa.VARCHAR(), nullable=False) - op.alter_column( - "keyvaluestore", "prefix", existing_type=sa.VARCHAR(), nullable=False - ) - op.alter_column( - "build_artifact", - "artifact_type", - existing_type=sa.Enum( - "DIRECTORY", - "LOCKFILE", - "LOGS", - "YAML", - "CONDA_PACK", - "DOCKER_BLOB", - "DOCKER_MANIFEST", - "CONTAINER_REGISTRY", - "CONSTRUCTOR_INSTALLER", - name="buildartifacttype", - ), - type_=sa.VARCHAR(length=21), - existing_nullable=False, - ) - op.alter_column( - "build", - "status", - existing_type=sa.Enum( - "QUEUED", "BUILDING", "COMPLETED", "FAILED", "CANCELED", name="buildstatus" - ), - type_=sa.VARCHAR(length=9), - existing_nullable=True, - ) - op.drop_table("userpermission") - op.drop_table("user") - # ### end Alembic commands ### diff --git a/conda-store-server/conda_store_server/_internal/alembic/versions/98e16d86ffb0_add_user_table.py b/conda-store-server/conda_store_server/_internal/alembic/versions/98e16d86ffb0_add_user_table.py new file mode 100644 index 000000000..f8fc78556 --- /dev/null +++ b/conda-store-server/conda_store_server/_internal/alembic/versions/98e16d86ffb0_add_user_table.py @@ -0,0 +1,45 @@ +# Copyright (c) conda-store development team. All rights reserved. +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file. + +"""add user table + +Revision ID: 98e16d86ffb0 +Revises: bf065abf375b +Create Date: 2024-11-11 16:36:31.047525 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '98e16d86ffb0' +down_revision = 'bf065abf375b' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'user', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.Unicode(), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table( + 'userpermission', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('environment_id', sa.Integer(), nullable=True), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('role', sa.Enum('NONE', 'VIEWER', 'EDITOR', 'ADMIN', name='role'), nullable=True), + sa.ForeignKeyConstraint(['environment_id'], ['environment.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + + +def downgrade(): + op.drop_table('userpermission') + op.drop_table('user') diff --git a/conda-store-server/conda_store_server/_internal/dbutil.py b/conda-store-server/conda_store_server/_internal/dbutil.py index 39835dd8d..c5e04376c 100644 --- a/conda-store-server/conda_store_server/_internal/dbutil.py +++ b/conda-store-server/conda_store_server/_internal/dbutil.py @@ -4,12 +4,15 @@ import json import os +import sys +import textwrap from contextlib import contextmanager from functools import partial from tempfile import TemporaryDirectory from alembic import command from alembic.config import Config +from alembic.script import Script from sqlalchemy import create_engine, inspect from conda_store_server._internal import utils @@ -106,3 +109,56 @@ def upgrade(db_url, revision="head"): # run the upgrade. command.upgrade(config=alembic_cfg, revision=revision) + + +def autogenerate_migrations( + message, + running_db: str = "postgresql+psycopg2://postgres:password@localhost:5432/conda-store", +): + """Automatically generate new database migrations. + + Make sure a postgres database is running with the previous version of the database at + the `running_db`. + + Parameters + ---------- + running_db : str + URI of a running (current) version of the database pre-migration. The state of + this database will be compared to what is in `orm.py` to automatically generate + migrations. + """ + print( + f"Automatically generating migrations by comparing database at {running_db} to " + "this repository's table definitions in `orm.py`..." + ) + + with _temp_alembic_ini(db_url=running_db) as alembic_ini: + scripts = command.revision( + Config(alembic_ini), + autogenerate=True, + message=message, + ) + + if isinstance(scripts, Script): + scripts = [scripts] + + if scripts: + print("Automatically generated migrations:") + print( + textwrap.indent( + "\n".join(script.path for script in list(scripts)), + prefix=" ", + ) + ) + else: + print("No migrations generated.") + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print( + "Usage: `python -m conda_store_server._internal.dbutil ''" + ) + sys.exit(1) + + autogenerate_migrations(message=sys.argv[1]) From 409e6e8d5a4ebaad8ae66c1f6135a016df7011aa Mon Sep 17 00:00:00 2001 From: pdmurray Date: Mon, 11 Nov 2024 18:40:51 -0800 Subject: [PATCH 16/18] Implement RoleBindings --- .../conda_store_server/_internal/orm.py | 67 +++++++++++++++++-- conda-store-server/conda_store_server/api.py | 59 +++++++--------- 2 files changed, 85 insertions(+), 41 deletions(-) diff --git a/conda-store-server/conda_store_server/_internal/orm.py b/conda-store-server/conda_store_server/_internal/orm.py index 1bb5c95cf..ff3b1cc57 100644 --- a/conda-store-server/conda_store_server/_internal/orm.py +++ b/conda-store-server/conda_store_server/_internal/orm.py @@ -31,9 +31,11 @@ ) from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import ( + Query, backref, declarative_base, relationship, + session, sessionmaker, validates, ) @@ -819,7 +821,7 @@ def new_session_factory( return session_factory -class UserPermission(Base): +class RoleBinding(Base): """The permissions a User has for an namespace/environment. Maps a namespace/environment matching rule to a `Role` for the matching @@ -828,21 +830,72 @@ class UserPermission(Base): Intended to replace NamespaceRoleMapping and NamespaceRoleMappingV2. """ - __tablename__ = "userpermission" + __tablename__ = "rolebinding" id = Column(Integer, primary_key=True) - environment = relationship(Environment) - environment_id = Column(Integer, ForeignKey("environment.id")) - user = relationship("User", back_populates="permissions") + pattern = Column(Unicode(255)) + user = relationship("User", back_populates="rolebinding") user_id = Column(Integer, ForeignKey("user.id")) role = Column(Enum(schema.Role), default=schema.Role.NONE) + def get_matching_environments(self, db: session) -> Query: + """Get the environments which match the regex pattern. + + Parameters + ---------- + db : session + Database to query + + Returns + ------- + Query + The environments that match the regex of the RoleBinding + """ + namespace, environment = utils.compile_arn_sql_like( + self.pattern, + schema.ARN_ALLOWED_REGEX, + ) + return ( + db.query(Environment) + .join(Namespace) + .filter( + and_( + Namespace.name.like(namespace), + Environment.name.like(environment), + ) + ) + ) + class User(Base): - """User which contains permissions to namespaces and environments.""" + """User which contains role bindings to environments.""" __tablename__ = "user" id = Column(Integer, primary_key=True) name = Column(Unicode, unique=True) - permissions = relationship("UserPermission", back_populates="user") + role_bindings = relationship("RoleBinding", back_populates="user") + + def get_environments( + self, db: session, min_role: schema.Role = schema.Role.VIEWER + ) -> Query: + """Get the environments a user has access to. + + Parameters + ---------- + db : session + Database to query + min_role : schema.Role + Minimum role the user must have for the returned environments + + Returns + ------- + Query + The environments for which the user has at least `min_role` for + """ + queries = [] + for role_binding in self.role_bindings: + if role_binding.role >= min_role: + queries.append(role_binding.get_matching_environments(db)) + + return db.query(*(query.subquery() for query in queries)) diff --git a/conda-store-server/conda_store_server/api.py b/conda-store-server/conda_store_server/api.py index 262258499..ff7597c4c 100644 --- a/conda-store-server/conda_store_server/api.py +++ b/conda-store-server/conda_store_server/api.py @@ -11,7 +11,6 @@ from sqlalchemy.orm import Query, aliased, session from conda_store_server._internal import conda_utils, orm, schema, utils -from conda_store_server._internal.environment import filter_environments def list_namespaces(db, show_soft_deleted: bool = False): @@ -359,7 +358,6 @@ def list_environments( ) if user: - breakpoint() query = ( query.join( orm.UserPermission, @@ -842,7 +840,7 @@ def add_user( Parses the role_bindings to set the role bindings of the user in the database. Only use the maximum role in the set of RoleBindings, since that's what determines the - permissions for the namespace/environment. + level of access for the namespace/environment. Parameters ---------- @@ -854,16 +852,16 @@ def add_user( Role bindings to apply to the new user """ if db.query(exists().filter(orm.User.name == user_name)): - raise ValueError("Username '{user_name}' already exists in the database.") + raise ValueError(f"Username '{user_name}' already exists in the database.") - user_permissions = create_user_permissions(db, role_bindings) - add_user_permissions(db, user_permissions) + user_bindings = create_user_bindings(db, role_bindings) + add_user_bindings(db, user_bindings) # Add the user with the given permissions db.add( orm.User( name=user_name if user_name else uuid.uuid4(), - permissions=user_permissions, + role_bindings=user_bindings, ) ) db.commit() @@ -938,39 +936,39 @@ def update_user( user.permissions.delete() if isinstance(new_user_permissions, schema.RoleBindings): - new_user_permissions = create_user_permissions(db, new_user_permissions) + new_user_permissions = create_user_bindings(db, new_user_permissions) - add_user_permissions(db, new_user_permissions) + add_user_bindings(db, new_user_permissions) user.permissions = new_user_permissions db.commit() -def add_user_permissions( +def add_user_bindings( db: session.Session, - user_permissions: Iterable[orm.UserPermission], -) -> List[orm.UserPermission]: + user_bindings: Iterable[orm.RoleBinding], +) -> List[orm.RoleBinding]: """Add a set of role bindings to the database as UserPermission entries. Parameters ---------- db : session.Session Database to add user permissions - user_permissions : Iterable[orm.UserPermission] + user_permissions : Iterable[orm.RoleBinding] Role bindings to add to the database """ - db.add_all(user_permissions) + db.add_all(user_bindings) db.commit() -def create_user_permissions( +def create_user_bindings( db: session.Session, role_bindings: schema.RoleBindings, -) -> List[orm.UserPermission]: - """Generate UserPermission objects for each environment targeted by a role binding. +) -> List[orm.RoleBinding]: + """Generate RoleBinding objects for each of the role binding regexes. - These are not added to the database - see add_user_permissions. + These are not added to the database - see add_role_bindings. Parameters ---------- @@ -981,23 +979,16 @@ def create_user_permissions( Returns ------- - List[orm.UserPermission] - A list containing a UserPermission for each environment that matches a role binding + List[orm.RoleBinding] + A list containing a RoleBinding for each role binding regex """ - all_envs = db.query(orm.Environment).join(orm.Namespace) - user_permissions = [] + user_bindings = [] for pattern, roles in role_bindings.items(): - max_role = schema.Role.max_role(roles) - - for environment in filter_environments( - query=all_envs, - role_bindings={pattern: roles}, - ).all(): - user_permissions.append( - orm.UserPermission( - environment=environment, - role=max_role, - ) + user_bindings.append( + orm.RoleBinding( + pattern=pattern, + role=schema.Role.max_role(roles), ) + ) - return user_permissions + return user_bindings From d01fed1360cbf1c856a711c9f4a9e98e0125b39c Mon Sep 17 00:00:00 2001 From: pdmurray Date: Mon, 11 Nov 2024 19:06:23 -0800 Subject: [PATCH 17/18] Regenerate migration --- ...able.py => b3aa6f2abe04_add_user_table.py} | 13 ++++---- conda-store-server/conda_store_server/api.py | 30 ++++++------------- 2 files changed, 15 insertions(+), 28 deletions(-) rename conda-store-server/conda_store_server/_internal/alembic/versions/{98e16d86ffb0_add_user_table.py => b3aa6f2abe04_add_user_table.py} (77%) diff --git a/conda-store-server/conda_store_server/_internal/alembic/versions/98e16d86ffb0_add_user_table.py b/conda-store-server/conda_store_server/_internal/alembic/versions/b3aa6f2abe04_add_user_table.py similarity index 77% rename from conda-store-server/conda_store_server/_internal/alembic/versions/98e16d86ffb0_add_user_table.py rename to conda-store-server/conda_store_server/_internal/alembic/versions/b3aa6f2abe04_add_user_table.py index f8fc78556..2fc5d72ac 100644 --- a/conda-store-server/conda_store_server/_internal/alembic/versions/98e16d86ffb0_add_user_table.py +++ b/conda-store-server/conda_store_server/_internal/alembic/versions/b3aa6f2abe04_add_user_table.py @@ -4,9 +4,9 @@ """add user table -Revision ID: 98e16d86ffb0 +Revision ID: b3aa6f2abe04 Revises: bf065abf375b -Create Date: 2024-11-11 16:36:31.047525 +Create Date: 2024-11-11 19:05:34.416019 """ from alembic import op @@ -14,7 +14,7 @@ # revision identifiers, used by Alembic. -revision = '98e16d86ffb0' +revision = 'b3aa6f2abe04' down_revision = 'bf065abf375b' branch_labels = None depends_on = None @@ -29,17 +29,16 @@ def upgrade(): sa.UniqueConstraint('name') ) op.create_table( - 'userpermission', + 'rolebinding', sa.Column('id', sa.Integer(), nullable=False), - sa.Column('environment_id', sa.Integer(), nullable=True), + sa.Column('pattern', sa.Unicode(length=255), nullable=True), sa.Column('user_id', sa.Integer(), nullable=True), sa.Column('role', sa.Enum('NONE', 'VIEWER', 'EDITOR', 'ADMIN', name='role'), nullable=True), - sa.ForeignKeyConstraint(['environment_id'], ['environment.id'], ), sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), sa.PrimaryKeyConstraint('id') ) def downgrade(): - op.drop_table('userpermission') + op.drop_table('rolebinding') op.drop_table('user') diff --git a/conda-store-server/conda_store_server/api.py b/conda-store-server/conda_store_server/api.py index ff7597c4c..d9fd842c9 100644 --- a/conda-store-server/conda_store_server/api.py +++ b/conda-store-server/conda_store_server/api.py @@ -308,13 +308,21 @@ def list_environments( show_soft_deleted : bool If specified, filter by environments which have a null value for the deleted_on attribute + user : orm.User + If specified, only the environments accessible to the user are queried; + if not, all environments are queried Returns ------- Query Sqlalchemy query containing the requested environments """ - query = db.query(orm.Environment).join(orm.Environment.namespace) + if user: + query = user.get_environments(db, min_role=schema.Role.VIEWER) + else: + query = db.query(orm.Environment) + + query = query.join(orm.Environment.namespace) if namespace: query = query.filter(orm.Namespace.name == namespace) @@ -357,26 +365,6 @@ def list_environments( .having(func.count() == len(packages)) ) - if user: - query = ( - query.join( - orm.UserPermission, - orm.UserPermission.environment_id == orm.Environment.id, - ) - .join(orm.User, orm.User == user) - .filter( - and_( - orm.UserPermission.role > schema.Role.NONE, - orm.UserPermission in user.permissions, - ) - ) - ) - - # if role_bindings: - # # Any entity binding is sufficient permissions to view an environment; - # # no entity binding will hide the environment - # query = filter_environments(query, role_bindings) - return query From ada6a6445881823117164f28ed3c65e0d2948eb9 Mon Sep 17 00:00:00 2001 From: pdmurray Date: Tue, 12 Nov 2024 08:55:18 -0800 Subject: [PATCH 18/18] Add some documentation --- .../conda_store_server/_internal/schema.py | 9 ++++- .../_internal/server/views/api.py | 39 ++++++++++++++----- .../conda_store_server/server/auth.py | 27 ++++++++----- 3 files changed, 54 insertions(+), 21 deletions(-) diff --git a/conda-store-server/conda_store_server/_internal/schema.py b/conda-store-server/conda_store_server/_internal/schema.py index df1eb7b17..daf3c75d9 100644 --- a/conda-store-server/conda_store_server/_internal/schema.py +++ b/conda-store-server/conda_store_server/_internal/schema.py @@ -163,7 +163,7 @@ class AuthenticationToken(BaseModel): ) primary_namespace: str = "default" role_bindings: RoleBindings = {} - user_name: str + user_name: Optional[str] = None ########################## @@ -781,6 +781,13 @@ class APIPostToken(APIResponse): data: APIPostTokenData +class APIPostTokenRequest(BaseModel): + user_name: Optional[str] + primary_namespace: Optional[str] + expiration: Optional[datetime.datetime] + role_bindings: Optional[RoleBindings] + + # GET /api/v1/namespace class APIListNamespace(APIPaginatedResponse): data: List[Namespace] diff --git a/conda-store-server/conda_store_server/_internal/server/views/api.py b/conda-store-server/conda_store_server/_internal/server/views/api.py index 076dbabd4..f8c961a8a 100644 --- a/conda-store-server/conda_store_server/_internal/server/views/api.py +++ b/conda-store-server/conda_store_server/_internal/server/views/api.py @@ -213,26 +213,45 @@ async def api_get_usage( response_model=schema.APIPostToken, ) async def api_post_token( - request: Request, - primary_namespace: Optional[str] = Body(None), - expiration: Optional[datetime.datetime] = Body(None), - role_bindings: Optional[Dict[str, List[str]]] = Body(None), - conda_store=Depends(dependencies.get_conda_store), - auth=Depends(dependencies.get_auth), - entity=Depends(dependencies.get_entity), + request: schema.APIPostTokenRequest, + conda_store: app.CondaStore = Depends(dependencies.get_conda_store), + auth: Authentication = Depends(dependencies.get_auth), + entity: AuthenticationToken = Depends(dependencies.get_entity), ) -> schema.APIPostToken: + """Get a token from the conda-store-server. + + Parameters + ---------- + request : schema.APIPostTokenRequest + Request to generate a new token + conda_store : app.CondaStore + The running conda store application + auth : Authentication + Authentication instance for the request + entity : AuthenticationToken + Authenticated token of the user making the request. If the user is not + authenticated, the default namespace and role bindings will be used. + + Returns + ------- + schema.APIPostToken + A newly minted token with the requested permissions + """ if entity is None: entity = schema.AuthenticationToken( exp=datetime.datetime.now(tz=datetime.timezone.utc) + datetime.timedelta(days=1), primary_namespace=conda_store.default_namespace, role_bindings={}, + user_name=None, ) new_entity = schema.AuthenticationToken( - exp=expiration or entity.exp, - primary_namespace=primary_namespace or entity.primary_namespace, - role_bindings=role_bindings or auth.authorization.get_entity_bindings(entity), + exp=request.expiration or entity.exp, + primary_namespace=request.primary_namespace or entity.primary_namespace, + role_bindings=request.role_bindings + or auth.authorization.get_entity_bindings(entity), + user_name=request.user_name, ) if not auth.authorization.is_subset_entity_permissions(entity, new_entity): diff --git a/conda-store-server/conda_store_server/server/auth.py b/conda-store-server/conda_store_server/server/auth.py index 45371494c..a8b8767b9 100644 --- a/conda-store-server/conda_store_server/server/auth.py +++ b/conda-store-server/conda_store_server/server/auth.py @@ -290,22 +290,29 @@ def get_entity_bindings( self, entity: schema.AuthenticationToken, ) -> schema.RoleBindings: - authenticated = entity is not None - entity_role_bindings = {} if entity is None else entity.role_bindings + """Return the role bindings of the given token. - if authenticated: - db_role_bindings = self.database_role_bindings(entity) + Parameters + ---------- + entity : schema.AuthenticationToken + Token containing role bindings + Returns + ------- + schema.RoleBindings + Role bindings of the token. Includes whatever role bindings are already + in the token plus the unauthenticated role bindings plus the + database role bindings. + """ + authenticated = entity is not None + if authenticated: return { **self.authenticated_role_bindings, - **entity_role_bindings, - **db_role_bindings, + **entity.role_bindings, + **self.database_role_bindings(entity), } else: - return { - **self.unauthenticated_role_bindings, - **entity_role_bindings, - } + return (self.unauthenticated_role_bindings,) @utils.user_deprecation def convert_roles_to_permissions(