diff --git a/backend/capellacollab/core/authentication/basic_auth.py b/backend/capellacollab/core/authentication/basic_auth.py index 5769a3db5f..c960308a4f 100644 --- a/backend/capellacollab/core/authentication/basic_auth.py +++ b/backend/capellacollab/core/authentication/basic_auth.py @@ -6,10 +6,12 @@ import fastapi from fastapi import security +from sqlalchemy import orm -from capellacollab.core import database from capellacollab.users import crud as user_crud +from capellacollab.users import models as users_models from capellacollab.users.tokens import crud as token_crud +from capellacollab.users.tokens import models as tokens_models from . import exceptions @@ -20,29 +22,31 @@ class HTTPBasicAuth(security.HTTPBasic): def __init__(self): super().__init__(auto_error=True) - async def __call__(self, request: fastapi.Request) -> str: # type: ignore + async def validate( + self, db: orm.Session, request: fastapi.Request + ) -> tuple[users_models.DatabaseUser, tokens_models.DatabaseUserToken]: credentials: ( security.HTTPBasicCredentials | None ) = await super().__call__(request) if not credentials: raise exceptions.UnauthenticatedError() - with database.SessionLocal() as session: - user = user_crud.get_user_by_name(session, credentials.username) - db_token = ( - token_crud.get_token_by_token_and_user( - session, credentials.password, user.id - ) - if user - else None + + user = user_crud.get_user_by_name(db, credentials.username) + if not user: + logger.info( + "User with username '%s' not found.", credentials.username ) - if not db_token: - logger.info("Token invalid for user %s", credentials.username) - raise exceptions.InvalidPersonalAccessTokenError() + raise exceptions.InvalidPersonalAccessTokenError() + db_token = token_crud.get_token_by_token_and_user( + db, credentials.password, user.id + ) + + if not db_token: + logger.info("Token invalid for user %s", credentials.username) + raise exceptions.InvalidPersonalAccessTokenError() - if db_token.expiration_date < datetime.date.today(): - logger.info("Token expired for user %s", credentials.username) - raise exceptions.PersonalAccessTokenExpired() - return self.get_username(credentials) + if db_token.expiration_date < datetime.date.today(): + logger.info("Token expired for user %s", credentials.username) + raise exceptions.PersonalAccessTokenExpired() - def get_username(self, credentials: security.HTTPBasicCredentials) -> str: - return credentials.username + return user, db_token diff --git a/backend/capellacollab/core/authentication/injectables.py b/backend/capellacollab/core/authentication/injectables.py index 8b3a09c40a..167f450db0 100644 --- a/backend/capellacollab/core/authentication/injectables.py +++ b/backend/capellacollab/core/authentication/injectables.py @@ -14,6 +14,7 @@ from capellacollab.core import database from capellacollab.core.authentication import api_key_cookie, basic_auth +from capellacollab.permissions import models as users_permissions from capellacollab.projects import crud as projects_crud from capellacollab.projects import exceptions as projects_exceptions from capellacollab.projects import models as projects_models @@ -59,28 +60,43 @@ class OpenAPIPersonalAccessToken(OpenAPIFakeBase): __hash__ = OpenAPIFakeBase.__hash__ -async def get_username( +async def validate_authentication_information( request: fastapi.Request, + db: orm.Session = fastapi.Depends(database.get_db), _unused1=fastapi.Depends(OpenAPIPersonalAccessToken()), -) -> str: +) -> tuple[users_models.DatabaseUser, users_permissions.GlobalScopes]: if request.cookies.get("id_token"): - username = await api_key_cookie.JWTAPIKeyCookie()(request) - return username + user = users_crud.get_user_by_name( + db, await api_key_cookie.JWTAPIKeyCookie()(request) + ) + assert user + return user, users_models.ROLE_MAPPING[user.role] authorization = request.headers.get("Authorization") scheme, _ = security_utils.get_authorization_scheme_param(authorization) match scheme.lower(): case "basic": - username = await basic_auth.HTTPBasicAuth()(request) + user, token = await basic_auth.HTTPBasicAuth().validate( + db, request + ) case "": raise exceptions.UnauthenticatedError() case _: raise exceptions.UnknownScheme(scheme) - return username + return user, users_permissions.GlobalScopes() # Replace with token scope + + +async def get_username( + authentication_information: tuple[ + users_models.DatabaseUser, users_permissions.GlobalScopes + ] = fastapi.Depends(validate_authentication_information), +) -> str: + return authentication_information[0].name +# TODO: Replace all occurrences of RoleVerification with PermissionValidation class RoleVerification: def __init__(self, required_role: users_models.Role, verify: bool = True): self.required_role = required_role diff --git a/backend/capellacollab/core/logging/__init__.py b/backend/capellacollab/core/logging/__init__.py index 79db6ddc40..6db7ad9489 100644 --- a/backend/capellacollab/core/logging/__init__.py +++ b/backend/capellacollab/core/logging/__init__.py @@ -14,6 +14,7 @@ from starlette.middleware import base from capellacollab.configuration.app import config +from capellacollab.core import database from capellacollab.core.authentication import injectables as auth_injectables LOGGING_LEVEL = config.logging.level @@ -80,7 +81,14 @@ async def dispatch( call_next: base.RequestResponseEndpoint, ): try: - username = await auth_injectables.get_username(request) + with database.SessionLocal() as session: + ( + user, + _, + ) = await auth_injectables.validate_authentication_information( + request, session + ) + username = user.name except fastapi.HTTPException: username = "anonymous" diff --git a/backend/capellacollab/permissions/__init__.py b/backend/capellacollab/permissions/__init__.py new file mode 100644 index 0000000000..04412280d8 --- /dev/null +++ b/backend/capellacollab/permissions/__init__.py @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 diff --git a/backend/capellacollab/permissions/exceptions.py b/backend/capellacollab/permissions/exceptions.py new file mode 100644 index 0000000000..a181dc703c --- /dev/null +++ b/backend/capellacollab/permissions/exceptions.py @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +from fastapi import status + +from capellacollab.core import exceptions as core_exceptions + +from . import models + + +class InsufficientPermissionError(core_exceptions.BaseError): + def __init__( + self, + required_permission: str, + required_verbs: set[models.UserTokenVerb], + ): + verbs = ", ".join(verb.value for verb in required_verbs) + super().__init__( + status_code=status.HTTP_403_FORBIDDEN, + title="Permission denied", + reason=f"Insufficient permissions: '{required_permission}' permission with verbs {verbs} is required for this transaction.", + err_code="INSUFFICIENT_PERMISSION", + ) diff --git a/backend/capellacollab/permissions/injectables.py b/backend/capellacollab/permissions/injectables.py new file mode 100644 index 0000000000..a84f165b69 --- /dev/null +++ b/backend/capellacollab/permissions/injectables.py @@ -0,0 +1,45 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +import dataclasses + +import fastapi +from sqlalchemy import orm + +from capellacollab.core import database +from capellacollab.core.authentication import injectables as auth_injectables +from capellacollab.users import models as users_models + +from . import exceptions, models + + +@dataclasses.dataclass(eq=False) +class PermissionValidation: + required_scope: models.GlobalScopes + + def __call__( + self, + authentication_information: tuple[ + users_models.DatabaseUser, models.GlobalScopes + ] = fastapi.Depends( + auth_injectables.validate_authentication_information + ), + db: orm.Session = fastapi.Depends(database.get_db), + ) -> None: + actual_scope = authentication_information[1].model_dump() + + for scope, perms in self.required_scope: + for perm, verbs in perms: + for verb in verbs: + if verb not in actual_scope[scope][perm]: + raise exceptions.InsufficientPermissionError( + perm, verbs + ) + + +async def get_scope( + authentication_information: tuple[ + users_models.DatabaseUser, models.GlobalScopes + ] = fastapi.Depends(auth_injectables.validate_authentication_information), +) -> models.GlobalScopes: + return authentication_information[1] diff --git a/backend/capellacollab/permissions/models.py b/backend/capellacollab/permissions/models.py new file mode 100644 index 0000000000..999bd9a4ea --- /dev/null +++ b/backend/capellacollab/permissions/models.py @@ -0,0 +1,165 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +import enum +import typing as t + +import pydantic + +from capellacollab.core import pydantic as core_pydantic + + +class UserTokenVerb(str, enum.Enum): + GET = "GET" + CREATE = "CREATE" + UPDATE = "UPDATE" + DELETE = "DELETE" + + +class UserScopes(core_pydantic.BaseModel): + sessions: set[ + t.Literal[ + UserTokenVerb.GET, UserTokenVerb.CREATE, UserTokenVerb.DELETE + ] + ] = pydantic.Field( + default={}, + title="Sessions", + description="Manage sessions of your own user", + ) + projects: set[t.Literal[UserTokenVerb.CREATE]] = pydantic.Field( + default={}, title="Projects", description="Create new projects" + ) + tokens: set[ + t.Literal[ + UserTokenVerb.GET, UserTokenVerb.CREATE, UserTokenVerb.DELETE + ] + ] = pydantic.Field( + default={}, + title="Personal Access Tokens", + description="Manage personal access tokens", + ) + + +class ProjectUserScopes(core_pydantic.BaseModel): + root: set[t.Literal[UserTokenVerb.DELETE]] = pydantic.Field( + default={}, + title="Project", + description="Add capability to delete the project or update the project metadata (visibility & project type)", + ) + pipelines: set[ + t.Literal[ + UserTokenVerb.GET, UserTokenVerb.CREATE, UserTokenVerb.DELETE + ] + ] = pydantic.Field( + default={}, + title="Pipelines", + description="See pipelines, create new pipelines or delete existing pipelines", + ) + pipeline_runs: set[t.Literal[UserTokenVerb.GET, UserTokenVerb.CREATE]] = ( + pydantic.Field( + default={}, + title="Pipeline Runs", + description="Allow access to see or trigger pipeline runs", + ) + ) + + +class AdminScopes(core_pydantic.BaseModel): + users: set[ + t.Literal[ + UserTokenVerb.GET, + UserTokenVerb.CREATE, + UserTokenVerb.UPDATE, + UserTokenVerb.DELETE, + ] + ] = pydantic.Field( + default={}, + title="Users", + description="Manage all users of the application", + ) + projects: set[ + t.Literal[ + UserTokenVerb.GET, + UserTokenVerb.CREATE, + UserTokenVerb.UPDATE, + UserTokenVerb.DELETE, + ] + ] = pydantic.Field( + default={}, + title="Projects", + description="Grant permission to all sub-resources of ALL projects", + ) + tools: set[ + t.Literal[ + UserTokenVerb.GET, + UserTokenVerb.CREATE, + UserTokenVerb.UPDATE, + UserTokenVerb.DELETE, + ] + ] = pydantic.Field( + default={}, + title="Tools", + description="Manage all tools, including it's versions and natures", + ) + announcements: set[ + t.Literal[UserTokenVerb.CREATE, UserTokenVerb.DELETE] + ] = pydantic.Field( + default={}, + title="Announcements", + description="Manage all announcements", + ) + monitoring: set[t.Literal[UserTokenVerb.GET]] = pydantic.Field( + default={}, + title="Monitoring", + description="Allow access to monitoring dashboards, Prometheus and Grafana", + ) + configuration: set[t.Literal[UserTokenVerb.GET, UserTokenVerb.UPDATE]] = ( + pydantic.Field( + default={}, + title="Global Configuration", + description="See and update the global configuration", + ) + ) + t4c_servers: set[ + t.Literal[ + UserTokenVerb.GET, + UserTokenVerb.CREATE, + UserTokenVerb.UPDATE, + UserTokenVerb.DELETE, + ] + ] = pydantic.Field( + default={}, + title="TeamForCapella Servers", + description="Manage Team4Capella servers and license servers", + ) + t4c_repositories: set[ + t.Literal[ + UserTokenVerb.GET, + UserTokenVerb.CREATE, + UserTokenVerb.UPDATE, + UserTokenVerb.DELETE, + ] + ] = pydantic.Field( + default={}, + title="TeamForCapella Repositories", + description="Manage Team4Capella repositories", + ) + pv_configuration: set[ + t.Literal[UserTokenVerb.UPDATE, UserTokenVerb.DELETE] + ] = pydantic.Field( + default={}, + title="pure::variants Configuration", + description="pure::variants license configuration", + ) + events: set[t.Literal[UserTokenVerb.GET]] = pydantic.Field( + default={}, title="Events", description="See all events" + ) + + +class GlobalScopes(core_pydantic.BaseModel): + user: UserScopes = pydantic.Field( + default=UserScopes(), title="User Scopes" + ) + admin: AdminScopes = pydantic.Field( + default=AdminScopes(), title="Administrator Scopes" + ) diff --git a/backend/capellacollab/permissions/routes.py b/backend/capellacollab/permissions/routes.py new file mode 100644 index 0000000000..e30514de70 --- /dev/null +++ b/backend/capellacollab/permissions/routes.py @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +import fastapi + +from . import models + +router = fastapi.APIRouter() + + +@router.get("") +def get_permissions(): + return models.GlobalScopes.model_json_schema() diff --git a/backend/capellacollab/projects/routes.py b/backend/capellacollab/projects/routes.py index 608d494e8d..d19bc9d3c6 100644 --- a/backend/capellacollab/projects/routes.py +++ b/backend/capellacollab/projects/routes.py @@ -45,14 +45,11 @@ def get_projects( users_injectables.get_own_user ), db: orm.Session = fastapi.Depends(database.get_db), - username: str = fastapi.Depends(auth_injectables.get_username), log: logging.LoggerAdapter = fastapi.Depends( core_logging.get_request_logger ), ) -> list[models.DatabaseProject]: - if auth_injectables.RoleVerification( - required_role=users_models.Role.ADMIN, verify=False - )(username, db): + if user.role == users_models.Role.ADMIN: log.debug("Fetching all projects") return list(crud.get_projects(db)) @@ -70,7 +67,7 @@ def get_projects( for association in user.projects if auth_injectables.ProjectRoleVerification( minimum_role, verify=False - )(association.project.slug, username, db) + )(association.project.slug, user.name, db) ] return projects @@ -93,7 +90,9 @@ def update_project( project: models.DatabaseProject = fastapi.Depends( projects_injectables.get_existing_project ), - username: str = fastapi.Depends(auth_injectables.get_username), + user: users_models.DatabaseUser = fastapi.Depends( + users_injectables.get_own_user + ), db: orm.Session = fastapi.Depends(database.get_db), ) -> models.DatabaseProject: if patch_project.name: @@ -102,7 +101,7 @@ def update_project( if project.slug != new_slug and crud.get_project_by_slug(db, new_slug): raise exceptions.ProjectAlreadyExistsError(project.slug) if patch_project.is_archived: - _delete_all_pipelines_for_project(db, project, username) + _delete_all_pipelines_for_project(db, project, user) return crud.update_project(db, project, patch_project) @@ -185,13 +184,15 @@ def delete_project( def _delete_all_pipelines_for_project( - db: orm.Session, project: models.DatabaseProject, username: str + db: orm.Session, + project: models.DatabaseProject, + user: users_models.DatabaseUser, ): pipelines: list[backups_models.DatabaseBackup] = [] for model in project.models: pipelines.extend(backups_crud.get_pipelines_for_tool_model(db, model)) for pipeline in pipelines: - backups_core.delete_pipeline(db, pipeline, username, True) + backups_core.delete_pipeline(db, pipeline, user, True) router.include_router( diff --git a/backend/capellacollab/projects/toolmodels/backups/core.py b/backend/capellacollab/projects/toolmodels/backups/core.py index 72b71ab288..536c8606d2 100644 --- a/backend/capellacollab/projects/toolmodels/backups/core.py +++ b/backend/capellacollab/projects/toolmodels/backups/core.py @@ -8,7 +8,6 @@ import requests from sqlalchemy import orm -from capellacollab.core.authentication import injectables as auth_injectables from capellacollab.projects.toolmodels import models as toolmodels_models from capellacollab.projects.toolmodels.modelsources.git import ( models as git_models, @@ -64,7 +63,7 @@ def get_environment( def delete_pipeline( db: orm.Session, pipeline: models.DatabaseBackup, - username: str, + user: users_models.DatabaseUser, force: bool, ): try: @@ -80,12 +79,7 @@ def delete_pipeline( exc_info=True, ) - if not ( - force - and auth_injectables.RoleVerification( - required_role=users_models.Role.ADMIN, verify=False - )(username=username, db=db) - ): + if not (force and user.role == users_models.Role.ADMIN): raise exceptions.PipelineOperationFailedT4CServerUnreachable( exceptions.PipelineOperation.DELETE ) diff --git a/backend/capellacollab/projects/toolmodels/backups/routes.py b/backend/capellacollab/projects/toolmodels/backups/routes.py index ef52128383..6347b30634 100644 --- a/backend/capellacollab/projects/toolmodels/backups/routes.py +++ b/backend/capellacollab/projects/toolmodels/backups/routes.py @@ -28,6 +28,8 @@ interface as t4c_repository_interface, ) from capellacollab.tools import crud as tools_crud +from capellacollab.users import injectables as users_injectables +from capellacollab.users import models as users_models from .. import exceptions as toolmodels_exceptions from . import core, crud, exceptions, injectables, models @@ -74,7 +76,6 @@ def create_backup( toolmodels_injectables.get_existing_capella_model ), db: orm.Session = fastapi.Depends(database.get_db), - username: str = fastapi.Depends(auth_injectables.get_username), ): git_model = git_injectables.get_existing_git_model( body.git_model_id, toolmodel, db @@ -148,10 +149,12 @@ def delete_pipeline( injectables.get_existing_pipeline ), db: orm.Session = fastapi.Depends(database.get_db), - username: str = fastapi.Depends(auth_injectables.get_username), + user: users_models.DatabaseUser = fastapi.Depends( + users_injectables.get_own_user + ), force: bool = False, ): - core.delete_pipeline(db, pipeline, username, force) + core.delete_pipeline(db, pipeline, user, force) router.include_router( diff --git a/backend/capellacollab/projects/users/routes.py b/backend/capellacollab/projects/users/routes.py index e3611c6fea..8f43e6704f 100644 --- a/backend/capellacollab/projects/users/routes.py +++ b/backend/capellacollab/projects/users/routes.py @@ -67,11 +67,8 @@ def get_current_project_user( projects_injectables.get_existing_project ), db: orm.Session = fastapi.Depends(database.get_db), - username: str = fastapi.Depends(auth_injectables.get_username), ) -> models.ProjectUserAssociation | models.ProjectUser: - if auth_injectables.RoleVerification( - required_role=users_models.Role.ADMIN, verify=False - )(username, db): + if user.role == users_models.Role.ADMIN: return models.ProjectUser( role=models.ProjectUserRole.ADMIN, permission=models.ProjectUserPermission.WRITE, diff --git a/backend/capellacollab/routes.py b/backend/capellacollab/routes.py index 9f606f9158..f5a5f5a55c 100644 --- a/backend/capellacollab/routes.py +++ b/backend/capellacollab/routes.py @@ -13,6 +13,7 @@ from capellacollab.feedback import routes as feedback_routes from capellacollab.health import routes as health_routes from capellacollab.notices import routes as notices_routes +from capellacollab.permissions import routes as permissions_routes from capellacollab.projects import routes as projects_routes from capellacollab.sessions import routes as sessions_routes from capellacollab.settings import routes as settings_routes @@ -60,6 +61,11 @@ responses=auth_responses.api_exceptions(include_authentication=True), tags=["Users"], ) +router.include_router( + permissions_routes.router, + prefix="/permissions", + tags=["Permissions"], +) router.include_router( events_router.router, prefix="/events", diff --git a/backend/capellacollab/users/injectables.py b/backend/capellacollab/users/injectables.py index 70f1687304..66515cce1f 100644 --- a/backend/capellacollab/users/injectables.py +++ b/backend/capellacollab/users/injectables.py @@ -1,6 +1,7 @@ # SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors # SPDX-License-Identifier: Apache-2.0 + import fastapi from sqlalchemy import orm diff --git a/backend/capellacollab/users/models.py b/backend/capellacollab/users/models.py index 74bf9fb000..2e92e69b54 100644 --- a/backend/capellacollab/users/models.py +++ b/backend/capellacollab/users/models.py @@ -13,6 +13,8 @@ from capellacollab.core import database from capellacollab.core import pydantic as core_pydantic +from ..permissions import models + if t.TYPE_CHECKING: from capellacollab.events.models import DatabaseUserHistoryEvent from capellacollab.projects.users.models import ProjectUserAssociation @@ -25,6 +27,78 @@ class Role(str, enum.Enum): USER = "user" +USER_TOKEN_SCOPE = models.UserScopes( + sessions={ + models.UserTokenVerb.GET, + models.UserTokenVerb.CREATE, + models.UserTokenVerb.DELETE, + }, + projects={models.UserTokenVerb.CREATE}, + tokens={ + models.UserTokenVerb.GET, + models.UserTokenVerb.CREATE, + models.UserTokenVerb.DELETE, + }, +) + +ROLE_MAPPING = { + Role.USER: models.GlobalScopes( + user=USER_TOKEN_SCOPE, + ), + Role.ADMIN: models.GlobalScopes( + user=USER_TOKEN_SCOPE, + admin=models.AdminScopes( + users={ + models.UserTokenVerb.GET, + models.UserTokenVerb.CREATE, + models.UserTokenVerb.UPDATE, + models.UserTokenVerb.DELETE, + }, + projects={ + models.UserTokenVerb.GET, + models.UserTokenVerb.CREATE, + models.UserTokenVerb.UPDATE, + models.UserTokenVerb.DELETE, + }, + tools={ + models.UserTokenVerb.GET, + models.UserTokenVerb.CREATE, + models.UserTokenVerb.UPDATE, + models.UserTokenVerb.DELETE, + }, + announcements={ + models.UserTokenVerb.CREATE, + models.UserTokenVerb.DELETE, + }, + monitoring={ + models.UserTokenVerb.GET, + }, + configuration={ + models.UserTokenVerb.GET, + models.UserTokenVerb.UPDATE, + }, + t4c_servers={ + models.UserTokenVerb.GET, + models.UserTokenVerb.UPDATE, + models.UserTokenVerb.DELETE, + }, + t4c_repositories={ + models.UserTokenVerb.GET, + models.UserTokenVerb.UPDATE, + models.UserTokenVerb.DELETE, + }, + pv_configuration={ + models.UserTokenVerb.UPDATE, + models.UserTokenVerb.DELETE, + }, + events={ + models.UserTokenVerb.GET, + }, + ), + ), +} + + class BaseUser(core_pydantic.BaseModel): id: int name: str diff --git a/backend/capellacollab/users/routes.py b/backend/capellacollab/users/routes.py index 2d72687027..6b0bdb19c9 100644 --- a/backend/capellacollab/users/routes.py +++ b/backend/capellacollab/users/routes.py @@ -8,35 +8,33 @@ from capellacollab.configuration import core as config_core from capellacollab.core import database -from capellacollab.core.authentication import injectables as auth_injectables from capellacollab.events import crud as events_crud from capellacollab.events import models as events_models from capellacollab.feedback import crud as feedback_crud +from capellacollab.permissions import injectables as permissions_injectables +from capellacollab.permissions import models as permissions_models from capellacollab.projects import crud as projects_crud from capellacollab.projects import models as projects_models from capellacollab.projects.users import crud as projects_users_crud from capellacollab.sessions import routes as session_routes from capellacollab.users import injectables as users_injectables -from capellacollab.users import models as users_models from capellacollab.users.tokens import routes as tokens_routes from capellacollab.users.workspaces import routes as workspaces_routes from capellacollab.users.workspaces import util as workspaces_util from . import crud, exceptions, injectables, models -router = fastapi.APIRouter( - dependencies=[ - fastapi.Depends( - auth_injectables.RoleVerification(required_role=models.Role.USER) - ) - ] -) +router = fastapi.APIRouter() -@router.get("/current", response_model=models.User) +@router.get( + "/current", + response_model=models.User, +) def get_current_user( user: models.DatabaseUser = fastapi.Depends(injectables.get_own_user), ) -> models.DatabaseUser: + """Return the user that is currently logged in. No scope required.""" return user @@ -47,15 +45,20 @@ def get_current_user( def get_user( own_user: models.DatabaseUser = fastapi.Depends(injectables.get_own_user), user: models.DatabaseUser = fastapi.Depends(injectables.get_existing_user), + scope: permissions_models.GlobalScopes = fastapi.Depends( + permissions_injectables.get_scope + ), db: orm.Session = fastapi.Depends(database.get_db), ) -> models.DatabaseUser: + """Return the user. + + Requires scope `admin.users/GET` or at least one common project with the user. + """ if ( user.id == own_user.id or len(projects_crud.get_common_projects_for_users(db, own_user, user)) > 0 - or auth_injectables.RoleVerification( - required_role=models.Role.ADMIN, verify=False - )(own_user.name, db) + or permissions_models.UserTokenVerb.GET in scope.admin.users ): return user else: @@ -67,13 +70,23 @@ def get_user( response_model=list[models.User], dependencies=[ fastapi.Depends( - auth_injectables.RoleVerification(required_role=models.Role.ADMIN) + permissions_injectables.PermissionValidation( + required_scope=permissions_models.GlobalScopes( + admin=permissions_models.AdminScopes( + users={permissions_models.UserTokenVerb.GET} + ) + ) + ) ) ], ) def get_users( db: orm.Session = fastapi.Depends(database.get_db), ) -> abc.Sequence[models.DatabaseUser]: + """Get all users. + + Requires scope `admin.users/GET`. + """ return crud.get_users(db) @@ -82,7 +95,13 @@ def get_users( response_model=models.User, dependencies=[ fastapi.Depends( - auth_injectables.RoleVerification(required_role=models.Role.ADMIN) + permissions_injectables.PermissionValidation( + required_scope=permissions_models.GlobalScopes( + admin=permissions_models.AdminScopes( + users={permissions_models.UserTokenVerb.CREATE} + ) + ) + ) ) ], ) @@ -91,6 +110,12 @@ def create_user( own_user: models.DatabaseUser = fastapi.Depends(injectables.get_own_user), db: orm.Session = fastapi.Depends(database.get_db), ): + """Create a user. + + This is usually not needed since users are auto-created on login. + + Requires scope `admin.users/CREATE`. + """ created_user = crud.create_user( db, post_user.name, post_user.idp_identifier, post_user.email ) @@ -111,8 +136,19 @@ def get_common_projects( ), user: models.DatabaseUser = fastapi.Depends(injectables.get_own_user), db: orm.Session = fastapi.Depends(database.get_db), + scope: permissions_models.GlobalScopes = fastapi.Depends( + permissions_injectables.get_scope + ), ) -> list[projects_models.DatabaseProject]: - if user.role == models.Role.ADMIN: + """List all common projects with a user. + + If you request with `admin.projects/GET` and `admin.users/GET` scopes, + the API will return all projects of the selected user. + """ + if ( + permissions_models.UserTokenVerb.GET in scope.admin.projects + and permissions_models.UserTokenVerb.GET in scope.admin.users + ): return [ association.project for association in user_for_common_projects.projects @@ -132,9 +168,26 @@ def update_user( user: models.DatabaseUser = fastapi.Depends(injectables.get_existing_user), own_user: models.DatabaseUser = fastapi.Depends(get_current_user), db: orm.Session = fastapi.Depends(database.get_db), + scope: permissions_models.GlobalScopes = fastapi.Depends( + permissions_injectables.get_scope + ), ): - # Users are only allowed to update their beta_tester status unless they are an admin - if own_user.role != models.Role.ADMIN: + """Update the user. + + The `reason` field is required when updating the role. + When changing the role to `ADMIN`, the explicit membership in all projects will be removed and + will be replaced with an implicit membership in all projects. + + The `beta_user` field can only be updated when `beta.enabled` is activated in the + global configuration. + + The `beta_user` field can be updated for the own user when `beta.allow_self_enrollment` + is activated in the global configuration. + All other fields can only be updated with the `admin.users/UPDATE` scope. + """ + + # Users are only allowed to update their beta_tester status unless they have the `admin.users/UPDATE` scope + if permissions_models.UserTokenVerb.UPDATE not in scope.admin.users: if own_user.id != user.id: raise exceptions.ChangesNotAllowedForOtherUsersError() if any(patch_user.model_dump(exclude={"beta_tester"}).values()): @@ -146,7 +199,8 @@ def update_user( raise exceptions.BetaTestingDisabledError() if ( not cfg.beta.allow_self_enrollment - and own_user.role != models.Role.ADMIN + and permissions_models.UserTokenVerb.UPDATE + not in scope.admin.users ): raise exceptions.BetaTestingSelfEnrollmentNotAllowedError() @@ -166,7 +220,13 @@ def update_user( status_code=204, dependencies=[ fastapi.Depends( - auth_injectables.RoleVerification(required_role=models.Role.ADMIN) + permissions_injectables.PermissionValidation( + required_scope=permissions_models.GlobalScopes( + admin=permissions_models.AdminScopes( + users={permissions_models.UserTokenVerb.DELETE} + ) + ) + ) ) ], ) @@ -174,6 +234,13 @@ def delete_user( user: models.DatabaseUser = fastapi.Depends(injectables.get_existing_user), db: orm.Session = fastapi.Depends(database.get_db), ): + """Delete a user irrevocably. + + The user will be removed from all projects, all events the user was involved in will be deleted, + all workspaces of the user will be deleted, and all feedback of the user will be anonymized. + + Requires scope `admin.users/DELETE`. + """ projects_users_crud.delete_projects_for_user(db, user.id) events_crud.delete_all_events_user_involved_in(db, user.id) workspaces_util.delete_all_workspaces_of_user(db, user) @@ -186,27 +253,27 @@ def delete_user( response_model=list[events_models.HistoryEvent], dependencies=[ fastapi.Depends( - auth_injectables.RoleVerification( - required_role=users_models.Role.ADMIN + permissions_injectables.PermissionValidation( + required_scope=permissions_models.GlobalScopes( + admin=permissions_models.AdminScopes( + users={permissions_models.UserTokenVerb.GET}, + events={permissions_models.UserTokenVerb.GET}, + ) + ) ) ) ], ) def get_user_events( - user: users_models.DatabaseUser = fastapi.Depends( + user: models.DatabaseUser = fastapi.Depends( users_injectables.get_existing_user ), ) -> list[events_models.DatabaseUserHistoryEvent]: - return user.events - + """List all events for the user. -def get_projects_for_user( - user: models.DatabaseUser, db: orm.Session -) -> list[projects_models.DatabaseProject]: - if user.role != models.Role.ADMIN: - return [association.project for association in user.projects] - else: - return list(projects_crud.get_projects(db)) + Requires scopes `admin.users/GET` and `admin.events/GET`. + """ + return user.events def update_user_role( diff --git a/backend/capellacollab/users/tokens/models.py b/backend/capellacollab/users/tokens/models.py index 7d4a79e523..2eaa1e8e6f 100644 --- a/backend/capellacollab/users/tokens/models.py +++ b/backend/capellacollab/users/tokens/models.py @@ -3,11 +3,13 @@ import datetime import typing as t +import pydantic import sqlalchemy as sa from sqlalchemy import orm from capellacollab.core import database from capellacollab.core import pydantic as core_pydantic +from capellacollab.permissions import models as users_permissions if t.TYPE_CHECKING: from capellacollab.users.models import DatabaseUser @@ -22,6 +24,14 @@ class UserToken(core_pydantic.BaseModel): source: str +class FineGrainedResource(core_pydantic.BaseModel): + user: users_permissions.UserScopes + admin: users_permissions.AdminScopes + projects: dict[str, users_permissions.ProjectUserScopes] = pydantic.Field( + description="Project Slug / Resource mapping." + ) + + class UserTokenWithPassword(UserToken): password: str @@ -30,6 +40,7 @@ class PostToken(core_pydantic.BaseModel): expiration_date: datetime.date description: str source: str + scopes: FineGrainedResource class DatabaseUserToken(database.Base): diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 7a09738116..d966d573cc 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -18,7 +18,7 @@ import { ConfigurationSettingsComponent } from 'src/app/settings/core/configurat import { PipelinesOverviewComponent } from 'src/app/settings/core/pipelines-overview/pipelines-overview.component'; import { CreateToolComponent } from 'src/app/settings/core/tools-settings/create-tool/create-tool.component'; import { AddGitInstanceComponent } from 'src/app/settings/modelsources/git-settings/add-git-instance/add-git-instance.component'; -import { BasicAuthTokenComponent } from 'src/app/users/basic-auth-token/basic-auth-token.component'; +import { PersonalAccessTokensComponent } from 'src/app/users/personal-access-tokens/personal-access-tokens.component'; import { UserWrapperComponent } from 'src/app/users/user-wrapper/user-wrapper.component'; import { UsersProfileComponent } from 'src/app/users/users-profile/users-profile.component'; import { EventsComponent } from './events/events.component'; @@ -547,7 +547,7 @@ export const routes: Routes = [ { path: 'tokens', data: { breadcrumb: 'Tokens' }, - component: BasicAuthTokenComponent, + component: PersonalAccessTokensComponent, }, ], }, diff --git a/frontend/src/app/openapi/.openapi-generator/FILES b/frontend/src/app/openapi/.openapi-generator/FILES index bd30a4a58b..5279d7fe24 100644 --- a/frontend/src/app/openapi/.openapi-generator/FILES +++ b/frontend/src/app/openapi/.openapi-generator/FILES @@ -7,6 +7,7 @@ api/feedback.service.ts api/health.service.ts api/integrations-pure-variants.service.ts api/notices.service.ts +api/permissions.service.ts api/projects-events.service.ts api/projects-models-backups.service.ts api/projects-models-diagrams.service.ts @@ -31,6 +32,7 @@ api/users.service.ts configuration.ts encoder.ts index.ts +model/admin-scopes.ts model/anonymized-session.ts model/authorization-response.ts model/backup-pipeline-run.ts @@ -69,6 +71,7 @@ model/feedback-rating.ts model/feedback.ts model/file-tree.ts model/file-type.ts +model/fine-grained-resource.ts model/get-revision-model.ts model/get-revisions-response-model.ts model/git-credentials.ts @@ -142,6 +145,7 @@ model/project-tool.ts model/project-type.ts model/project-user-permission.ts model/project-user-role.ts +model/project-user-scopes.ts model/project-user.ts model/project-visibility.ts model/project.ts @@ -219,6 +223,7 @@ model/tool.ts model/toolmodel-status.ts model/unified-config.ts model/user-metadata.ts +model/user-scopes.ts model/user-token-with-password.ts model/user-token.ts model/user.ts diff --git a/frontend/src/app/openapi/api/api.ts b/frontend/src/app/openapi/api/api.ts index 72a053c3c5..2b602f8d7a 100644 --- a/frontend/src/app/openapi/api/api.ts +++ b/frontend/src/app/openapi/api/api.ts @@ -23,6 +23,8 @@ export * from './integrations-pure-variants.service'; import { IntegrationsPureVariantsService } from './integrations-pure-variants.service'; export * from './notices.service'; import { NoticesService } from './notices.service'; +export * from './permissions.service'; +import { PermissionsService } from './permissions.service'; export * from './projects.service'; import { ProjectsService } from './projects.service'; export * from './projects-events.service'; @@ -65,4 +67,4 @@ export * from './users-token.service'; import { UsersTokenService } from './users-token.service'; export * from './users-workspaces.service'; import { UsersWorkspacesService } from './users-workspaces.service'; -export const APIS = [AuthenticationService, ConfigurationService, EventsService, FeedbackService, HealthService, IntegrationsPureVariantsService, NoticesService, ProjectsService, ProjectsEventsService, ProjectsModelsService, ProjectsModelsBackupsService, ProjectsModelsDiagramsService, ProjectsModelsGitService, ProjectsModelsModelComplexityBadgeService, ProjectsModelsProvisioningService, ProjectsModelsREADMEService, ProjectsModelsRestrictionsService, ProjectsModelsT4CService, ProjectsToolsService, SessionsService, SettingsModelsourcesGitService, SettingsModelsourcesT4CInstancesService, SettingsModelsourcesT4CLicenseServersService, ToolsService, UsersService, UsersSessionsService, UsersTokenService, UsersWorkspacesService]; +export const APIS = [AuthenticationService, ConfigurationService, EventsService, FeedbackService, HealthService, IntegrationsPureVariantsService, NoticesService, PermissionsService, ProjectsService, ProjectsEventsService, ProjectsModelsService, ProjectsModelsBackupsService, ProjectsModelsDiagramsService, ProjectsModelsGitService, ProjectsModelsModelComplexityBadgeService, ProjectsModelsProvisioningService, ProjectsModelsREADMEService, ProjectsModelsRestrictionsService, ProjectsModelsT4CService, ProjectsToolsService, SessionsService, SettingsModelsourcesGitService, SettingsModelsourcesT4CInstancesService, SettingsModelsourcesT4CLicenseServersService, ToolsService, UsersService, UsersSessionsService, UsersTokenService, UsersWorkspacesService]; diff --git a/frontend/src/app/openapi/api/permissions.service.ts b/frontend/src/app/openapi/api/permissions.service.ts new file mode 100644 index 0000000000..711444bfce --- /dev/null +++ b/frontend/src/app/openapi/api/permissions.service.ts @@ -0,0 +1,153 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Capella Collaboration + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * Do not edit the class manually. + + To generate a new version, run `make openapi` in the root directory of this repository. + */ + +/* tslint:disable:no-unused-variable member-ordering */ + +import { Inject, Injectable, Optional } from '@angular/core'; +import { HttpClient, HttpHeaders, HttpParams, + HttpResponse, HttpEvent, HttpParameterCodec, HttpContext + } from '@angular/common/http'; +import { CustomHttpParameterCodec } from '../encoder'; +import { Observable } from 'rxjs'; + + +// @ts-ignore +import { BASE_PATH, COLLECTION_FORMATS } from '../variables'; +import { Configuration } from '../configuration'; + + + +@Injectable({ + providedIn: 'root' +}) +export class PermissionsService { + + protected basePath = 'http://localhost'; + public defaultHeaders = new HttpHeaders(); + public configuration = new Configuration(); + public encoder: HttpParameterCodec; + + constructor(protected httpClient: HttpClient, @Optional()@Inject(BASE_PATH) basePath: string|string[], @Optional() configuration: Configuration) { + if (configuration) { + this.configuration = configuration; + } + if (typeof this.configuration.basePath !== 'string') { + const firstBasePath = Array.isArray(basePath) ? basePath[0] : undefined; + if (firstBasePath != undefined) { + basePath = firstBasePath; + } + + if (typeof basePath !== 'string') { + basePath = this.basePath; + } + this.configuration.basePath = basePath; + } + this.encoder = this.configuration.encoder || new CustomHttpParameterCodec(); + } + + + // @ts-ignore + private addToHttpParams(httpParams: HttpParams, value: any, key?: string): HttpParams { + if (typeof value === "object" && value instanceof Date === false) { + httpParams = this.addToHttpParamsRecursive(httpParams, value); + } else { + httpParams = this.addToHttpParamsRecursive(httpParams, value, key); + } + return httpParams; + } + + private addToHttpParamsRecursive(httpParams: HttpParams, value?: any, key?: string): HttpParams { + if (value == null) { + return httpParams; + } + + if (typeof value === "object") { + if (Array.isArray(value)) { + (value as any[]).forEach( elem => httpParams = this.addToHttpParamsRecursive(httpParams, elem, key)); + } else if (value instanceof Date) { + if (key != null) { + httpParams = httpParams.append(key, (value as Date).toISOString().substring(0, 10)); + } else { + throw Error("key may not be null if value is Date"); + } + } else { + Object.keys(value).forEach( k => httpParams = this.addToHttpParamsRecursive( + httpParams, value[k], key != null ? `${key}.${k}` : k)); + } + } else if (key != null) { + httpParams = httpParams.append(key, value); + } else { + throw Error("key may not be null if value is not object or array"); + } + return httpParams; + } + + /** + * Get Permissions + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + */ + public getPermissions(observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public getPermissions(observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getPermissions(observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getPermissions(observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + + let localVarHeaders = this.defaultHeaders; + + let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept; + if (localVarHttpHeaderAcceptSelected === undefined) { + // to determine the Accept header + const httpHeaderAccepts: string[] = [ + 'application/json' + ]; + localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts); + } + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + let localVarHttpContext: HttpContext | undefined = options && options.context; + if (localVarHttpContext === undefined) { + localVarHttpContext = new HttpContext(); + } + + let localVarTransferCache: boolean | undefined = options && options.transferCache; + if (localVarTransferCache === undefined) { + localVarTransferCache = true; + } + + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/api/v1/permissions`; + return this.httpClient.request('get', `${this.configuration.basePath}${localVarPath}`, + { + context: localVarHttpContext, + responseType: responseType_, + withCredentials: this.configuration.withCredentials, + headers: localVarHeaders, + observe: observe, + transferCache: localVarTransferCache, + reportProgress: reportProgress + } + ); + } + +} diff --git a/frontend/src/app/openapi/api/projects.service.ts b/frontend/src/app/openapi/api/projects.service.ts index b370f8a258..3e49b053bd 100644 --- a/frontend/src/app/openapi/api/projects.service.ts +++ b/frontend/src/app/openapi/api/projects.service.ts @@ -345,6 +345,7 @@ export class ProjectsService { /** * Get Common Projects + * List all common projects with a user. If you request with `admin.projects/GET` and `admin.users/GET` scopes, the API will return all projects of the selected user. * @param userId * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. * @param reportProgress flag to report request and response progress. diff --git a/frontend/src/app/openapi/api/users.service.ts b/frontend/src/app/openapi/api/users.service.ts index 9495cfdf00..d02c0413e9 100644 --- a/frontend/src/app/openapi/api/users.service.ts +++ b/frontend/src/app/openapi/api/users.service.ts @@ -195,6 +195,7 @@ export class UsersService { /** * Create User + * Create a user. This is usually not needed since users are auto-created on login. Requires scope `admin.users/CREATE`. * @param postUser * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. * @param reportProgress flag to report request and response progress. @@ -347,6 +348,7 @@ export class UsersService { /** * Delete User + * Delete a user irrevocably. The user will be removed from all projects, all events the user was involved in will be deleted, all workspaces of the user will be deleted, and all feedback of the user will be anonymized. Requires scope `admin.users/DELETE`. * @param userId * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. * @param reportProgress flag to report request and response progress. @@ -560,6 +562,7 @@ export class UsersService { /** * Get Common Projects + * List all common projects with a user. If you request with `admin.projects/GET` and `admin.users/GET` scopes, the API will return all projects of the selected user. * @param userId * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. * @param reportProgress flag to report request and response progress. @@ -631,6 +634,7 @@ export class UsersService { /** * Get Current User + * Return the user that is currently logged in. No scope required. * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. * @param reportProgress flag to report request and response progress. */ @@ -769,6 +773,7 @@ export class UsersService { /** * Get User + * Return the user. Requires scope `admin.users/GET` or at least one common project with the user. * @param userId * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. * @param reportProgress flag to report request and response progress. @@ -840,6 +845,7 @@ export class UsersService { /** * Get User Events + * List all events for the user. Requires scopes `admin.users/GET` and `admin.events/GET`. * @param userId * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. * @param reportProgress flag to report request and response progress. @@ -911,6 +917,7 @@ export class UsersService { /** * Get Users + * Get all users. Requires scope `admin.users/GET`. * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. * @param reportProgress flag to report request and response progress. */ @@ -1049,6 +1056,7 @@ export class UsersService { /** * Update User + * Update the user. The `reason` field is required when updating the role. When changing the role to `ADMIN`, the explicit membership in all projects will be removed and will be replaced with an implicit membership in all projects. The `beta_user` field can only be updated when `beta.enabled` is activated in the global configuration. The `beta_user` field can be updated for the own user when `beta.allow_self_enrollment` is activated in the global configuration. All other fields can only be updated with the `admin.users/UPDATE` scope. * @param userId * @param patchUser * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. diff --git a/frontend/src/app/openapi/model/admin-scopes.ts b/frontend/src/app/openapi/model/admin-scopes.ts new file mode 100644 index 0000000000..5d7e8c583d --- /dev/null +++ b/frontend/src/app/openapi/model/admin-scopes.ts @@ -0,0 +1,115 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Capella Collaboration + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * Do not edit the class manually. + + To generate a new version, run `make openapi` in the root directory of this repository. + */ + + + +export interface AdminScopes { + /** + * Manage all users of the application. + */ + users?: Set; + /** + * Grant permission to all sub-resources of ALL projects. + */ + projects?: Set; + /** + * Manage all tools, including it\'s versions and natures. + */ + tools?: Set; + /** + * Manage all announcements. + */ + announcements?: Set; + /** + * Allow access to monitoring dashboards, Prometheus and Grafana. + */ + monitoring?: Set; + /** + * See and update the global configuration. + */ + configuration?: Set; + /** + * Manage Team4Capella servers and license servers + */ + t4c_servers?: Set; + /** + * Manage Team4Capella repositories + */ + t4c_repositories?: Set; + /** + * pure::variants license configuration + */ + pv_configuration?: Set; + /** + * See all events. + */ + events?: Set; +} +export namespace AdminScopes { + export type UsersEnum = 'GET' | 'CREATE' | 'UPDATE' | 'DELETE'; + export const UsersEnum = { + Get: 'GET' as UsersEnum, + Create: 'CREATE' as UsersEnum, + Update: 'UPDATE' as UsersEnum, + Delete: 'DELETE' as UsersEnum + }; + export type ProjectsEnum = 'GET' | 'CREATE' | 'UPDATE' | 'DELETE'; + export const ProjectsEnum = { + Get: 'GET' as ProjectsEnum, + Create: 'CREATE' as ProjectsEnum, + Update: 'UPDATE' as ProjectsEnum, + Delete: 'DELETE' as ProjectsEnum + }; + export type ToolsEnum = 'GET' | 'CREATE' | 'UPDATE' | 'DELETE'; + export const ToolsEnum = { + Get: 'GET' as ToolsEnum, + Create: 'CREATE' as ToolsEnum, + Update: 'UPDATE' as ToolsEnum, + Delete: 'DELETE' as ToolsEnum + }; + export type AnnouncementsEnum = 'CREATE' | 'DELETE'; + export const AnnouncementsEnum = { + Create: 'CREATE' as AnnouncementsEnum, + Delete: 'DELETE' as AnnouncementsEnum + }; + export type MonitoringEnum = 'GET'; + export const MonitoringEnum = { + Get: 'GET' as MonitoringEnum + }; + export type ConfigurationEnum = 'GET' | 'UPDATE'; + export const ConfigurationEnum = { + Get: 'GET' as ConfigurationEnum, + Update: 'UPDATE' as ConfigurationEnum + }; + export type T4cServersEnum = 'GET' | 'UPDATE' | 'DELETE'; + export const T4cServersEnum = { + Get: 'GET' as T4cServersEnum, + Update: 'UPDATE' as T4cServersEnum, + Delete: 'DELETE' as T4cServersEnum + }; + export type T4cRepositoriesEnum = 'GET' | 'UPDATE' | 'DELETE'; + export const T4cRepositoriesEnum = { + Get: 'GET' as T4cRepositoriesEnum, + Update: 'UPDATE' as T4cRepositoriesEnum, + Delete: 'DELETE' as T4cRepositoriesEnum + }; + export type PvConfigurationEnum = 'UPDATE' | 'DELETE'; + export const PvConfigurationEnum = { + Update: 'UPDATE' as PvConfigurationEnum, + Delete: 'DELETE' as PvConfigurationEnum + }; + export type EventsEnum = 'GET'; + export const EventsEnum = { + Get: 'GET' as EventsEnum + }; +} + + diff --git a/frontend/src/app/openapi/model/fine-grained-resource.ts b/frontend/src/app/openapi/model/fine-grained-resource.ts new file mode 100644 index 0000000000..7461185955 --- /dev/null +++ b/frontend/src/app/openapi/model/fine-grained-resource.ts @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Capella Collaboration + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * Do not edit the class manually. + + To generate a new version, run `make openapi` in the root directory of this repository. + */ + +import { AdminScopes } from './admin-scopes'; +import { ProjectUserScopes } from './project-user-scopes'; +import { UserScopes } from './user-scopes'; + + +export interface FineGrainedResource { + user: UserScopes; + admin: AdminScopes; + /** + * Project Slug / Resource mapping. + */ + projects: { [key: string]: ProjectUserScopes; }; +} + diff --git a/frontend/src/app/openapi/model/models.ts b/frontend/src/app/openapi/model/models.ts index 4b0fe80b7b..6a61bb2f98 100644 --- a/frontend/src/app/openapi/model/models.ts +++ b/frontend/src/app/openapi/model/models.ts @@ -9,6 +9,7 @@ + To generate a new version, run `make openapi` in the root directory of this repository. */ +export * from './admin-scopes'; export * from './anonymized-session'; export * from './authorization-response'; export * from './backup'; @@ -47,6 +48,7 @@ export * from './feedback-interval-configuration-output'; export * from './feedback-rating'; export * from './file-tree'; export * from './file-type'; +export * from './fine-grained-resource'; export * from './get-revision-model'; export * from './get-revisions-response-model'; export * from './git-credentials'; @@ -121,6 +123,7 @@ export * from './project-type'; export * from './project-user'; export * from './project-user-permission'; export * from './project-user-role'; +export * from './project-user-scopes'; export * from './project-visibility'; export * from './prometheus-configuration-input'; export * from './prometheus-configuration-output'; @@ -197,6 +200,7 @@ export * from './toolmodel-status'; export * from './unified-config'; export * from './user'; export * from './user-metadata'; +export * from './user-scopes'; export * from './user-token'; export * from './user-token-with-password'; export * from './validation-error'; diff --git a/frontend/src/app/openapi/model/post-token.ts b/frontend/src/app/openapi/model/post-token.ts index 8baa30f5e6..c52ff4c449 100644 --- a/frontend/src/app/openapi/model/post-token.ts +++ b/frontend/src/app/openapi/model/post-token.ts @@ -9,11 +9,13 @@ + To generate a new version, run `make openapi` in the root directory of this repository. */ +import { FineGrainedResource } from './fine-grained-resource'; export interface PostToken { expiration_date: string; description: string; source: string; + scopes: FineGrainedResource; } diff --git a/frontend/src/app/openapi/model/project-user-scopes.ts b/frontend/src/app/openapi/model/project-user-scopes.ts new file mode 100644 index 0000000000..ad340a7a66 --- /dev/null +++ b/frontend/src/app/openapi/model/project-user-scopes.ts @@ -0,0 +1,46 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Capella Collaboration + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * Do not edit the class manually. + + To generate a new version, run `make openapi` in the root directory of this repository. + */ + + + +export interface ProjectUserScopes { + /** + * Add capability to delete the project or update the project metadata (visibility & project type). + */ + root?: Set; + /** + * See pipelines, create new pipelines or delete existing pipelines. + */ + pipelines?: Set; + /** + * Allow access to see or trigger pipeline runs. + */ + pipeline_runs?: Set; +} +export namespace ProjectUserScopes { + export type RootEnum = 'DELETE'; + export const RootEnum = { + Delete: 'DELETE' as RootEnum + }; + export type PipelinesEnum = 'GET' | 'CREATE' | 'DELETE'; + export const PipelinesEnum = { + Get: 'GET' as PipelinesEnum, + Create: 'CREATE' as PipelinesEnum, + Delete: 'DELETE' as PipelinesEnum + }; + export type PipelineRunsEnum = 'GET' | 'CREATE'; + export const PipelineRunsEnum = { + Get: 'GET' as PipelineRunsEnum, + Create: 'CREATE' as PipelineRunsEnum + }; +} + + diff --git a/frontend/src/app/openapi/model/user-scopes.ts b/frontend/src/app/openapi/model/user-scopes.ts new file mode 100644 index 0000000000..2d0a35f914 --- /dev/null +++ b/frontend/src/app/openapi/model/user-scopes.ts @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Capella Collaboration + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * Do not edit the class manually. + + To generate a new version, run `make openapi` in the root directory of this repository. + */ + + + +export interface UserScopes { + /** + * Manage sessions of your own user. + */ + sessions?: Set; + /** + * Create new projects. + */ + projects?: Set; + /** + * Manage personal access tokens. + */ + tokens?: Set; +} +export namespace UserScopes { + export type SessionsEnum = 'GET' | 'CREATE' | 'DELETE'; + export const SessionsEnum = { + Get: 'GET' as SessionsEnum, + Create: 'CREATE' as SessionsEnum, + Delete: 'DELETE' as SessionsEnum + }; + export type ProjectsEnum = 'CREATE'; + export const ProjectsEnum = { + Create: 'CREATE' as ProjectsEnum + }; + export type TokensEnum = 'GET' | 'CREATE' | 'DELETE'; + export const TokensEnum = { + Get: 'GET' as TokensEnum, + Create: 'CREATE' as TokensEnum, + Delete: 'DELETE' as TokensEnum + }; +} + + diff --git a/frontend/src/app/projects/models/diagrams/model-diagram-dialog/model-diagram-code-block/model-diagram-code-block.component.ts b/frontend/src/app/projects/models/diagrams/model-diagram-dialog/model-diagram-code-block/model-diagram-code-block.component.ts index a4188f93fb..32f4701a9d 100644 --- a/frontend/src/app/projects/models/diagrams/model-diagram-dialog/model-diagram-code-block/model-diagram-code-block.component.ts +++ b/frontend/src/app/projects/models/diagrams/model-diagram-dialog/model-diagram-code-block/model-diagram-code-block.component.ts @@ -23,10 +23,14 @@ import { MatTooltip } from '@angular/material/tooltip'; import hljs from 'highlight.js'; import { MetadataService } from 'src/app/general/metadata/metadata.service'; import { ToastService } from 'src/app/helpers/toast/toast.service'; -import { Metadata, Project, ToolModel } from 'src/app/openapi'; +import { + Metadata, + Project, + ToolModel, + UsersTokenService, +} from 'src/app/openapi'; import { getPrimaryGitModel } from 'src/app/projects/models/service/model.service'; import { OwnUserWrapperService } from 'src/app/services/user/user.service'; -import { TokenService } from 'src/app/users/basic-auth-service/basic-auth-token.service'; @Component({ selector: 'app-model-diagram-code-block', @@ -52,7 +56,7 @@ export class ModelDiagramCodeBlockComponent implements OnInit, AfterViewInit { constructor( private metadataService: MetadataService, private userService: OwnUserWrapperService, - private tokenService: TokenService, + private tokenService: UsersTokenService, private toastService: ToastService, ) {} @@ -114,15 +118,16 @@ model = capellambse.MelodyModel( async insertToken() { const expirationDate = new Date(); expirationDate.setDate(expirationDate.getDate() + 30); - this.tokenService - .createToken( - 'Created in diagram cache dialog', - expirationDate, - 'Diagram-cache', - ) - .subscribe((token) => { - this.passwordValue = token.password; - }); + // this.tokenService + // .createTokenForUser({ + // description: 'Token used to fetch diagrams from the diagram cache', + // expiration_date: expirationDate.toISOString().substring(0, 10), + // source: `Diagram Viewer (Project ${this.project.slug})`, + // scopes: {}, + // }) + // .subscribe((token) => { + // this.passwordValue = token.password; + // }); } showClipboardMessage(): void { diff --git a/frontend/src/app/users/basic-auth-service/basic-auth-token.service.ts b/frontend/src/app/users/basic-auth-service/basic-auth-token.service.ts deleted file mode 100644 index 505b15c28c..0000000000 --- a/frontend/src/app/users/basic-auth-service/basic-auth-token.service.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* - * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors - * SPDX-License-Identifier: Apache-2.0 - */ -import { Injectable } from '@angular/core'; -import { BehaviorSubject, Observable, tap } from 'rxjs'; -import { - UsersTokenService, - UserToken, - UserTokenWithPassword, -} from 'src/app/openapi'; - -@Injectable({ - providedIn: 'root', -}) -export class TokenService { - constructor(private tokenService: UsersTokenService) {} - private _tokens = new BehaviorSubject(undefined); - - readonly tokens$ = this._tokens.asObservable(); - - loadTokens(): void { - this.tokenService.getAllTokensOfUser().subscribe({ - next: (token) => this._tokens.next(token), - error: () => this._tokens.next(undefined), - }); - } - - createToken( - description: string, - expiration_date: Date, - source: string, - ): Observable { - return this.tokenService - .createTokenForUser({ - description, - expiration_date: expiration_date.toISOString().substring(0, 10), - source, - }) - .pipe(tap(() => this.loadTokens())); - } - - deleteToken(token: UserToken): Observable { - return this.tokenService - .deleteTokenForUser(token.id) - .pipe(tap(() => this.loadTokens())); - } -} diff --git a/frontend/src/app/users/basic-auth-token/basic-auth-token.component.css b/frontend/src/app/users/basic-auth-token/basic-auth-token.component.css deleted file mode 100644 index db719d355b..0000000000 --- a/frontend/src/app/users/basic-auth-token/basic-auth-token.component.css +++ /dev/null @@ -1,11 +0,0 @@ -/* - * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -.border { - margin-right: 5px; - padding: 5px; - border: 2px solid var(--primary-color); - border-radius: 5px; -} diff --git a/frontend/src/app/users/basic-auth-token/basic-auth-token.component.html b/frontend/src/app/users/basic-auth-token/basic-auth-token.component.html deleted file mode 100644 index a03109556a..0000000000 --- a/frontend/src/app/users/basic-auth-token/basic-auth-token.component.html +++ /dev/null @@ -1,85 +0,0 @@ - - -
-

Personal Access Tokens

- To create a Personal Access Token please choose an expiration date and provide - a short description. -
- - Token description - - Note: The created token has the same permissions as you have when - being logged in. - -
- - Choose an expiration date - - MM/DD/YYYY - - - -
- -
- @if (password) { -
- Generated Token: - -
- Make sure you save the token - you won't be able to access it again. -
-
- } - -

Token overview

- @if ((tokenService.tokens$ | async)?.length) { - @for (token of tokenService.tokens$ | async; track token) { -
-
- Description: {{ token.description }}
- Expiration date: {{ token.expiration_date | date }}
- Creation location: {{ token.source }} - @if (isTokenExpired(token.expiration_date)) { - warning - This token has expired! - } -
- -
- } - } @else { - No token created for your user yet. - } -
diff --git a/frontend/src/app/users/basic-auth-token/basic-auth-token.component.ts b/frontend/src/app/users/basic-auth-token/basic-auth-token.component.ts deleted file mode 100644 index 7d2adf0913..0000000000 --- a/frontend/src/app/users/basic-auth-token/basic-auth-token.component.ts +++ /dev/null @@ -1,120 +0,0 @@ -/* - * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors - * SPDX-License-Identifier: Apache-2.0 - */ -import { AsyncPipe, DatePipe } from '@angular/common'; -import { Component, OnInit } from '@angular/core'; -import { - FormBuilder, - Validators, - FormsModule, - ReactiveFormsModule, -} from '@angular/forms'; -import { MatButton } from '@angular/material/button'; -import { - MatDatepickerInput, - MatDatepickerToggle, - MatDatepicker, -} from '@angular/material/datepicker'; -import { - MatFormField, - MatLabel, - MatHint, - MatSuffix, -} from '@angular/material/form-field'; -import { MatIcon } from '@angular/material/icon'; -import { MatInput } from '@angular/material/input'; -import { ToastService } from 'src/app/helpers/toast/toast.service'; -import { UserToken } from 'src/app/openapi'; -import { TokenService } from 'src/app/users/basic-auth-service/basic-auth-token.service'; -import { DisplayValueComponent } from '../../helpers/display-value/display-value.component'; - -@Component({ - selector: 'app-token-settings', - templateUrl: './basic-auth-token.component.html', - styleUrls: ['./basic-auth-token.component.css'], - imports: [ - FormsModule, - ReactiveFormsModule, - MatFormField, - MatLabel, - MatInput, - MatHint, - MatDatepickerInput, - MatDatepickerToggle, - MatSuffix, - MatDatepicker, - MatButton, - DisplayValueComponent, - MatIcon, - AsyncPipe, - DatePipe, - ], -}) -export class BasicAuthTokenComponent implements OnInit { - password?: string; - passwordRevealed = false; - minDate: Date; - maxDate: Date; - - tokenForm = this.formBuilder.group({ - description: ['', [Validators.required, Validators.minLength(1)]], - date: [this.getTomorrow(), [Validators.required]], - }); - constructor( - public tokenService: TokenService, - private toastService: ToastService, - private formBuilder: FormBuilder, - ) { - this.minDate = this.getTomorrow(); - this.maxDate = new Date( - this.minDate.getFullYear() + 1, - this.minDate.getMonth(), - this.minDate.getDate(), - ); - } - - ngOnInit() { - this.tokenService.loadTokens(); - } - - getTomorrow(): Date { - const tomorrow = new Date(); - tomorrow.setDate(tomorrow.getDate() + 1); - return tomorrow; - } - - createToken(): void { - if (this.tokenForm.valid) { - this.tokenService - .createToken( - this.tokenForm.value.description!, - this.tokenForm.value.date!, - 'Token-overview', - ) - .subscribe((token) => { - this.password = token.password; - this.tokenForm.controls.date.setValue(this.getTomorrow()); - }); - } - } - - deleteToken(token: UserToken) { - this.tokenService.deleteToken(token).subscribe(); - this.toastService.showSuccess( - 'Token deleted', - `The token ${token.description} was successfully deleted!`, - ); - } - - isTokenExpired(expirationDate: string): boolean { - return new Date(expirationDate) < new Date(); - } - - showClipboardMessage(): void { - this.toastService.showSuccess( - 'Token copied', - 'The token was copied to your clipboard.', - ); - } -} diff --git a/frontend/src/app/users/personal-access-tokens/personal-access-tokens.component.html b/frontend/src/app/users/personal-access-tokens/personal-access-tokens.component.html new file mode 100644 index 0000000000..13250b2d3d --- /dev/null +++ b/frontend/src/app/users/personal-access-tokens/personal-access-tokens.component.html @@ -0,0 +1,191 @@ + + +
+

Personal Access Tokens

+ Personal Access Tokens can be used to authenticate against the API. +
+ + Token description + + +
+ + Choose an expiration date + + MM/DD/YYYY + + + +
+ +
+

Token Scopes

+ + @if (permissionsSchema) { + @for ( + scope of permissionsSchema["properties"] | keyvalue; + track $index + ) { +
+ +

+ {{ scope.value.title }} +

+
+ +
+ + + + + + + + + + + + @for ( + permission of getPermissionByRef(scope.value.$ref)?.properties + | keyvalue; + track $index + ) { + + + + + + + + } + +
PermissionGETCREATEUPDATEDELETE
+ {{ permission.value.title }}
+ {{ + permission.value.description + }} +
+ @if (permission.value.items.enum.includes("GET")) { +
+ +
+ } +
+ @if (permission.value.items.enum.includes("CREATE")) { +
+ +
+ } +
+ @if (permission.value.items.enum.includes("UPDATE")) { +
+ +
+ } +
+ @if (permission.value.items.enum.includes("DELETE")) { +
+ +
+ } +
+
+ } + } +
+ +
+ +
+ + @if (generatedToken) { +
+ Generated Token: + +
+ Make sure you save the token - you won't be able to access it again. +
+
+ } + +

Token Overview

+ @if ((tokens$ | async)?.length) { + @for (token of tokens$ | async; track token) { +
+
+ Description: {{ token.description }}
+ Expiration date: {{ token.expiration_date | date }}
+ Creation location: {{ token.source }} + @if (isTokenExpired(token.expiration_date)) { + warning + This token has expired! + } +
+ +
+ } + } @else { + No token created for your user yet. + } +
diff --git a/frontend/src/app/users/personal-access-tokens/personal-access-tokens.component.ts b/frontend/src/app/users/personal-access-tokens/personal-access-tokens.component.ts new file mode 100644 index 0000000000..af3256afb4 --- /dev/null +++ b/frontend/src/app/users/personal-access-tokens/personal-access-tokens.component.ts @@ -0,0 +1,170 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { AsyncPipe, DatePipe, KeyValuePipe } from '@angular/common'; +import { Component, OnInit } from '@angular/core'; +import { + FormBuilder, + Validators, + FormsModule, + ReactiveFormsModule, +} from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatDatepickerModule } from '@angular/material/datepicker'; +import { MatFormField } from '@angular/material/form-field'; +import { MatIcon } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { BehaviorSubject, tap } from 'rxjs'; +import { ToastService } from 'src/app/helpers/toast/toast.service'; +import { + PermissionsService, + UsersTokenService, + UserToken, +} from 'src/app/openapi'; +import { DisplayValueComponent } from '../../helpers/display-value/display-value.component'; + +@Component({ + selector: 'app-personal-access-tokens', + templateUrl: './personal-access-tokens.component.html', + imports: [ + FormsModule, + ReactiveFormsModule, + MatFormField, + DisplayValueComponent, + MatIcon, + AsyncPipe, + DatePipe, + MatButtonModule, + MatDatepickerModule, + MatInputModule, + MatCheckboxModule, + KeyValuePipe, + ], +}) +export class PersonalAccessTokensComponent implements OnInit { + generatedToken?: string; + passwordRevealed = false; + minDate: Date; + maxDate: Date; + + permissionsSchema?: Scopes; + + expandedTokenScopes: Record = {}; + + private _tokens = new BehaviorSubject(undefined); + readonly tokens$ = this._tokens.asObservable(); + + tokenForm = this.formBuilder.group({ + description: ['', [Validators.required, Validators.minLength(1)]], + date: [this.getTomorrow(), [Validators.required]], + }); + constructor( + public tokenService: UsersTokenService, + private toastService: ToastService, + private formBuilder: FormBuilder, + private permissionsService: PermissionsService, + ) { + this.minDate = this.getTomorrow(); + this.maxDate = new Date( + this.minDate.getFullYear() + 1, + this.minDate.getMonth(), + this.minDate.getDate(), + ); + } + + ngOnInit() { + this.loadTokens(); + this.permissionsService.getPermissions().subscribe((permissions) => { + this.permissionsSchema = permissions; + }); + } + + getTomorrow(): Date { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + return tomorrow; + } + + createToken(): void { + if (this.tokenForm.valid) { + this.tokenService + .createTokenForUser({ + description: this.tokenForm.value.description!, + expiration_date: this.tokenForm.value + .date!.toISOString() + .substring(0, 10), + source: 'Token Overview', + scopes: {}, + }) + .pipe(tap(() => this.loadTokens())) + .subscribe((token) => { + this.generatedToken = token.password; + this.tokenForm.controls.date.setValue(this.getTomorrow()); + }); + } + } + + isTokenExpired(expirationDate: string): boolean { + return new Date(expirationDate) < new Date(); + } + + showClipboardMessage(): void { + this.toastService.showSuccess( + 'Token copied', + 'The token was copied to your clipboard.', + ); + } + + loadTokens(): void { + this.tokenService.getAllTokensOfUser().subscribe({ + next: (token) => this._tokens.next(token), + error: () => this._tokens.next(undefined), + }); + } + + deleteToken(token: UserToken) { + this.tokenService + .deleteTokenForUser(token.id) + .pipe(tap(() => this.loadTokens())) + .subscribe(() => { + this.toastService.showSuccess( + 'Token deleted', + `The token ${token.description} was successfully deleted!`, + ); + }); + } + + getPermissionByRef(ref: string) { + return this.permissionsSchema?.$defs[ref.split('/').pop()!]; + } +} + +interface Scopes { + $defs: Record< + string, + { + properties: Record< + string, + { + title: string; + description: string; + items: { + enum: [UserTokenVerb]; + }; + } + >; + title: string; + } + >; + properties: Record< + string, + { + title: string; + $ref: string; + } + >; +} + +type UserTokenVerb = 'GET' | 'CREATE' | 'UPDATE' | 'DELETE';