Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Change API to be able to assign roles to namespaces #607

Merged
merged 27 commits into from
Dec 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""add v2 role mappings

Revision ID: 771180018e1b
Revises: 30b37e725c32
Create Date: 2023-11-29 09:02:35.835664

"""
import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision = "771180018e1b"
down_revision = "30b37e725c32"
branch_labels = None
depends_on = None


def upgrade():
op.create_table(
"namespace_role_mapping_v2",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("namespace_id", sa.Integer(), nullable=False),
sa.Column("other_namespace_id", sa.Integer(), nullable=False),
dcmcand marked this conversation as resolved.
Show resolved Hide resolved
sa.Column("role", sa.Unicode(length=255), nullable=False),
sa.ForeignKeyConstraint(
["namespace_id"],
["namespace.id"],
),
sa.ForeignKeyConstraint(
["other_namespace_id"],
["namespace.id"],
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("namespace_id", "other_namespace_id", name="_uc"),
)


def downgrade():
op.drop_table("namespace_role_mapping_v2")
171 changes: 171 additions & 0 deletions conda-store-server/conda_store_server/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from conda_store_server import conda_utils, orm, schema, utils
from sqlalchemy import distinct, func, null, or_
from sqlalchemy.orm import aliased


def list_namespaces(db, show_soft_deleted: bool = False):
Expand Down Expand Up @@ -82,6 +83,176 @@ def update_namespace(
return namespace


# v2 API
def update_namespace_metadata(
db,
name: str,
metadata_: Dict[str, Any] = None,
):
namespace = get_namespace(db, name)
if namespace is None:
raise ValueError(f"Namespace='{name}' not found")

if metadata_ is not None:
namespace.metadata_ = metadata_

return namespace


# v2 API
def get_namespace_roles(
db,
name: str,
):
"""Which namespaces can access namespace 'name'?"""
namespace = get_namespace(db, name)
if namespace is None:
raise ValueError(f"Namespace='{name}' not found")

nrm = aliased(orm.NamespaceRoleMappingV2)
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we please have more descriptive variable names here? Readability is part of maintainability.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

In general, I agree. But not here. I deliberately use short names so that queries don't get split up over multiple lines by the linter, which would be hard to read. All these DB functions are very small, so there's no concern of this clashing with something else causing confusion. I couldn't think of a short name for NamespaceRoleMapping other than using the first letters.

this = aliased(orm.Namespace)
other = aliased(orm.Namespace)
q = (
db.query(nrm.id, this.name, other.name, nrm.role)
.filter(nrm.namespace_id == namespace.id)
.filter(nrm.namespace_id == this.id)
.filter(nrm.other_namespace_id == other.id)
.all()
)
return [schema.NamespaceRoleMappingV2.from_list(x) for x in q]


# v2 API
def get_other_namespace_roles(
db,
name: str,
):
"""To which namespaces does namespace 'name' have access?"""
namespace = get_namespace(db, name)
if namespace is None:
raise ValueError(f"Namespace='{name}' not found")

nrm = aliased(orm.NamespaceRoleMappingV2)
dcmcand marked this conversation as resolved.
Show resolved Hide resolved
this = aliased(orm.Namespace)
other = aliased(orm.Namespace)
q = (
db.query(nrm.id, this.name, other.name, nrm.role)
.filter(nrm.other_namespace_id == namespace.id)
.filter(nrm.namespace_id == this.id)
.filter(nrm.other_namespace_id == other.id)
.all()
)
return [schema.NamespaceRoleMappingV2.from_list(x) for x in q]


# v2 API
def delete_namespace_roles(
db,
name: str,
):
namespace = get_namespace(db, name)
if namespace is None:
raise ValueError(f"Namespace='{name}' not found")

nrm = orm.NamespaceRoleMappingV2
db.query(nrm).filter(nrm.namespace_id == namespace.id).delete()


# v2 API
def get_namespace_role(
db,
name: str,
other: str,
):
namespace = get_namespace(db, name)
dcmcand marked this conversation as resolved.
Show resolved Hide resolved
if namespace is None:
raise ValueError(f"Namespace='{name}' not found")

other_namespace = get_namespace(db, other)
if other_namespace is None:
raise ValueError(f"Namespace='{other}' not found")

nrm = aliased(orm.NamespaceRoleMappingV2)
this = aliased(orm.Namespace)
other = aliased(orm.Namespace)
q = (
db.query(nrm.id, this.name, other.name, nrm.role)
.filter(nrm.namespace_id == namespace.id)
.filter(nrm.other_namespace_id == other_namespace.id)
.filter(nrm.namespace_id == this.id)
.filter(nrm.other_namespace_id == other.id)
.first()
)
if q is None:
return None
nkaretnikov marked this conversation as resolved.
Show resolved Hide resolved
return schema.NamespaceRoleMappingV2.from_list(q)


# v2 API
def create_namespace_role(
db,
name: str,
other: str,
role: str,
):
namespace = get_namespace(db, name)
if namespace is None:
raise ValueError(f"Namespace='{name}' not found")

other_namespace = get_namespace(db, other)
if other_namespace is None:
raise ValueError(f"Namespace='{other}' not found")

db.add(
orm.NamespaceRoleMappingV2(
namespace_id=namespace.id,
other_namespace_id=other_namespace.id,
role=role,
)
)


# v2 API
def update_namespace_role(
db,
name: str,
other: str,
role: str,
):
namespace = get_namespace(db, name)
if namespace is None:
raise ValueError(f"Namespace='{name}' not found")

other_namespace = get_namespace(db, other)
if other_namespace is None:
raise ValueError(f"Namespace='{other}' not found")

nrm = orm.NamespaceRoleMappingV2
db.query(nrm).filter(nrm.namespace_id == namespace.id).filter(
nrm.other_namespace_id == other_namespace.id
).update({"role": role})


# v2 API
def delete_namespace_role(
db,
name: str,
other: str,
):
namespace = get_namespace(db, name)
if namespace is None:
raise ValueError(f"Namespace='{name}' not found")

other_namespace = get_namespace(db, other)
if other_namespace is None:
raise ValueError(f"Namespace='{other}' not found")

nrm = orm.NamespaceRoleMappingV2
db.query(nrm).filter(nrm.namespace_id == namespace.id).filter(
nrm.other_namespace_id == other_namespace.id
).delete()


def delete_namespace(db, name: str = None, id: int = None):
namespace = get_namespace(db, name=name, id=id)
if namespace:
Expand Down
31 changes: 31 additions & 0 deletions conda-store-server/conda_store_server/orm.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,37 @@ def validate_role(self, key, role):
return role


class NamespaceRoleMappingV2(Base):
"""Mapping between roles and namespaces"""

__tablename__ = "namespace_role_mapping_v2"

id = Column(Integer, primary_key=True)
# Provides access to this namespace
namespace_id = Column(Integer, ForeignKey("namespace.id"), nullable=False)
namespace = relationship(Namespace, foreign_keys=[namespace_id])

# ... for other namespace
other_namespace_id = Column(Integer, ForeignKey("namespace.id"), nullable=False)
Copy link
Contributor

Choose a reason for hiding this comment

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

As mentioned above, these names don't make it clear which namespace has permissions to which.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Addressed in another comment above.

other_namespace = relationship(Namespace, foreign_keys=[other_namespace_id])

# ... with this role, like 'viewer'
role = Column(Unicode(255), nullable=False)

@validates("role")
nkaretnikov marked this conversation as resolved.
Show resolved Hide resolved
def validate_role(self, key, role):
if role not in ["admin", "viewer", "developer"]:
raise ValueError(f"invalid role={role}")
return role

__table_args__ = (
# Ensures no duplicates can be added with this combination of fields.
# Note: this doesn't add role because role needs to be unique for each
# pair of ids.
UniqueConstraint("namespace_id", "other_namespace_id", name="_uc"),
nkaretnikov marked this conversation as resolved.
Show resolved Hide resolved
)


class Specification(Base):
"""The specifiction for a given conda environment"""

Expand Down
34 changes: 33 additions & 1 deletion conda-store-server/conda_store_server/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,9 @@ class Permissions(enum.Enum):
NAMESPACE_READ = "namespace::read"
NAMESPACE_UPDATE = "namespace::update"
NAMESPACE_DELETE = "namespace::delete"
NAMESPACE_ROLE_MAPPING_READ = "namespace-role-mapping::read"
NAMESPACE_ROLE_MAPPING_CREATE = "namespace-role-mapping::create"
NAMESPACE_ROLE_MAPPING_READ = "namespace-role-mapping::read"
NAMESPACE_ROLE_MAPPING_UPDATE = "namespace-role-mapping::update"
NAMESPACE_ROLE_MAPPING_DELETE = "namespace-role-mapping::delete"
SETTING_READ = "setting::read"
SETTING_UPDATE = "setting::update"
Expand Down Expand Up @@ -102,6 +103,20 @@ class Config:
orm_mode = True


class NamespaceRoleMappingV2(BaseModel):
id: int
namespace: str
other_namespace: str
role: str

class Config:
orm_mode = True

@classmethod
def from_list(cls, lst):
return cls(**{k: v for k, v in zip(cls.__fields__.keys(), lst)})


class Namespace(BaseModel):
id: int
name: constr(regex=f"^[{ALLOWED_CHARACTERS}]+$") # noqa: F722
Expand Down Expand Up @@ -616,6 +631,23 @@ class APIGetNamespace(APIResponse):
data: Namespace


# POST /api/v1/namespace/{name}/role
class APIPostNamespaceRole(BaseModel):
other_namespace: str
role: str


# PUT /api/v1/namespace/{name}/role
class APIPutNamespaceRole(BaseModel):
other_namespace: str
role: str


# DELETE /api/v1/namespace/{name}/role
class APIDeleteNamespaceRole(BaseModel):
other_namespace: str


# GET /api/v1/environment
class APIListEnvironment(APIPaginatedResponse):
data: List[Environment]
Expand Down
8 changes: 8 additions & 0 deletions conda-store-server/conda_store_server/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,14 @@ async def http_exception_handler(request, exc):
status_code=exc.status_code,
)

# Prints exceptions to the terminal
# https://fastapi.tiangolo.com/tutorial/handling-errors/#re-use-fastapis-exception-handlers
# https://github.com/tiangolo/fastapi/issues/1241
@app.exception_handler(Exception)
async def exception_handler(request, exc):
print(exc)
return await http_exception_handler(request, exc)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added this to print an exception to the terminal when an invalid role_mappings_version value is used. Previously, nothing was printed except "Invalid server error".


app.include_router(
self.authentication.router,
prefix=trim_slash(self.url_prefix),
Expand Down
Loading
Loading