From 5442ca353c226c96f70a8d3f6c1f4f1f5b1a4fbc Mon Sep 17 00:00:00 2001 From: Davlat Davydov Date: Wed, 19 Aug 2026 17:33:26 +0300 Subject: [PATCH 01/13] feat(infra): some infrastructure & bootstrap changes --- Dockerfile | 5 +-- Justfile | 32 ++++++++++++++--- docker-compose.base.yaml | 74 ++++------------------------------------ example.config.toml | 6 ++-- 4 files changed, 40 insertions(+), 77 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1654041..d57eca1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,7 +21,7 @@ RUN apt-get update \ RUN pip install "uv==$UV_VERSION" COPY pyproject.toml config.toml ./ RUN uv venv -p 3.13 -RUN uv sync --all-extras +RUN uv sync --no-dev COPY src ./src RUN uv pip install -e . @@ -31,4 +31,5 @@ FROM python-base AS runner COPY --from=builder $VIRTUAL_ENV $VIRTUAL_ENV COPY --from=builder $APP_PATH/src $APP_PATH/src COPY --from=builder $APP_PATH/pyproject.toml $APP_PATH/pyproject.toml -COPY --from=builder $APP_PATH/config.toml $APP_PATH/config.toml \ No newline at end of file +COPY --from=builder $APP_PATH/config.toml $APP_PATH/config.toml +COPY --from=builder $APP_PATH/src/conode/infrastructure/credentials $APP_PATH/infrastructure/credentials \ No newline at end of file diff --git a/Justfile b/Justfile index 12fbb53..4bcb52b 100644 --- a/Justfile +++ b/Justfile @@ -36,18 +36,40 @@ up target build="" profile="": up $BUILD_FLAG fi -down rm="": +down flag="": #!/usr/bin/env bash set -e - RM_FLAG="" + FLAG="" - if [ "{{rm}}" = "rm" ]; then - RM_FLAG="-v" + if [ "{{flag}}" = "rm" ]; then + FLAG="-v" fi - docker compose -f docker-compose.base.yaml down $RM_FLAG + docker compose -f docker-compose.base.yaml down $FLAG + +keygen path="src/conode/infrastructure/credentials/": + #!/usr/bin/env bash + + openssl genrsa -out {{path}}private.pem 4096 + openssl rsa \ + -in {{path}}private.pem \ + -pubout \ + -out {{path}}public.pem + +test flag="": + #!/usr/bin/env bash + + set -e + + FLAG="" + + if [ "{{flag}}" = "parallel" ]; then + FLAG="-n auto" + fi + + pytest . $FLAG restart target: docker compose -f docker-compose.base.yaml restart {{target}} diff --git a/docker-compose.base.yaml b/docker-compose.base.yaml index f3654e2..0542e61 100644 --- a/docker-compose.base.yaml +++ b/docker-compose.base.yaml @@ -6,13 +6,18 @@ services: command: ["conode", "api", "run"] depends_on: - keycloak: - condition: service_healthy postgres: condition: service_healthy migrations: condition: service_completed_successfully + labels: + - "traefik.enable=true" + + - "traefik.http.routers.grafana.rule=Host(`api.conode.com`)" + - "traefik.http.routers.grafana.entrypoints=web" + - "traefik.http.services.grafana.loadbalancer.server.port=8000" + networks: - conode-network @@ -43,32 +48,6 @@ services: networks: - conode-network - oauth: - image: quay.io/oauth2-proxy/oauth2-proxy:latest - command: ["--config=/etc/oauth2-proxy/oauth2-proxy.toml"] - - environment: - OAUTH2_PROXY_CLIENT_ID: conode - OAUTH2_PROXY_CLIENT_SECRET: y9QISRU4LktDcAOvrXmw5q8t5s44JshMrnc4PIqPfq1vIEGipEtYgmUxvgiG9nb0PsxVwvlVQQ4FPOlhl9tiZ3 - OAUTH2_PROXY_COOKIE_SECRET: x3CpXUQxfDgXzDDxIdST3mTbTDosKOlx - - volumes: - - "./deploy/oauth_proxy.toml:/etc/oauth2-proxy/oauth2-proxy.toml" - - labels: - - "traefik.enable=true" - - - "traefik.http.routers.oauth.rule=Host(`api.conode.com`)" - - "traefik.http.routers.oauth.entrypoints=web" - - "traefik.http.services.oauth.loadbalancer.server.port=4180" - - networks: - - conode-network - - depends_on: - keycloak: - condition: service_healthy - traefik: image: traefik:latest command: @@ -84,51 +63,13 @@ services: networks: conode-network: aliases: - - keycloak.conode.com - api.conode.com - keycloak: - image: quay.io/keycloak/keycloak:latest - - command: start-dev --import-realm - - depends_on: - postgres: - condition: service_healthy - - env_file: - - "./deploy/keycloak/keycloak.env" - - healthcheck: - test: - - CMD-SHELL - - > - { printf 'HEAD /health/ready HTTP/1.0\r\n\r\n' >&0; - grep -q '200'; } 0<>/dev/tcp/localhost/9000 - interval: 5s - timeout: 10s - retries: 30 - start_period: 30s - - labels: - - "traefik.enable=true" - - "traefik.http.routers.keycloak.rule=Host(`keycloak.conode.com`)" - - "traefik.http.routers.keycloak.entrypoints=web" - - "traefik.http.services.keycloak.loadbalancer.server.port=8080" - - volumes: - - keycloak-data:/opt/keycloak/ - - ./deploy/keycloak/keycloak.json:/opt/keycloak/data/import/realm-export.json - - networks: - - conode-network - postgres: image: postgres:16-alpine volumes: - postgres-data:/var/lib/postgresql/data - - ./deploy/persistence:/docker-entrypoint-initdb.d:ro networks: conode-network: @@ -200,7 +141,6 @@ services: volumes: prometheus-data: postgres-data: - keycloak-data: tempo-data: alloy-data: diff --git a/example.config.toml b/example.config.toml index 636cf0f..a457ec9 100644 --- a/example.config.toml +++ b/example.config.toml @@ -10,10 +10,10 @@ host = "persistence.literally.io" port = 5432 [secrets] -secret = "ABC123" expires_in = 3600 -jwks_url = "http://keycloak:8080/realms/conode/protocol/openid-connect/certs" -issuer = "http://keycloak.conode.com/realms/conode" +private_key_file_path = "/conode/src/conode/infrastructure/credentials/private.pem" +public_key_file_path = "/conode/src/conode/infrastructure/credentials/public.pem" +issuer = "https://api.conode.com/" audience = "conode" [telemetry] From bc5cec9accff48ed404ec222a6977a0590f07467 Mon Sep 17 00:00:00 2001 From: Davlat Davydov Date: Wed, 19 Aug 2026 17:34:33 +0300 Subject: [PATCH 02/13] feat(application): login interactor --- src/conode/application/auth/login.py | 78 ++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 src/conode/application/auth/login.py diff --git a/src/conode/application/auth/login.py b/src/conode/application/auth/login.py new file mode 100644 index 0000000..d82a6e3 --- /dev/null +++ b/src/conode/application/auth/login.py @@ -0,0 +1,78 @@ +from dataclasses import dataclass + +from conode.application.auth.shared import AuthorizedResponseDTO +from conode.application.errors import InvalidCredentialsError, UserNotFoundError +from conode.application.interfaces.identity_provider import IdentityProvider +from conode.application.interfaces.password_hasher import PasswordHasher +from conode.application.interfaces.repositories import ( + AuthorizationRepository, + SessionRepository, + UserRepository, +) +from conode.application.interfaces.transaction_manager import TransactionManager +from conode.application.services import AuthorizationService +from conode.domain.user import Email + + +@dataclass(frozen=True, slots=True, kw_only=True) +class LoginRequestDTO: + email: str + password: str + + +@dataclass +class LoginInteractor: + user_repository: UserRepository + identity_provider: IdentityProvider + authorization_repository: AuthorizationRepository + authorization_service: AuthorizationService + session_repository: SessionRepository + transaction_manager: TransactionManager + password_hasher: PasswordHasher + + async def execute(self, request: LoginRequestDTO) -> AuthorizedResponseDTO: + async with self.transaction_manager: + user = await self.user_repository.get_by_email(Email(request.email)) + if user is None: + raise UserNotFoundError( + "User with this email not found", + meta=[{"key": "email", "value": request.email}], + ) + + authorization = await self.authorization_repository.get_by_user(user) + + if not self.password_hasher.verify( + authorization.hashed_password, request.password + ): + raise InvalidCredentialsError( + "Passwords doesn't match", + meta=[{"key": "password", "value": request.password}], + ) + + current_user_ip = self.identity_provider.get_current_ip() + + authorization_service_response = ( + self.authorization_service.generate_authorization_stuff( + user, + request.password, + current_user_ip, + ) + ) + session = await self.session_repository.get_by_ip_and_user( + user, current_user_ip + ) + if session is None: + await self.session_repository.create( + authorization_service_response.session + ) + else: + session.set_access_token( + authorization_service_response.session.access_token + ) + await self.session_repository.update(session) + + return AuthorizedResponseDTO( + access_token=authorization_service_response.session.access_token, + refresh_token=authorization_service_response.refresh_token, + expires_in=authorization_service_response.expires_in, + ) From 960686f8a41e3399c73f4975236f886ec0b1fdd8 Mon Sep 17 00:00:00 2001 From: Davlat Davydov Date: Wed, 19 Aug 2026 17:34:45 +0300 Subject: [PATCH 03/13] feat(application): register interactor --- src/conode/application/auth/register.py | 80 +++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 src/conode/application/auth/register.py diff --git a/src/conode/application/auth/register.py b/src/conode/application/auth/register.py new file mode 100644 index 0000000..b718727 --- /dev/null +++ b/src/conode/application/auth/register.py @@ -0,0 +1,80 @@ +from dataclasses import dataclass +from uuid import uuid4 + +from conode.application.auth.shared import AuthorizedResponseDTO +from conode.application.errors import UserAlreadyExistsError +from conode.application.interfaces.identity_provider import IdentityProvider +from conode.application.interfaces.repositories import ( + AuthorizationRepository, + SessionRepository, + UserRepository, +) +from conode.application.interfaces.transaction_manager import TransactionManager +from conode.application.services import AuthorizationService +from conode.domain.user import Email, User, UserId, Username + + +@dataclass(frozen=True, slots=True, kw_only=True) +class RegisterRequestDTO: + username: str + password: str + first_name: str + last_name: str + email: str + bio: str + + +@dataclass +class RegisterInteractor: + user_repository: UserRepository + identity_provider: IdentityProvider + session_repository: SessionRepository + transaction_manager: TransactionManager + authorization_service: AuthorizationService + authorization_repository: AuthorizationRepository + + async def execute(self, request: RegisterRequestDTO) -> AuthorizedResponseDTO: + async with self.transaction_manager: + user = await self.user_repository.get_by_username_or_email( + Username(request.username), + Email(request.email), + ) + if user is not None: + raise UserAlreadyExistsError( + "User with this username or email already exists", + [ + {"key": "email", "value": request.email}, + {"key": "username", "value": request.username}, + ], + ) + + user = User.new( + UserId(uuid4()), + username=request.username, + email=request.email, + last_name=request.last_name, + first_name=request.first_name, + bio=request.bio, + ) + + current_user_ip = self.identity_provider.get_current_ip() + + authorization_service_response = ( + self.authorization_service.generate_authorization_stuff( + user, + request.password, + current_user_ip, + ) + ) + + await self.user_repository.create(user) + await self.authorization_repository.create( + authorization_service_response.authorization + ) + await self.session_repository.create(authorization_service_response.session) + + return AuthorizedResponseDTO( + access_token=authorization_service_response.session.access_token, + refresh_token=authorization_service_response.refresh_token, + expires_in=authorization_service_response.expires_in, + ) From b470cc1bebe500723dec7f3dc03aaad441c20722 Mon Sep 17 00:00:00 2001 From: Davlat Davydov Date: Wed, 19 Aug 2026 17:34:59 +0300 Subject: [PATCH 04/13] feat(application): refresh token interactor --- src/conode/application/auth/refresh_token.py | 45 ++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/conode/application/auth/refresh_token.py diff --git a/src/conode/application/auth/refresh_token.py b/src/conode/application/auth/refresh_token.py new file mode 100644 index 0000000..3efffc3 --- /dev/null +++ b/src/conode/application/auth/refresh_token.py @@ -0,0 +1,45 @@ +from dataclasses import dataclass + +from conode.application.auth.shared import AuthorizedResponseDTO +from conode.application.interfaces.identity_provider import IdentityProvider +from conode.application.interfaces.repositories import SessionRepository +from conode.application.interfaces.token_managers import ( + AccessTokenManager, + RefreshTokenManager, +) +from conode.application.interfaces.transaction_manager import TransactionManager +from conode.application.services import AccessControlService, AuthorizationService + + +@dataclass +class RefreshTokenInteractor: + identity_provider: IdentityProvider + session_repository: SessionRepository + transaction_manager: TransactionManager + refresh_token_manager: RefreshTokenManager + access_token_manager: AccessTokenManager + access_control_service: AccessControlService + authorization_service: AuthorizationService + + async def execute(self) -> AuthorizedResponseDTO: + async with self.transaction_manager: + current_refresh_token = self.identity_provider.get_current_refresh_token() + + user = await self.access_control_service.get_authorized_user() + session = await self.session_repository.get_by_refresh_token( + current_refresh_token + ) + + access_token, expires_in = self.access_token_manager.encode(user) + refresh_token = self.refresh_token_manager.encode() + + session.set_access_token(access_token) + session.set_refresh_token(refresh_token) + + await self.session_repository.update(session) + + return AuthorizedResponseDTO( + access_token=access_token, + refresh_token=refresh_token, + expires_in=expires_in, + ) From 8882c85304f164075e22614847b755f8e9872e01 Mon Sep 17 00:00:00 2001 From: Davlat Davydov Date: Wed, 19 Aug 2026 17:35:18 +0300 Subject: [PATCH 05/13] feat(application): custom A/A flow --- src/conode/application/auth/__init__.py | 11 +++++++++++ src/conode/application/auth/shared.py | 8 ++++++++ 2 files changed, 19 insertions(+) create mode 100644 src/conode/application/auth/__init__.py create mode 100644 src/conode/application/auth/shared.py diff --git a/src/conode/application/auth/__init__.py b/src/conode/application/auth/__init__.py new file mode 100644 index 0000000..b010e6e --- /dev/null +++ b/src/conode/application/auth/__init__.py @@ -0,0 +1,11 @@ +from .login import LoginInteractor, LoginRequestDTO +from .register import RegisterInteractor, RegisterRequestDTO +from .shared import AuthorizedResponseDTO + +__all__ = ( + "AuthorizedResponseDTO", + "LoginInteractor", + "LoginRequestDTO", + "RegisterInteractor", + "RegisterRequestDTO", +) diff --git a/src/conode/application/auth/shared.py b/src/conode/application/auth/shared.py new file mode 100644 index 0000000..0002dff --- /dev/null +++ b/src/conode/application/auth/shared.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True, kw_only=True) +class AuthorizedResponseDTO: + access_token: str + refresh_token: str + expires_in: int From 74a22a04d3837c82475ba64523efb402d4d36513 Mon Sep 17 00:00:00 2001 From: Davlat Davydov Date: Wed, 19 Aug 2026 17:35:39 +0300 Subject: [PATCH 06/13] feat(application): session & authorization repositories --- .../application/interfaces/repositories/__init__.py | 3 +++ .../interfaces/repositories/auth/__init__.py | 7 +++++++ .../interfaces/repositories/auth/authorization.py | 9 +++++++++ .../interfaces/repositories/auth/session.py | 11 +++++++++++ 4 files changed, 30 insertions(+) create mode 100644 src/conode/application/interfaces/repositories/auth/__init__.py create mode 100644 src/conode/application/interfaces/repositories/auth/authorization.py create mode 100644 src/conode/application/interfaces/repositories/auth/session.py diff --git a/src/conode/application/interfaces/repositories/__init__.py b/src/conode/application/interfaces/repositories/__init__.py index cb20e83..86cd1d4 100644 --- a/src/conode/application/interfaces/repositories/__init__.py +++ b/src/conode/application/interfaces/repositories/__init__.py @@ -1,3 +1,4 @@ +from .auth import AuthorizationRepository, SessionRepository from .company import CompanyRepository from .context import ContextRepository from .contract import ContractRepository @@ -16,6 +17,7 @@ from .user import UserRepository __all__ = ( + "AuthorizationRepository", "CompanyRepository", "ContextRepository", "ContractRepository", @@ -29,6 +31,7 @@ "OfferRepository", "RolePermissionsRepository", "RoleRepository", + "SessionRepository", "UserGrantRepository", "UserRepository", ) diff --git a/src/conode/application/interfaces/repositories/auth/__init__.py b/src/conode/application/interfaces/repositories/auth/__init__.py new file mode 100644 index 0000000..52ff945 --- /dev/null +++ b/src/conode/application/interfaces/repositories/auth/__init__.py @@ -0,0 +1,7 @@ +from .authorization import AuthorizationRepository +from .session import SessionRepository + +__all__ = ( + "AuthorizationRepository", + "SessionRepository", +) diff --git a/src/conode/application/interfaces/repositories/auth/authorization.py b/src/conode/application/interfaces/repositories/auth/authorization.py new file mode 100644 index 0000000..ccae6d3 --- /dev/null +++ b/src/conode/application/interfaces/repositories/auth/authorization.py @@ -0,0 +1,9 @@ +from typing import Protocol + +from conode.domain.auth import Authorization +from conode.domain.user import User + + +class AuthorizationRepository(Protocol): + async def create(self, authorization: Authorization) -> None: ... + async def get_by_user(self, user: User) -> Authorization: ... diff --git a/src/conode/application/interfaces/repositories/auth/session.py b/src/conode/application/interfaces/repositories/auth/session.py new file mode 100644 index 0000000..04cd088 --- /dev/null +++ b/src/conode/application/interfaces/repositories/auth/session.py @@ -0,0 +1,11 @@ +from typing import Protocol + +from conode.domain.auth import Session +from conode.domain.user import User + + +class SessionRepository(Protocol): + async def create(self, session: Session) -> None: ... + async def update(self, session: Session) -> None: ... + async def get_by_ip_and_user(self, user: User, ip: str) -> Session | None: ... + async def get_by_refresh_token(self, token: str) -> Session: ... From 48af91d896ebb9984cd673911046e485a7f0c9fb Mon Sep 17 00:00:00 2001 From: Davlat Davydov Date: Wed, 19 Aug 2026 17:36:02 +0300 Subject: [PATCH 07/13] feat(application): access/refresh token managers --- .../interfaces/token_managers/__init__.py | 13 +++++++++++++ .../interfaces/token_managers/access.py | 18 ++++++++++++++++++ .../interfaces/token_managers/refresh.py | 5 +++++ 3 files changed, 36 insertions(+) create mode 100644 src/conode/application/interfaces/token_managers/__init__.py create mode 100644 src/conode/application/interfaces/token_managers/access.py create mode 100644 src/conode/application/interfaces/token_managers/refresh.py diff --git a/src/conode/application/interfaces/token_managers/__init__.py b/src/conode/application/interfaces/token_managers/__init__.py new file mode 100644 index 0000000..9437e0e --- /dev/null +++ b/src/conode/application/interfaces/token_managers/__init__.py @@ -0,0 +1,13 @@ +from .access import ( + AccessTokenManager, + AccessTokenManagerResponse, + UserMeta, +) +from .refresh import RefreshTokenManager + +__all__ = ( + "AccessTokenManager", + "AccessTokenManagerResponse", + "RefreshTokenManager", + "UserMeta", +) diff --git a/src/conode/application/interfaces/token_managers/access.py b/src/conode/application/interfaces/token_managers/access.py new file mode 100644 index 0000000..f81a599 --- /dev/null +++ b/src/conode/application/interfaces/token_managers/access.py @@ -0,0 +1,18 @@ +from typing import NamedTuple, Protocol, TypedDict + +from conode.domain.user import User, UserSystemRole + + +class UserMeta(TypedDict): + system_role: UserSystemRole + email: str + + +class AccessTokenManagerResponse(NamedTuple): + token: str + expires_in: int + + +class AccessTokenManager(Protocol): + def decode(self, token: str) -> UserMeta: ... + def encode(self, user: User) -> AccessTokenManagerResponse: ... diff --git a/src/conode/application/interfaces/token_managers/refresh.py b/src/conode/application/interfaces/token_managers/refresh.py new file mode 100644 index 0000000..78ddc8d --- /dev/null +++ b/src/conode/application/interfaces/token_managers/refresh.py @@ -0,0 +1,5 @@ +from typing import Protocol + + +class RefreshTokenManager(Protocol): + def encode(self) -> str: ... From 8ab1f346feff3aef8ccb46d49fbae09767dfbeeb Mon Sep 17 00:00:00 2001 From: Davlat Davydov Date: Wed, 19 Aug 2026 17:36:18 +0300 Subject: [PATCH 08/13] feat(application): custom A/A flow --- .../interfaces/identity_provider.py | 3 ++- .../application/interfaces/password_hasher.py | 4 +++- .../interfaces/token_manager/__init__.py | 11 ---------- .../interfaces/token_manager/token_manager.py | 22 ------------------- 4 files changed, 5 insertions(+), 35 deletions(-) delete mode 100644 src/conode/application/interfaces/token_manager/__init__.py delete mode 100644 src/conode/application/interfaces/token_manager/token_manager.py diff --git a/src/conode/application/interfaces/identity_provider.py b/src/conode/application/interfaces/identity_provider.py index a3e986a..91364ad 100644 --- a/src/conode/application/interfaces/identity_provider.py +++ b/src/conode/application/interfaces/identity_provider.py @@ -1,8 +1,9 @@ from typing import Protocol -from conode.application.interfaces.token_manager import UserMeta +from conode.application.interfaces.token_managers import UserMeta class IdentityProvider(Protocol): def get_current_ip(self) -> str: ... + def get_current_refresh_token(self) -> str: ... def get_current_user_meta(self) -> UserMeta: ... diff --git a/src/conode/application/interfaces/password_hasher.py b/src/conode/application/interfaces/password_hasher.py index 68c5dd3..11ac835 100644 --- a/src/conode/application/interfaces/password_hasher.py +++ b/src/conode/application/interfaces/password_hasher.py @@ -1,6 +1,8 @@ from typing import Protocol +from conode.domain.auth import HashedPassword + class PasswordHasher(Protocol): def hash(self, password: str) -> str: ... - def verify(self, hashed_password: str, password: str) -> bool: ... + def verify(self, hashed_password: HashedPassword, password: str) -> bool: ... diff --git a/src/conode/application/interfaces/token_manager/__init__.py b/src/conode/application/interfaces/token_manager/__init__.py deleted file mode 100644 index 49ee8bb..0000000 --- a/src/conode/application/interfaces/token_manager/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -from .token_manager import ( - TokenManager, - TokenManagerResponse, - UserMeta, -) - -__all__ = ( - "TokenManager", - "TokenManagerResponse", - "UserMeta", -) diff --git a/src/conode/application/interfaces/token_manager/token_manager.py b/src/conode/application/interfaces/token_manager/token_manager.py deleted file mode 100644 index 7121a9d..0000000 --- a/src/conode/application/interfaces/token_manager/token_manager.py +++ /dev/null @@ -1,22 +0,0 @@ -from typing import NamedTuple, Protocol, TypedDict - -from conode.domain.user import User, UserSystemRole - - -class UserMeta(TypedDict): - system_role: UserSystemRole - first_name: str - last_name: str - email: str - username: str - email_verified: bool - - -class TokenManagerResponse(NamedTuple): - token: str - expires_in: int - - -class TokenManager(Protocol): - def decode(self, token: str) -> UserMeta: ... - def encode(self, user: User) -> str: ... From 805876335e2d9c3dd79af170e8ef6cdbe96e9e2f Mon Sep 17 00:00:00 2001 From: Davlat Davydov Date: Wed, 19 Aug 2026 17:37:09 +0300 Subject: [PATCH 09/13] feat(application): authorization service --- deploy/keycloak/keycloak.env | 8 - deploy/keycloak/keycloak.json | 3099 ----------------- deploy/oauth_proxy.toml | 25 - deploy/persistence/init.sql | 27 - src/conode/application/errors.py | 3 + src/conode/application/services/__init__.py | 3 + .../application/services/access_control.py | 26 +- .../application/services/authorization.py | 44 + src/conode/bootstrap/di/container.py | 2 - src/conode/bootstrap/di/providers/__init__.py | 2 - .../bootstrap/di/providers/application.py | 5 + .../bootstrap/di/providers/infrastructure.py | 10 + src/conode/bootstrap/di/providers/secrets.py | 10 - src/conode/domain/auth/__init__.py | 4 + .../domain/auth/authorization/__init__.py | 3 + .../domain/auth/authorization/errors.py | 7 + src/conode/domain/auth/authorization/model.py | 50 + src/conode/domain/auth/session/__init__.py | 3 + src/conode/domain/auth/session/model.py | 43 + src/conode/domain/company/model.py | 2 +- src/conode/domain/context/model.py | 2 +- src/conode/domain/group/model.py | 2 +- src/conode/domain/node/model.py | 4 +- src/conode/domain/offer/model.py | 2 +- src/conode/domain/role/model.py | 2 +- src/conode/domain/user/model.py | 1 + src/conode/infrastructure/config.py | 19 +- .../infrastructure/identity_provider.py | 11 +- src/conode/infrastructure/password_hasher.py | 5 +- .../b5793b879b16_restore_auth_models.py | 50 + .../be8366314bd6_object_with_validation.py | 32 + ...a9b4_refresh_token_and_unique_constrait.py | 36 + .../infrastructure/persistence/registry.py | 28 + .../infrastructure/persistence/types.py | 7 + .../infrastructure/repositories/__init__.py | 3 + .../repositories/auth/__init__.py | 7 + .../repositories/auth/authorization.py | 53 + .../repositories/auth/session.py | 86 + .../infrastructure/repositories/company.py | 1 + src/conode/infrastructure/token_manager.py | 40 - .../infrastructure/token_managers/__init__.py | 7 + .../infrastructure/token_managers/access.py | 48 + .../infrastructure/token_managers/refresh.py | 10 + src/conode/presentation/common.py | 2 + src/conode/presentation/schemas/auth.py | 67 + src/conode/presentation/views/__init__.py | 2 + src/conode/presentation/views/auth.py | 56 + tests/conftest.py | 64 +- tests/e2e/user/test_verified_email.py | 29 - tests/factories/common.py | 2 +- tests/factories/models/user.py | 37 +- 51 files changed, 757 insertions(+), 3334 deletions(-) delete mode 100644 deploy/keycloak/keycloak.env delete mode 100644 deploy/keycloak/keycloak.json delete mode 100644 deploy/oauth_proxy.toml delete mode 100644 deploy/persistence/init.sql create mode 100644 src/conode/application/services/authorization.py delete mode 100644 src/conode/bootstrap/di/providers/secrets.py create mode 100644 src/conode/domain/auth/__init__.py create mode 100644 src/conode/domain/auth/authorization/__init__.py create mode 100644 src/conode/domain/auth/authorization/errors.py create mode 100644 src/conode/domain/auth/authorization/model.py create mode 100644 src/conode/domain/auth/session/__init__.py create mode 100644 src/conode/domain/auth/session/model.py create mode 100644 src/conode/infrastructure/persistence/migrations/versions/b5793b879b16_restore_auth_models.py create mode 100644 src/conode/infrastructure/persistence/migrations/versions/be8366314bd6_object_with_validation.py create mode 100644 src/conode/infrastructure/persistence/migrations/versions/bf7551bea9b4_refresh_token_and_unique_constrait.py create mode 100644 src/conode/infrastructure/repositories/auth/__init__.py create mode 100644 src/conode/infrastructure/repositories/auth/authorization.py create mode 100644 src/conode/infrastructure/repositories/auth/session.py delete mode 100644 src/conode/infrastructure/token_manager.py create mode 100644 src/conode/infrastructure/token_managers/__init__.py create mode 100644 src/conode/infrastructure/token_managers/access.py create mode 100644 src/conode/infrastructure/token_managers/refresh.py create mode 100644 src/conode/presentation/schemas/auth.py create mode 100644 src/conode/presentation/views/auth.py delete mode 100644 tests/e2e/user/test_verified_email.py diff --git a/deploy/keycloak/keycloak.env b/deploy/keycloak/keycloak.env deleted file mode 100644 index 0405a4e..0000000 --- a/deploy/keycloak/keycloak.env +++ /dev/null @@ -1,8 +0,0 @@ -KC_DB=postgres -KC_DB_USERNAME=keycloak -KC_DB_URL_HOST=postgres -KC_DB_URL_PORT=5432 -KC_BOOTSTRAP_ADMIN_USERNAME=keycloak -KC_BOOTSTRAP_ADMIN_PASSWORD=keycloakPassword -KC_DB_PASSWORD=keycloakPassword -KC_HEALTH_ENABLED=true \ No newline at end of file diff --git a/deploy/keycloak/keycloak.json b/deploy/keycloak/keycloak.json deleted file mode 100644 index be218da..0000000 --- a/deploy/keycloak/keycloak.json +++ /dev/null @@ -1,3099 +0,0 @@ -{ - "id": "ec41a80a-173f-4609-8a8d-b1fac4205041", - "realm": "conode", - "notBefore": 0, - "defaultSignatureAlgorithm": "RS256", - "revokeRefreshToken": false, - "refreshTokenMaxReuse": 0, - "accessTokenLifespan": 300, - "accessTokenLifespanForImplicitFlow": 900, - "ssoSessionIdleTimeout": 1800, - "ssoSessionMaxLifespan": 36000, - "ssoSessionIdleTimeoutRememberMe": 0, - "ssoSessionMaxLifespanRememberMe": 0, - "offlineSessionIdleTimeout": 2592000, - "offlineSessionMaxLifespanEnabled": false, - "offlineSessionMaxLifespan": 5184000, - "clientSessionIdleTimeout": 0, - "clientSessionMaxLifespan": 0, - "clientOfflineSessionIdleTimeout": 0, - "clientOfflineSessionMaxLifespan": 0, - "accessCodeLifespan": 60, - "accessCodeLifespanUserAction": 300, - "accessCodeLifespanLogin": 1800, - "actionTokenGeneratedByAdminLifespan": 43200, - "actionTokenGeneratedByUserLifespan": 300, - "oauth2DeviceCodeLifespan": 600, - "oauth2DevicePollingInterval": 5, - "enabled": true, - "sslRequired": "external", - "registrationAllowed": true, - "registrationEmailAsUsername": true, - "rememberMe": true, - "verifyEmail": false, - "loginWithEmailAllowed": true, - "duplicateEmailsAllowed": false, - "resetPasswordAllowed": false, - "editUsernameAllowed": false, - "bruteForceProtected": false, - "permanentLockout": false, - "maxTemporaryLockouts": 0, - "bruteForceStrategy": "MULTIPLE", - "maxFailureWaitSeconds": 900, - "minimumQuickLoginWaitSeconds": 60, - "waitIncrementSeconds": 60, - "quickLoginCheckMilliSeconds": 1000, - "maxDeltaTimeSeconds": 43200, - "failureFactor": 30, - "maxSecondaryAuthFailures": 0, - "roles": { - "realm": [ - { - "id": "49a995af-a1d7-42ab-8a43-966a78ef4331", - "name": "uma_authorization", - "description": "${role_uma_authorization}", - "composite": false, - "clientRole": false, - "containerId": "ec41a80a-173f-4609-8a8d-b1fac4205041", - "attributes": {} - }, - { - "id": "ddce9f6a-35d1-410d-8024-f256234f531a", - "name": "admin", - "description": "", - "composite": false, - "clientRole": false, - "containerId": "ec41a80a-173f-4609-8a8d-b1fac4205041", - "attributes": {} - }, - { - "id": "2bebf11f-cdf1-4aff-b428-40430e728bd4", - "name": "default-roles-conode", - "description": "${role_default-roles}", - "composite": true, - "composites": { - "realm": [ - "offline_access", - "uma_authorization" - ], - "client": { - "realm-management": [ - "view-realm", - "query-users", - "impersonation", - "query-organizations", - "query-clients", - "view-clients", - "view-events", - "view-users", - "query-groups", - "view-authorization", - "query-realms" - ], - "broker": [ - "read-token" - ], - "account": [ - "delete-account", - "view-groups", - "view-applications", - "manage-account-links", - "view-consent", - "manage-consent", - "view-profile", - "manage-account" - ] - } - }, - "clientRole": false, - "containerId": "ec41a80a-173f-4609-8a8d-b1fac4205041", - "attributes": {} - }, - { - "id": "e1b0dbeb-0764-45fc-956a-a12aebaf0f14", - "name": "offline_access", - "description": "${role_offline-access}", - "composite": false, - "clientRole": false, - "containerId": "ec41a80a-173f-4609-8a8d-b1fac4205041", - "attributes": {} - } - ], - "client": { - "conode": [ - { - "id": "1f2189b1-ec0e-4a51-a496-5ccf64330044", - "name": "uma_protection", - "composite": false, - "clientRole": true, - "containerId": "4a49a878-bf46-409c-881c-16896bdef462", - "attributes": {} - } - ], - "realm-management": [ - { - "id": "13d780d4-44c5-433d-b26d-719ef5fb695f", - "name": "manage-realm", - "description": "${role_manage-realm}", - "composite": false, - "clientRole": true, - "containerId": "e17b8210-42bb-4c4e-9de1-cf71c737dee9", - "attributes": {} - }, - { - "id": "90dca780-0937-40f3-a533-12abf29e90c9", - "name": "manage-identity-providers", - "description": "${role_manage-identity-providers}", - "composite": false, - "clientRole": true, - "containerId": "e17b8210-42bb-4c4e-9de1-cf71c737dee9", - "attributes": {} - }, - { - "id": "0c867e97-e15c-4a34-b012-f07e8b996564", - "name": "query-users", - "description": "${role_query-users}", - "composite": false, - "clientRole": true, - "containerId": "e17b8210-42bb-4c4e-9de1-cf71c737dee9", - "attributes": {} - }, - { - "id": "91c01681-77c9-4b12-ada0-8b3c4d104d16", - "name": "view-realm", - "description": "${role_view-realm}", - "composite": false, - "clientRole": true, - "containerId": "e17b8210-42bb-4c4e-9de1-cf71c737dee9", - "attributes": {} - }, - { - "id": "e315a1f6-4ac0-4d06-90e5-31616801ec9e", - "name": "create-client", - "description": "${role_create-client}", - "composite": false, - "clientRole": true, - "containerId": "e17b8210-42bb-4c4e-9de1-cf71c737dee9", - "attributes": {} - }, - { - "id": "f086cb99-afdc-4724-af7d-e9d3ff5e6818", - "name": "manage-events", - "description": "${role_manage-events}", - "composite": false, - "clientRole": true, - "containerId": "e17b8210-42bb-4c4e-9de1-cf71c737dee9", - "attributes": {} - }, - { - "id": "67209571-2887-486f-8cba-146fb12ddd10", - "name": "impersonation", - "description": "${role_impersonation}", - "composite": false, - "clientRole": true, - "containerId": "e17b8210-42bb-4c4e-9de1-cf71c737dee9", - "attributes": {} - }, - { - "id": "635a48ad-43d7-4b4a-b6a3-1c747297fc3b", - "name": "view-identity-providers", - "description": "${role_view-identity-providers}", - "composite": false, - "clientRole": true, - "containerId": "e17b8210-42bb-4c4e-9de1-cf71c737dee9", - "attributes": {} - }, - { - "id": "b883cdae-9702-4df2-a8bf-d48302462a92", - "name": "query-organizations", - "description": "${role_query-organizations}", - "composite": false, - "clientRole": true, - "containerId": "e17b8210-42bb-4c4e-9de1-cf71c737dee9", - "attributes": {} - }, - { - "id": "e5a35f08-7dc7-437d-8635-dff0a0cdbdc9", - "name": "manage-authorization", - "description": "${role_manage-authorization}", - "composite": false, - "clientRole": true, - "containerId": "e17b8210-42bb-4c4e-9de1-cf71c737dee9", - "attributes": {} - }, - { - "id": "26429b17-4e42-4d7c-a35b-1ec74951ec41", - "name": "realm-admin", - "description": "${role_realm-admin}", - "composite": true, - "composites": { - "client": { - "realm-management": [ - "manage-realm", - "view-realm", - "manage-identity-providers", - "query-users", - "create-client", - "manage-events", - "view-identity-providers", - "impersonation", - "query-organizations", - "manage-authorization", - "manage-users", - "query-clients", - "view-clients", - "manage-organizations", - "publish-events", - "view-events", - "view-users", - "query-groups", - "view-organizations", - "view-authorization", - "manage-clients", - "query-realms" - ] - } - }, - "clientRole": true, - "containerId": "e17b8210-42bb-4c4e-9de1-cf71c737dee9", - "attributes": {} - }, - { - "id": "3b908b30-b228-472a-8393-65777261f8a1", - "name": "manage-users", - "description": "${role_manage-users}", - "composite": false, - "clientRole": true, - "containerId": "e17b8210-42bb-4c4e-9de1-cf71c737dee9", - "attributes": {} - }, - { - "id": "de68d8b9-f696-4862-87a2-fab2cdf4e88b", - "name": "query-clients", - "description": "${role_query-clients}", - "composite": false, - "clientRole": true, - "containerId": "e17b8210-42bb-4c4e-9de1-cf71c737dee9", - "attributes": {} - }, - { - "id": "11306d4c-6f0b-4d6f-a3ed-b3c82997678e", - "name": "view-clients", - "description": "${role_view-clients}", - "composite": true, - "composites": { - "client": { - "realm-management": [ - "query-clients" - ] - } - }, - "clientRole": true, - "containerId": "e17b8210-42bb-4c4e-9de1-cf71c737dee9", - "attributes": {} - }, - { - "id": "b6519e23-766c-4351-b1ac-a4a2e3eb0f21", - "name": "manage-organizations", - "description": "${role_manage-organizations}", - "composite": false, - "clientRole": true, - "containerId": "e17b8210-42bb-4c4e-9de1-cf71c737dee9", - "attributes": {} - }, - { - "id": "a2413153-b6ec-472b-9daa-57d90bcbf0c5", - "name": "publish-events", - "description": "${role_publish-events}", - "composite": false, - "clientRole": true, - "containerId": "e17b8210-42bb-4c4e-9de1-cf71c737dee9", - "attributes": {} - }, - { - "id": "71fa910a-68c0-4aa6-b24e-fa08b1647257", - "name": "view-events", - "description": "${role_view-events}", - "composite": false, - "clientRole": true, - "containerId": "e17b8210-42bb-4c4e-9de1-cf71c737dee9", - "attributes": {} - }, - { - "id": "e1092238-6117-4b18-81b9-3d147478c3af", - "name": "view-users", - "description": "${role_view-users}", - "composite": true, - "composites": { - "client": { - "realm-management": [ - "query-users", - "query-groups" - ] - } - }, - "clientRole": true, - "containerId": "e17b8210-42bb-4c4e-9de1-cf71c737dee9", - "attributes": {} - }, - { - "id": "da967960-8b1c-4aa4-bad6-a9575b285263", - "name": "query-groups", - "description": "${role_query-groups}", - "composite": false, - "clientRole": true, - "containerId": "e17b8210-42bb-4c4e-9de1-cf71c737dee9", - "attributes": {} - }, - { - "id": "5319760f-a94e-47e6-8a64-bd3be72b5e9f", - "name": "view-organizations", - "description": "${role_view-organizations}", - "composite": true, - "composites": { - "client": { - "realm-management": [ - "query-organizations" - ] - } - }, - "clientRole": true, - "containerId": "e17b8210-42bb-4c4e-9de1-cf71c737dee9", - "attributes": {} - }, - { - "id": "51552ffa-14c0-46d1-9fdc-0dba0687e2bc", - "name": "manage-clients", - "description": "${role_manage-clients}", - "composite": false, - "clientRole": true, - "containerId": "e17b8210-42bb-4c4e-9de1-cf71c737dee9", - "attributes": {} - }, - { - "id": "f4bb4418-7109-4d9b-b681-6130819d30d7", - "name": "view-authorization", - "description": "${role_view-authorization}", - "composite": false, - "clientRole": true, - "containerId": "e17b8210-42bb-4c4e-9de1-cf71c737dee9", - "attributes": {} - }, - { - "id": "c517c084-2b9e-4787-9946-afc1d039b3a9", - "name": "create-organization", - "description": "${role_create-organization}", - "composite": false, - "clientRole": true, - "containerId": "e17b8210-42bb-4c4e-9de1-cf71c737dee9", - "attributes": {} - }, - { - "id": "07726516-72a9-4397-9c96-c257bc763963", - "name": "query-realms", - "description": "${role_query-realms}", - "composite": false, - "clientRole": true, - "containerId": "e17b8210-42bb-4c4e-9de1-cf71c737dee9", - "attributes": {} - } - ], - "security-admin-console": [], - "admin-cli": [], - "account-console": [], - "broker": [ - { - "id": "2b6ee18a-5dd5-43f4-8c2d-1209a645dae4", - "name": "read-token", - "description": "${role_read-token}", - "composite": false, - "clientRole": true, - "containerId": "7904be1a-7d78-4b10-ac95-486e25689078", - "attributes": {} - } - ], - "account": [ - { - "id": "d1ba3ac8-71fc-4ce6-a8dc-f0fc8b709fe0", - "name": "delete-account", - "description": "${role_delete-account}", - "composite": false, - "clientRole": true, - "containerId": "75942b65-13ab-4635-9368-bdc005bd6bfa", - "attributes": {} - }, - { - "id": "a7929188-ce65-40b3-9ca1-05bc39659e81", - "name": "manage-consent", - "description": "${role_manage-consent}", - "composite": true, - "composites": { - "client": { - "account": [ - "view-consent" - ] - } - }, - "clientRole": true, - "containerId": "75942b65-13ab-4635-9368-bdc005bd6bfa", - "attributes": {} - }, - { - "id": "b737ec04-5145-434a-a952-67e277e5dfc2", - "name": "view-groups", - "description": "${role_view-groups}", - "composite": false, - "clientRole": true, - "containerId": "75942b65-13ab-4635-9368-bdc005bd6bfa", - "attributes": {} - }, - { - "id": "29dd0736-9496-4eb4-9046-2a85ddfeced5", - "name": "view-applications", - "description": "${role_view-applications}", - "composite": false, - "clientRole": true, - "containerId": "75942b65-13ab-4635-9368-bdc005bd6bfa", - "attributes": {} - }, - { - "id": "e0723b7a-8ead-4332-ba22-dd048278b678", - "name": "manage-account-links", - "description": "${role_manage-account-links}", - "composite": false, - "clientRole": true, - "containerId": "75942b65-13ab-4635-9368-bdc005bd6bfa", - "attributes": {} - }, - { - "id": "f6b3caf7-b6b6-452b-8466-e5c3cebe475e", - "name": "view-consent", - "description": "${role_view-consent}", - "composite": false, - "clientRole": true, - "containerId": "75942b65-13ab-4635-9368-bdc005bd6bfa", - "attributes": {} - }, - { - "id": "f874a5b8-3b8d-493d-a347-09ddb1a7658a", - "name": "manage-account", - "description": "${role_manage-account}", - "composite": true, - "composites": { - "client": { - "account": [ - "manage-account-links" - ] - } - }, - "clientRole": true, - "containerId": "75942b65-13ab-4635-9368-bdc005bd6bfa", - "attributes": {} - }, - { - "id": "c79656f9-016f-4a7f-a068-e4033cdabb0f", - "name": "view-profile", - "description": "${role_view-profile}", - "composite": false, - "clientRole": true, - "containerId": "75942b65-13ab-4635-9368-bdc005bd6bfa", - "attributes": {} - } - ] - } - }, - "groups": [], - "defaultRole": { - "id": "2bebf11f-cdf1-4aff-b428-40430e728bd4", - "name": "default-roles-conode", - "description": "${role_default-roles}", - "composite": true, - "clientRole": false, - "containerId": "ec41a80a-173f-4609-8a8d-b1fac4205041" - }, - "requiredCredentials": [ - "password" - ], - "otpPolicyType": "totp", - "otpPolicyAlgorithm": "HmacSHA1", - "otpPolicyInitialCounter": 0, - "otpPolicyDigits": 6, - "otpPolicyLookAheadWindow": 1, - "otpPolicyPeriod": 30, - "otpPolicyCodeReusable": false, - "otpSupportedApplications": [ - "totpAppFreeOTPName", - "totpAppGoogleName", - "totpAppMicrosoftAuthenticatorName" - ], - "localizationTexts": {}, - "webAuthnPolicyRpEntityName": "keycloak", - "webAuthnPolicySignatureAlgorithms": [ - "ES256", - "RS256" - ], - "webAuthnPolicyRpId": "", - "webAuthnPolicyAttestationConveyancePreference": "not specified", - "webAuthnPolicyAuthenticatorAttachment": "not specified", - "webAuthnPolicyRequireResidentKey": "not specified", - "webAuthnPolicyResidentKey": "not specified", - "webAuthnPolicyUserVerificationRequirement": "not specified", - "webAuthnPolicyCreateTimeout": 0, - "webAuthnPolicyAvoidSameAuthenticatorRegister": false, - "webAuthnPolicyAcceptableAaguids": [], - "webAuthnPolicyExtraOrigins": [], - "webAuthnPolicyPasswordlessRpEntityName": "keycloak", - "webAuthnPolicyPasswordlessSignatureAlgorithms": [ - "ES256", - "RS256" - ], - "webAuthnPolicyPasswordlessRpId": "", - "webAuthnPolicyPasswordlessAttestationConveyancePreference": "not specified", - "webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified", - "webAuthnPolicyPasswordlessRequireResidentKey": "not specified", - "webAuthnPolicyPasswordlessResidentKey": "required", - "webAuthnPolicyPasswordlessUserVerificationRequirement": "required", - "webAuthnPolicyPasswordlessCreateTimeout": 0, - "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": false, - "webAuthnPolicyPasswordlessAcceptableAaguids": [], - "webAuthnPolicyPasswordlessExtraOrigins": [], - "users": [ - { - "id": "2378b129-4f28-4cd9-bc25-24832d3c8b4f", - "username": "service-account-conode", - "emailVerified": false, - "enabled": true, - "createdTimestamp": 1784228290542, - "totp": false, - "serviceAccountClientId": "conode", - "disableableCredentialTypes": [], - "requiredActions": [], - "realmRoles": [ - "default-roles-conode" - ], - "clientRoles": { - "conode": [ - "uma_protection" - ] - }, - "notBefore": 0, - "groups": [] - } - ], - "scopeMappings": [ - { - "clientScope": "offline_access", - "roles": [ - "offline_access" - ] - } - ], - "clientScopeMappings": { - "account": [ - { - "client": "account-console", - "roles": [ - "manage-account", - "view-groups" - ] - } - ] - }, - "clients": [ - { - "id": "75942b65-13ab-4635-9368-bdc005bd6bfa", - "clientId": "account", - "name": "${client_account}", - "rootUrl": "${authBaseUrl}", - "baseUrl": "/realms/conode/account/", - "surrogateAuthRequired": false, - "enabled": true, - "alwaysDisplayInConsole": false, - "clientAuthenticatorType": "client-secret", - "redirectUris": [ - "/realms/conode/account/*" - ], - "webOrigins": [], - "notBefore": 0, - "bearerOnly": false, - "consentRequired": false, - "standardFlowEnabled": true, - "implicitFlowEnabled": false, - "directAccessGrantsEnabled": false, - "serviceAccountsEnabled": false, - "publicClient": true, - "frontchannelLogout": false, - "protocol": "openid-connect", - "attributes": { - "realm_client": "false", - "post.logout.redirect.uris": "+" - }, - "authenticationFlowBindingOverrides": {}, - "fullScopeAllowed": false, - "nodeReRegistrationTimeout": 0, - "defaultClientScopes": [ - "web-origins", - "acr", - "roles", - "profile", - "basic", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "organization", - "microprofile-jwt" - ] - }, - { - "id": "132eb0b9-145b-4141-92d9-c7595b7ae31c", - "clientId": "account-console", - "name": "${client_account-console}", - "rootUrl": "${authBaseUrl}", - "baseUrl": "/realms/conode/account/", - "surrogateAuthRequired": false, - "enabled": true, - "alwaysDisplayInConsole": false, - "clientAuthenticatorType": "client-secret", - "redirectUris": [ - "/realms/conode/account/*" - ], - "webOrigins": [], - "notBefore": 0, - "bearerOnly": false, - "consentRequired": false, - "standardFlowEnabled": true, - "implicitFlowEnabled": false, - "directAccessGrantsEnabled": false, - "serviceAccountsEnabled": false, - "publicClient": true, - "frontchannelLogout": false, - "protocol": "openid-connect", - "attributes": { - "realm_client": "false", - "post.logout.redirect.uris": "+", - "pkce.code.challenge.method": "S256" - }, - "authenticationFlowBindingOverrides": {}, - "fullScopeAllowed": false, - "nodeReRegistrationTimeout": 0, - "protocolMappers": [ - { - "id": "b87ac1f1-ca34-485d-a477-de0f5648315d", - "name": "audience resolve", - "protocol": "openid-connect", - "protocolMapper": "oidc-audience-resolve-mapper", - "consentRequired": false, - "config": {} - } - ], - "defaultClientScopes": [ - "web-origins", - "acr", - "roles", - "profile", - "basic", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "organization", - "microprofile-jwt" - ] - }, - { - "id": "e0bf2bae-7450-47d4-940c-a25a5d7a9088", - "clientId": "admin-cli", - "name": "${client_admin-cli}", - "surrogateAuthRequired": false, - "enabled": true, - "alwaysDisplayInConsole": false, - "clientAuthenticatorType": "client-secret", - "redirectUris": [], - "webOrigins": [], - "notBefore": 0, - "bearerOnly": false, - "consentRequired": false, - "standardFlowEnabled": false, - "implicitFlowEnabled": false, - "directAccessGrantsEnabled": true, - "serviceAccountsEnabled": false, - "publicClient": true, - "frontchannelLogout": false, - "protocol": "openid-connect", - "attributes": { - "realm_client": "false", - "client.use.lightweight.access.token.enabled": "true" - }, - "authenticationFlowBindingOverrides": {}, - "fullScopeAllowed": true, - "nodeReRegistrationTimeout": 0, - "defaultClientScopes": [ - "web-origins", - "acr", - "roles", - "profile", - "basic", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "organization", - "microprofile-jwt" - ] - }, - { - "id": "7904be1a-7d78-4b10-ac95-486e25689078", - "clientId": "broker", - "name": "${client_broker}", - "surrogateAuthRequired": false, - "enabled": true, - "alwaysDisplayInConsole": false, - "clientAuthenticatorType": "client-secret", - "redirectUris": [], - "webOrigins": [], - "notBefore": 0, - "bearerOnly": true, - "consentRequired": false, - "standardFlowEnabled": true, - "implicitFlowEnabled": false, - "directAccessGrantsEnabled": false, - "serviceAccountsEnabled": false, - "publicClient": false, - "frontchannelLogout": false, - "protocol": "openid-connect", - "attributes": { - "realm_client": "true" - }, - "authenticationFlowBindingOverrides": {}, - "fullScopeAllowed": false, - "nodeReRegistrationTimeout": 0, - "defaultClientScopes": [ - "web-origins", - "acr", - "roles", - "profile", - "basic", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "organization", - "microprofile-jwt" - ] - }, - { - "id": "4a49a878-bf46-409c-881c-16896bdef462", - "clientId": "conode", - "name": "Conode", - "description": "Easy-to-use API for building graphs with state contexts. ", - "rootUrl": "http://conode.com", - "adminUrl": "http://conode.com", - "baseUrl": "http://conode.com", - "surrogateAuthRequired": false, - "enabled": true, - "alwaysDisplayInConsole": false, - "clientAuthenticatorType": "client-secret", - "secret": "**********", - "redirectUris": [ - "http://api.conode.com/oauth2/callback" - ], - "webOrigins": [], - "notBefore": 0, - "bearerOnly": false, - "consentRequired": false, - "standardFlowEnabled": true, - "implicitFlowEnabled": false, - "directAccessGrantsEnabled": false, - "serviceAccountsEnabled": true, - "authorizationServicesEnabled": true, - "publicClient": false, - "frontchannelLogout": true, - "protocol": "openid-connect", - "attributes": { - "logout.confirmation.enabled": "false", - "client.secret.creation.time": "1784228290", - "oauth2.jwt.authorization.grant.enabled": "false", - "standard.token.exchange.enabled": "false", - "frontchannel.logout.session.required": "true", - "oauth2.device.authorization.grant.enabled": "false", - "use.jwks.url": "false", - "backchannel.logout.revoke.offline.tokens": "false", - "realm_client": "false", - "oidc.ciba.grant.enabled": "false", - "backchannel.logout.session.required": "true", - "display.on.consent.screen": "false", - "dpop.bound.access.tokens": "false" - }, - "authenticationFlowBindingOverrides": {}, - "fullScopeAllowed": true, - "nodeReRegistrationTimeout": -1, - "protocolMappers": [ - { - "id": "4ab86e46-3496-496f-8bbc-fdeff0c12a79", - "name": "aud-mapper", - "protocol": "openid-connect", - "protocolMapper": "oidc-audience-mapper", - "consentRequired": false, - "config": { - "included.client.audience": "conode", - "id.token.claim": "false", - "lightweight.claim": "false", - "access.token.claim": "true", - "introspection.token.claim": "true" - } - } - ], - "defaultClientScopes": [ - "service_account", - "web-origins", - "acr", - "roles", - "profile", - "basic", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "organization", - "microprofile-jwt" - ], - "authorizationSettings": { - "allowRemoteResourceManagement": true, - "policyEnforcementMode": "ENFORCING", - "resources": [], - "policies": [], - "scopes": [], - "decisionStrategy": "UNANIMOUS" - } - }, - { - "id": "e17b8210-42bb-4c4e-9de1-cf71c737dee9", - "clientId": "realm-management", - "name": "${client_realm-management}", - "surrogateAuthRequired": false, - "enabled": true, - "alwaysDisplayInConsole": false, - "clientAuthenticatorType": "client-secret", - "redirectUris": [], - "webOrigins": [], - "notBefore": 0, - "bearerOnly": true, - "consentRequired": false, - "standardFlowEnabled": true, - "implicitFlowEnabled": false, - "directAccessGrantsEnabled": false, - "serviceAccountsEnabled": false, - "publicClient": false, - "frontchannelLogout": false, - "protocol": "openid-connect", - "attributes": { - "realm_client": "true" - }, - "authenticationFlowBindingOverrides": {}, - "fullScopeAllowed": false, - "nodeReRegistrationTimeout": 0, - "defaultClientScopes": [ - "web-origins", - "acr", - "roles", - "profile", - "basic", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "organization", - "microprofile-jwt" - ] - }, - { - "id": "81c0c5f2-8a07-43f8-af8c-648586a203ef", - "clientId": "security-admin-console", - "name": "${client_security-admin-console}", - "rootUrl": "${authAdminUrl}", - "baseUrl": "/admin/conode/console/", - "surrogateAuthRequired": false, - "enabled": true, - "alwaysDisplayInConsole": false, - "clientAuthenticatorType": "client-secret", - "redirectUris": [ - "/admin/conode/console/*" - ], - "webOrigins": [ - "+" - ], - "notBefore": 0, - "bearerOnly": false, - "consentRequired": false, - "standardFlowEnabled": true, - "implicitFlowEnabled": false, - "directAccessGrantsEnabled": false, - "serviceAccountsEnabled": false, - "publicClient": true, - "frontchannelLogout": false, - "protocol": "openid-connect", - "attributes": { - "realm_client": "false", - "client.use.lightweight.access.token.enabled": "true", - "post.logout.redirect.uris": "+", - "pkce.code.challenge.method": "S256" - }, - "authenticationFlowBindingOverrides": {}, - "fullScopeAllowed": true, - "nodeReRegistrationTimeout": 0, - "protocolMappers": [ - { - "id": "531e9c28-1c10-455b-940e-911a0a0f6419", - "name": "locale", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "userinfo.token.claim": "true", - "user.attribute": "locale", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "locale", - "jsonType.label": "String" - } - } - ], - "defaultClientScopes": [ - "web-origins", - "acr", - "roles", - "profile", - "basic", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "organization", - "microprofile-jwt" - ] - } - ], - "clientScopes": [ - { - "id": "8bbf89b4-7236-4ff2-89a5-3728c2b38c4b", - "name": "service_account", - "description": "Specific scope for a client enabled for service accounts", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "false", - "display.on.consent.screen": "false" - }, - "protocolMappers": [ - { - "id": "c2165ab9-929b-4131-8f0f-6862683241e5", - "name": "Client ID", - "protocol": "openid-connect", - "protocolMapper": "oidc-usersessionmodel-note-mapper", - "consentRequired": false, - "config": { - "user.session.note": "client_id", - "id.token.claim": "true", - "introspection.token.claim": "true", - "access.token.claim": "true", - "claim.name": "client_id", - "jsonType.label": "String" - } - }, - { - "id": "45f94f95-1a50-470e-8fd7-c798e1e961fa", - "name": "Client Host", - "protocol": "openid-connect", - "protocolMapper": "oidc-usersessionmodel-note-mapper", - "consentRequired": false, - "config": { - "user.session.note": "clientHost", - "id.token.claim": "true", - "introspection.token.claim": "true", - "access.token.claim": "true", - "claim.name": "clientHost", - "jsonType.label": "String" - } - }, - { - "id": "fc1e2541-7286-4f4d-ada6-553be302a8e8", - "name": "Client IP Address", - "protocol": "openid-connect", - "protocolMapper": "oidc-usersessionmodel-note-mapper", - "consentRequired": false, - "config": { - "user.session.note": "clientAddress", - "id.token.claim": "true", - "introspection.token.claim": "true", - "access.token.claim": "true", - "claim.name": "clientAddress", - "jsonType.label": "String" - } - } - ] - }, - { - "id": "8a8108cf-eba7-46d6-a4ae-271a2c01388f", - "name": "organization", - "description": "Additional claims about the organization a subject belongs to", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "true", - "consent.screen.text": "${organizationScopeConsentText}", - "display.on.consent.screen": "true" - }, - "protocolMappers": [ - { - "id": "6465ac88-a49b-4a28-93ca-5eb8f07e2fbc", - "name": "organization", - "protocol": "openid-connect", - "protocolMapper": "oidc-organization-membership-mapper", - "consentRequired": false, - "config": { - "id.token.claim": "true", - "introspection.token.claim": "true", - "access.token.claim": "true", - "claim.name": "organization", - "jsonType.label": "String", - "multivalued": "true" - } - } - ] - }, - { - "id": "0acd2827-2a80-4226-92ab-72f86772aff2", - "name": "profile", - "description": "OpenID Connect built-in scope: profile", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "true", - "consent.screen.text": "${profileScopeConsentText}", - "display.on.consent.screen": "true" - }, - "protocolMappers": [ - { - "id": "6e8f2433-ee66-4d36-a7b3-1bcd0dcd9d29", - "name": "full name", - "protocol": "openid-connect", - "protocolMapper": "oidc-full-name-mapper", - "consentRequired": false, - "config": { - "id.token.claim": "true", - "introspection.token.claim": "true", - "access.token.claim": "true", - "userinfo.token.claim": "true" - } - }, - { - "id": "258353df-f076-4824-af06-416c48faefb5", - "name": "nickname", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "userinfo.token.claim": "true", - "user.attribute": "nickname", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "nickname", - "jsonType.label": "String" - } - }, - { - "id": "5fbfc1d0-03bf-4339-9cb7-af7ca7e73969", - "name": "picture", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "userinfo.token.claim": "true", - "user.attribute": "picture", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "picture", - "jsonType.label": "String" - } - }, - { - "id": "85448af4-c41c-42f6-ac45-58226850bb14", - "name": "website", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "userinfo.token.claim": "true", - "user.attribute": "website", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "website", - "jsonType.label": "String" - } - }, - { - "id": "436794f4-1168-45e8-85a9-51d690d424fb", - "name": "birthdate", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "userinfo.token.claim": "true", - "user.attribute": "birthdate", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "birthdate", - "jsonType.label": "String" - } - }, - { - "id": "42e3cd61-aed5-4a4d-9b8b-eebdad37a805", - "name": "updated at", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "userinfo.token.claim": "true", - "user.attribute": "updatedAt", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "updated_at", - "jsonType.label": "long" - } - }, - { - "id": "959a0468-8688-4349-b473-7ca1f962c22d", - "name": "gender", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "userinfo.token.claim": "true", - "user.attribute": "gender", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "gender", - "jsonType.label": "String" - } - }, - { - "id": "cb285e2b-ac4e-4905-8654-4b242a4f0bfd", - "name": "locale", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "userinfo.token.claim": "true", - "user.attribute": "locale", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "locale", - "jsonType.label": "String" - } - }, - { - "id": "281dd374-e131-460a-8efd-f448d563b883", - "name": "family name", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "userinfo.token.claim": "true", - "user.attribute": "lastName", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "family_name", - "jsonType.label": "String" - } - }, - { - "id": "fbb0888c-19e3-4b8d-ab2f-8ed61e664f1d", - "name": "profile", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "userinfo.token.claim": "true", - "user.attribute": "profile", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "profile", - "jsonType.label": "String" - } - }, - { - "id": "05b46a46-fde1-4e2e-ad4d-5aff6e803f25", - "name": "given name", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "userinfo.token.claim": "true", - "user.attribute": "firstName", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "given_name", - "jsonType.label": "String" - } - }, - { - "id": "76085687-cc5a-4a25-a8c6-063d67945dda", - "name": "middle name", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "userinfo.token.claim": "true", - "user.attribute": "middleName", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "middle_name", - "jsonType.label": "String" - } - }, - { - "id": "b9ef9e68-c26a-4d2a-90f9-e0b201340c37", - "name": "username", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "userinfo.token.claim": "true", - "user.attribute": "username", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "preferred_username", - "jsonType.label": "String" - } - }, - { - "id": "b41e6db7-a840-423d-a8c7-234bfa35d75a", - "name": "zoneinfo", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "userinfo.token.claim": "true", - "user.attribute": "zoneinfo", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "zoneinfo", - "jsonType.label": "String" - } - } - ] - }, - { - "id": "43fee56e-094a-4a3b-98da-9185b91a584b", - "name": "basic", - "description": "OpenID Connect scope for add all basic claims to the token", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "false", - "display.on.consent.screen": "false" - }, - "protocolMappers": [ - { - "id": "5a6fe513-023e-4ad0-82be-2be4765c3468", - "name": "sub", - "protocol": "openid-connect", - "protocolMapper": "oidc-sub-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "access.token.claim": "true" - } - }, - { - "id": "d17da110-609e-47b9-8f38-f79f8be45181", - "name": "auth_time", - "protocol": "openid-connect", - "protocolMapper": "oidc-usersessionmodel-note-mapper", - "consentRequired": false, - "config": { - "user.session.note": "AUTH_TIME", - "id.token.claim": "true", - "introspection.token.claim": "true", - "access.token.claim": "true", - "claim.name": "auth_time", - "jsonType.label": "long" - } - } - ] - }, - { - "id": "e9bd8ed1-554b-4c77-a225-a564b92d5fcb", - "name": "offline_access", - "description": "OpenID Connect built-in scope: offline_access", - "protocol": "openid-connect", - "attributes": { - "consent.screen.text": "${offlineAccessScopeConsentText}", - "display.on.consent.screen": "true" - } - }, - { - "id": "1175d01e-62d0-48d1-bd1f-af409b1ec65b", - "name": "roles", - "description": "OpenID Connect scope for add user roles to the access token", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "false", - "consent.screen.text": "${rolesScopeConsentText}", - "display.on.consent.screen": "true" - }, - "protocolMappers": [ - { - "id": "0059d0b3-3f15-4525-a78e-4472842ad865", - "name": "realm roles", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-realm-role-mapper", - "consentRequired": false, - "config": { - "user.attribute": "foo", - "introspection.token.claim": "true", - "access.token.claim": "true", - "claim.name": "realm_access.roles", - "jsonType.label": "String", - "multivalued": "true" - } - }, - { - "id": "b52a9b4a-b171-4be1-9509-61e6607a2e25", - "name": "audience resolve", - "protocol": "openid-connect", - "protocolMapper": "oidc-audience-resolve-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "access.token.claim": "true" - } - }, - { - "id": "307c203d-7474-4833-b883-e5c02ab5ab97", - "name": "client roles", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-client-role-mapper", - "consentRequired": false, - "config": { - "user.attribute": "foo", - "introspection.token.claim": "true", - "access.token.claim": "true", - "claim.name": "resource_access.${client_id}.roles", - "jsonType.label": "String", - "multivalued": "true" - } - } - ] - }, - { - "id": "0f468a65-1754-4be9-8105-1e6bb1b5c55f", - "name": "acr", - "description": "OpenID Connect scope for add acr (authentication context class reference) to the token", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "false", - "display.on.consent.screen": "false" - }, - "protocolMappers": [ - { - "id": "2839fee0-de0d-4c6c-9328-868e16c45d92", - "name": "acr loa level", - "protocol": "openid-connect", - "protocolMapper": "oidc-acr-mapper", - "consentRequired": false, - "config": { - "id.token.claim": "true", - "introspection.token.claim": "true", - "access.token.claim": "true" - } - } - ] - }, - { - "id": "f0c6a48e-ad11-40d1-9714-311fa79e7fce", - "name": "address", - "description": "OpenID Connect built-in scope: address", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "true", - "consent.screen.text": "${addressScopeConsentText}", - "display.on.consent.screen": "true" - }, - "protocolMappers": [ - { - "id": "a4f804a0-f845-4672-b1e4-35b048bb27d1", - "name": "address", - "protocol": "openid-connect", - "protocolMapper": "oidc-address-mapper", - "consentRequired": false, - "config": { - "user.attribute.formatted": "formatted", - "user.attribute.country": "country", - "introspection.token.claim": "true", - "user.attribute.postal_code": "postal_code", - "userinfo.token.claim": "true", - "user.attribute.street": "street", - "id.token.claim": "true", - "user.attribute.region": "region", - "access.token.claim": "true", - "user.attribute.locality": "locality" - } - } - ] - }, - { - "id": "9869edaf-21a9-4dec-a5d9-13b7a565136b", - "name": "microprofile-jwt", - "description": "Microprofile - JWT built-in scope", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "true", - "display.on.consent.screen": "false" - }, - "protocolMappers": [ - { - "id": "fc63864e-3391-4ce0-b908-0647119a3a8b", - "name": "groups", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-realm-role-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "multivalued": "true", - "user.attribute": "foo", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "groups", - "jsonType.label": "String" - } - }, - { - "id": "3a020067-bd91-4127-963f-50bc2fd4b05a", - "name": "upn", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "userinfo.token.claim": "true", - "user.attribute": "username", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "upn", - "jsonType.label": "String" - } - } - ] - }, - { - "id": "99310df5-6af0-4a2e-aa9e-cbd6992d259d", - "name": "phone", - "description": "OpenID Connect built-in scope: phone", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "true", - "consent.screen.text": "${phoneScopeConsentText}", - "display.on.consent.screen": "true" - }, - "protocolMappers": [ - { - "id": "31490237-0398-41b3-a1ed-dcc69a7ef1fc", - "name": "phone number", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "userinfo.token.claim": "true", - "user.attribute": "phoneNumber", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "phone_number", - "jsonType.label": "String" - } - }, - { - "id": "ea53e139-b0ec-4f61-bd8f-5a974ba66da5", - "name": "phone number verified", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "userinfo.token.claim": "true", - "user.attribute": "phoneNumberVerified", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "phone_number_verified", - "jsonType.label": "boolean" - } - } - ] - }, - { - "id": "5f5cdb3b-ec94-43ae-ab80-b5ca8acceb82", - "name": "saml_organization", - "description": "Organization Membership", - "protocol": "saml", - "attributes": { - "display.on.consent.screen": "false" - }, - "protocolMappers": [ - { - "id": "d307f0d4-e1b3-493d-bf51-d721b90b6cd8", - "name": "organization", - "protocol": "saml", - "protocolMapper": "saml-organization-membership-mapper", - "consentRequired": false, - "config": {} - } - ] - }, - { - "id": "d70b8ad4-82c1-4567-9b11-5eb880489ebc", - "name": "email", - "description": "OpenID Connect built-in scope: email", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "true", - "consent.screen.text": "${emailScopeConsentText}", - "display.on.consent.screen": "true" - }, - "protocolMappers": [ - { - "id": "7f6a6f9e-34c4-4ff3-b4bb-c342f10f3651", - "name": "email", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "userinfo.token.claim": "true", - "user.attribute": "email", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "email", - "jsonType.label": "String" - } - }, - { - "id": "ad635e8a-73f2-40c8-bc4e-44ed2baac881", - "name": "email verified", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-property-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "userinfo.token.claim": "true", - "user.attribute": "emailVerified", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "email_verified", - "jsonType.label": "boolean" - } - } - ] - }, - { - "id": "e0feb8b3-0035-449c-be17-ae5140fbb675", - "name": "web-origins", - "description": "OpenID Connect scope for add allowed web origins to the access token", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "false", - "consent.screen.text": "", - "display.on.consent.screen": "false" - }, - "protocolMappers": [ - { - "id": "af02940a-fe65-4d2b-9028-e5a75ee3182f", - "name": "allowed web origins", - "protocol": "openid-connect", - "protocolMapper": "oidc-allowed-origins-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "access.token.claim": "true" - } - } - ] - }, - { - "id": "39163d5a-c8d0-4edf-8388-194be5134053", - "name": "AuthnContextClassRef", - "description": "AuthnContextClassRef Level of Authentiation", - "protocol": "saml", - "attributes": {}, - "protocolMappers": [ - { - "id": "d91a12f6-3f8c-4cce-b411-8c66768277b4", - "name": "AuthnContextClassRef", - "protocol": "saml", - "protocolMapper": "saml-authn-context-class-ref-mapper", - "consentRequired": false, - "config": {} - } - ] - }, - { - "id": "7ab00999-4f7b-4b3e-a139-3ba33692b9f5", - "name": "role_list", - "description": "SAML role list", - "protocol": "saml", - "attributes": { - "consent.screen.text": "${samlRoleListScopeConsentText}", - "display.on.consent.screen": "true" - }, - "protocolMappers": [ - { - "id": "ef48ad34-d63e-440a-b142-afea9a37e4cb", - "name": "role list", - "protocol": "saml", - "protocolMapper": "saml-role-list-mapper", - "consentRequired": false, - "config": { - "single": "false", - "attribute.nameformat": "Basic", - "attribute.name": "Role" - } - } - ] - } - ], - "defaultDefaultClientScopes": [ - "role_list", - "saml_organization", - "AuthnContextClassRef", - "profile", - "email", - "roles", - "web-origins", - "acr", - "basic" - ], - "defaultOptionalClientScopes": [ - "offline_access", - "address", - "phone", - "microprofile-jwt", - "organization" - ], - "browserSecurityHeaders": { - "contentSecurityPolicyReportOnly": "", - "xContentTypeOptions": "nosniff", - "referrerPolicy": "no-referrer", - "xRobotsTag": "none", - "xFrameOptions": "SAMEORIGIN", - "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", - "strictTransportSecurity": "max-age=31536000; includeSubDomains" - }, - "smtpServer": {}, - "loginTheme": "phasetwo-ui", - "accountTheme": "phasetwo-ui", - "adminTheme": "phasetwo-ui", - "emailTheme": "phasetwo-ui", - "eventsEnabled": true, - "eventsListeners": [ - "ext-event-http", - "jboss-logging", - "ext-event-script", - "ext-event-webhook" - ], - "enabledEventTypes": [ - "UPDATE_CONSENT_ERROR", - "UPDATE_TOTP", - "UPDATE_CREDENTIAL", - "IMPERSONATE_ERROR", - "CUSTOM_REQUIRED_ACTION", - "RESTART_AUTHENTICATION", - "VERIFIABLE_CREDENTIAL_OFFER_REQUEST", - "IMPERSONATE", - "LOGIN", - "CLIENT_INITIATED_ACCOUNT_LINKING", - "OAUTH2_EXTENSION_GRANT", - "USER_DISABLED_BY_PERMANENT_LOCKOUT", - "VERIFIABLE_CREDENTIAL_REQUEST", - "REMOVE_CREDENTIAL_ERROR", - "TOKEN_EXCHANGE", - "REGISTER", - "DELETE_ACCOUNT_ERROR", - "VERIFIABLE_CREDENTIAL_OFFER_REQUEST_ERROR", - "IDENTITY_PROVIDER_LINK_ACCOUNT", - "USER_DISABLED_BY_TEMPORARY_LOCKOUT", - "DELETE_ACCOUNT", - "UPDATE_PASSWORD", - "IDENTITY_PROVIDER_FIRST_LOGIN", - "VERIFY_EMAIL", - "CLIENT_LOGIN_ERROR", - "RESTART_AUTHENTICATION_ERROR", - "EXECUTE_ACTIONS", - "REMOVE_FEDERATED_IDENTITY_ERROR", - "TOKEN_EXCHANGE_ERROR", - "VERIFIABLE_CREDENTIAL_PRE_AUTHORIZED_GRANT", - "FEDERATED_IDENTITY_OVERRIDE_LINK", - "SEND_IDENTITY_PROVIDER_LINK_ERROR", - "VERIFIABLE_CREDENTIAL_CREATE_OFFER", - "JWT_AUTHORIZATION_GRANT_ERROR", - "VERIFIABLE_CREDENTIAL_PRE_AUTHORIZED_GRANT_ERROR", - "EXECUTE_ACTION_TOKEN_ERROR", - "OAUTH2_EXTENSION_GRANT_ERROR", - "SEND_VERIFY_EMAIL", - "EXECUTE_ACTIONS_ERROR", - "OAUTH2_DEVICE_CODE_TO_TOKEN", - "OAUTH2_DEVICE_VERIFY_USER_CODE_ERROR", - "REVOKE_GRANT_ERROR", - "UPDATE_EMAIL_ERROR", - "INVITE_ORG_ERROR", - "UPDATE_PROFILE", - "VERIFIABLE_CREDENTIAL_REQUEST_ERROR", - "SEND_IDENTITY_PROVIDER_LINK", - "SEND_VERIFY_EMAIL_ERROR", - "CLIENT_INITIATED_ACCOUNT_LINKING_ERROR", - "OAUTH2_DEVICE_AUTH_ERROR", - "REMOVE_TOTP_ERROR", - "VERIFY_EMAIL_ERROR", - "CLIENT_UPDATE", - "JWT_AUTHORIZATION_GRANT", - "UPDATE_TOTP_ERROR", - "VERIFY_PROFILE", - "GRANT_CONSENT_ERROR", - "SEND_RESET_PASSWORD", - "GRANT_CONSENT", - "VERIFY_PROFILE_ERROR", - "REMOVE_TOTP", - "REVOKE_GRANT", - "LOGIN_ERROR", - "CLIENT_LOGIN", - "RESET_PASSWORD_ERROR", - "CODE_TO_TOKEN_ERROR", - "OAUTH2_DEVICE_CODE_TO_TOKEN_ERROR", - "UPDATE_PROFILE_ERROR", - "OAUTH2_DEVICE_VERIFY_USER_CODE", - "UPDATE_PASSWORD_ERROR", - "IDENTITY_PROVIDER_LOGIN", - "AUTHREQID_TO_TOKEN", - "LOGOUT", - "CLIENT_REGISTER", - "CLIENT_DELETE", - "FEDERATED_IDENTITY_LINK_ERROR", - "CLIENT_DELETE_ERROR", - "PERMISSION_TOKEN", - "REGISTER_NODE", - "UPDATE_CREDENTIAL_ERROR", - "OAUTH2_DEVICE_AUTH", - "REMOVE_FEDERATED_IDENTITY", - "IDENTITY_PROVIDER_POST_LOGIN", - "VERIFIABLE_CREDENTIAL_CREATE_OFFER_ERROR", - "IDENTITY_PROVIDER_LINK_ACCOUNT_ERROR", - "FEDERATED_IDENTITY_OVERRIDE_LINK_ERROR", - "UPDATE_EMAIL", - "REGISTER_ERROR", - "EXECUTE_ACTION_TOKEN", - "LOGOUT_ERROR", - "CLIENT_UPDATE_ERROR", - "AUTHREQID_TO_TOKEN_ERROR", - "CLIENT_REGISTER_ERROR", - "FEDERATED_IDENTITY_LINK", - "INVITE_ORG", - "IDENTITY_PROVIDER_LOGIN_ERROR", - "RESET_PASSWORD", - "REMOVE_CREDENTIAL", - "UPDATE_CONSENT", - "SEND_RESET_PASSWORD_ERROR", - "CUSTOM_REQUIRED_ACTION_ERROR", - "IDENTITY_PROVIDER_POST_LOGIN_ERROR", - "CODE_TO_TOKEN", - "IDENTITY_PROVIDER_FIRST_LOGIN_ERROR" - ], - "adminEventsEnabled": false, - "adminEventsDetailsEnabled": false, - "identityProviders": [], - "identityProviderMappers": [], - "components": { - "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [ - { - "id": "f9b59027-c19f-439f-8734-1c59de02ed1d", - "name": "Allowed Registration Web Origins", - "providerId": "registration-web-origins", - "subType": "authenticated", - "subComponents": {}, - "config": {} - }, - { - "id": "147d6e99-6208-49e9-986f-aecc17906db7", - "name": "Allowed Protocol Mapper Types", - "providerId": "allowed-protocol-mappers", - "subType": "anonymous", - "subComponents": {}, - "config": { - "allowed-protocol-mapper-types": [ - "oidc-usermodel-attribute-mapper", - "oidc-usermodel-property-mapper", - "saml-user-property-mapper", - "oidc-full-name-mapper", - "saml-role-list-mapper", - "saml-user-attribute-mapper", - "oidc-sha256-pairwise-sub-mapper", - "oidc-address-mapper" - ] - } - }, - { - "id": "e97ff134-c0ad-4691-afce-88082ab14386", - "name": "Allowed Client Scopes", - "providerId": "allowed-client-templates", - "subType": "anonymous", - "subComponents": {}, - "config": { - "allow-default-scopes": [ - "true" - ] - } - }, - { - "id": "048701e6-eeb1-44c3-9fa4-109df2d9cc55", - "name": "Allowed Registration Web Origins", - "providerId": "registration-web-origins", - "subType": "anonymous", - "subComponents": {}, - "config": {} - }, - { - "id": "080f41d8-32ec-41ce-a632-f03f3081d665", - "name": "Allowed Client Scopes", - "providerId": "allowed-client-templates", - "subType": "authenticated", - "subComponents": {}, - "config": { - "allow-default-scopes": [ - "true" - ] - } - }, - { - "id": "513d33d8-77d7-4991-b4df-06058e4b7c96", - "name": "Trusted Hosts", - "providerId": "trusted-hosts", - "subType": "anonymous", - "subComponents": {}, - "config": { - "host-sending-registration-request-must-match": [ - "true" - ], - "client-uris-must-match": [ - "true" - ] - } - }, - { - "id": "04f87943-c844-42c0-85dc-04d90772d8c9", - "name": "Allowed Protocol Mapper Types", - "providerId": "allowed-protocol-mappers", - "subType": "authenticated", - "subComponents": {}, - "config": { - "allowed-protocol-mapper-types": [ - "oidc-usermodel-attribute-mapper", - "saml-user-property-mapper", - "saml-user-attribute-mapper", - "saml-role-list-mapper", - "oidc-address-mapper", - "oidc-full-name-mapper", - "oidc-usermodel-property-mapper", - "oidc-sha256-pairwise-sub-mapper" - ] - } - }, - { - "id": "216a7ba0-35c6-4836-9d9a-99879a74b9ae", - "name": "Max Clients Limit", - "providerId": "max-clients", - "subType": "anonymous", - "subComponents": {}, - "config": { - "max-clients": [ - "200" - ] - } - }, - { - "id": "b5431799-d570-4cd3-bf2a-baddaca33044", - "name": "Consent Required", - "providerId": "consent-required", - "subType": "anonymous", - "subComponents": {}, - "config": {} - }, - { - "id": "8c27fcaf-74ba-4622-a1b8-9f6676297dda", - "name": "Full Scope Disabled", - "providerId": "scope", - "subType": "anonymous", - "subComponents": {}, - "config": {} - } - ], - "org.keycloak.userprofile.UserProfileProvider": [ - { - "id": "a5f87151-1431-4d46-a906-32f6354fa33a", - "providerId": "declarative-user-profile", - "subComponents": {}, - "config": { - "kc.user.profile.config": [ - "{\"attributes\":[{\"name\":\"username\",\"displayName\":\"${username}\",\"validations\":{\"length\":{\"min\":3,\"max\":255},\"username-prohibited-characters\":{},\"up-username-not-idn-homograph\":{}},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"email\",\"displayName\":\"${email}\",\"validations\":{\"email\":{},\"length\":{\"max\":255}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"firstName\",\"displayName\":\"${firstName}\",\"validations\":{\"length\":{\"max\":255},\"person-name-prohibited-characters\":{}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"lastName\",\"displayName\":\"${lastName}\",\"validations\":{\"length\":{\"max\":255},\"person-name-prohibited-characters\":{}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"org.ro.active\",\"displayName\":\"Active organization ID\",\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"multivalued\":false}],\"groups\":[{\"name\":\"user-metadata\",\"displayHeader\":\"User metadata\",\"displayDescription\":\"Attributes, which refer to user metadata\"}]}" - ] - } - } - ], - "org.keycloak.keys.KeyProvider": [ - { - "id": "e131821d-c569-4ed8-8b5b-5ad84fcc0c13", - "name": "rsa-enc-generated", - "providerId": "rsa-enc-generated", - "subComponents": {}, - "config": { - "priority": [ - "100" - ], - "algorithm": [ - "RSA-OAEP" - ] - } - }, - { - "id": "5e14e592-fe10-4f38-bc98-1f12e3d05b83", - "name": "rsa-generated", - "providerId": "rsa-generated", - "subComponents": {}, - "config": { - "priority": [ - "100" - ] - } - }, - { - "id": "fa40d5cf-3027-40b3-819d-a1a1eae49129", - "name": "aes-generated", - "providerId": "aes-generated", - "subComponents": {}, - "config": { - "priority": [ - "100" - ] - } - }, - { - "id": "a5dfc729-180d-4bfc-839b-92c96b37f6e0", - "name": "hmac-generated-hs512", - "providerId": "hmac-generated", - "subComponents": {}, - "config": { - "priority": [ - "100" - ], - "algorithm": [ - "HS512" - ] - } - } - ] - }, - "internationalizationEnabled": false, - "authenticationFlows": [ - { - "id": "2c01c801-7adf-4dc4-b002-d7053cd7f207", - "alias": "Account verification options", - "description": "Method with which to verify the existing account", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "idp-email-verification", - "authenticatorFlow": false, - "requirement": "ALTERNATIVE", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticatorFlow": true, - "requirement": "ALTERNATIVE", - "priority": 20, - "autheticatorFlow": true, - "flowAlias": "Verify Existing Account by Re-authentication", - "userSetupAllowed": false - } - ] - }, - { - "id": "ce8fefa6-a08c-4ef6-b7ae-23114f8f4c70", - "alias": "Browser - Conditional 2FA", - "description": "Flow to determine if any 2FA is required for the authentication", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "conditional-user-configured", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticatorConfig": "browser-conditional-credential", - "authenticator": "conditional-credential", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 20, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "auth-otp-form", - "authenticatorFlow": false, - "requirement": "ALTERNATIVE", - "priority": 30, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "webauthn-authenticator", - "authenticatorFlow": false, - "requirement": "DISABLED", - "priority": 40, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "auth-recovery-authn-code-form", - "authenticatorFlow": false, - "requirement": "DISABLED", - "priority": 50, - "autheticatorFlow": false, - "userSetupAllowed": false - } - ] - }, - { - "id": "5f39626d-9db7-4f77-bd5f-79a53d4bc027", - "alias": "Browser - Conditional Organization", - "description": "Flow to determine if the organization identity-first login is to be used", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "conditional-user-configured", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "organization", - "authenticatorFlow": false, - "requirement": "ALTERNATIVE", - "priority": 20, - "autheticatorFlow": false, - "userSetupAllowed": false - } - ] - }, - { - "id": "8603ddbd-7da4-409e-9a48-39ad4d663da2", - "alias": "Cookies Sub-Flow", - "description": "Cookie sub-flow which can be used to switch org.", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "auth-cookie", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "ext-select-org", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 20, - "autheticatorFlow": false, - "userSetupAllowed": false - } - ] - }, - { - "id": "e445c393-f9a0-405e-b7fb-08c7e7060338", - "alias": "Direct Grant - Conditional OTP", - "description": "Flow to determine if the OTP is required for the authentication", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "conditional-user-configured", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "direct-grant-validate-otp", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 20, - "autheticatorFlow": false, - "userSetupAllowed": false - } - ] - }, - { - "id": "5fb98117-9ddf-415b-a541-b84a2879fdd0", - "alias": "First Broker Login - Conditional Organization", - "description": "Flow to determine if the authenticator that adds organization members is to be used", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "conditional-user-configured", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "idp-add-organization-member", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 20, - "autheticatorFlow": false, - "userSetupAllowed": false - } - ] - }, - { - "id": "6e11765b-49f7-4896-a911-9fc91414f7e4", - "alias": "First broker login - Conditional 2FA", - "description": "Flow to determine if any 2FA is required for the authentication", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "conditional-user-configured", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticatorConfig": "first-broker-login-conditional-credential", - "authenticator": "conditional-credential", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 20, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "auth-otp-form", - "authenticatorFlow": false, - "requirement": "ALTERNATIVE", - "priority": 30, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "webauthn-authenticator", - "authenticatorFlow": false, - "requirement": "DISABLED", - "priority": 40, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "auth-recovery-authn-code-form", - "authenticatorFlow": false, - "requirement": "DISABLED", - "priority": 50, - "autheticatorFlow": false, - "userSetupAllowed": false - } - ] - }, - { - "id": "8d0f87a9-64c0-4eee-815c-1006a46265e5", - "alias": "Forms Sub-Flow", - "description": "Username, password, otp and other auth forms.", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "auth-username-password-form", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticatorFlow": true, - "requirement": "CONDITIONAL", - "priority": 20, - "autheticatorFlow": true, - "flowAlias": "Org Browser - Conditional OTP", - "userSetupAllowed": false - }, - { - "authenticator": "ext-select-org", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 30, - "autheticatorFlow": false, - "userSetupAllowed": false - } - ] - }, - { - "id": "e3b30e27-cd94-498f-9444-7e88f1868f6c", - "alias": "Handle Existing Account", - "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "idp-confirm-link", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticatorFlow": true, - "requirement": "REQUIRED", - "priority": 20, - "autheticatorFlow": true, - "flowAlias": "Account verification options", - "userSetupAllowed": false - } - ] - }, - { - "id": "164311b5-d365-4029-a197-7ffd969854ee", - "alias": "IDP Sub-Flow", - "description": "IDP sub-flow to select org.", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "identity-provider-redirector", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "ext-select-org", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 20, - "autheticatorFlow": false, - "userSetupAllowed": false - } - ] - }, - { - "id": "ef77b92d-1007-4c1c-ad31-ff15c74fb10b", - "alias": "Org Browser - Conditional OTP", - "description": "Flow to determine if the OTP is required for the authentication", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "conditional-user-configured", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "auth-otp-form", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 20, - "autheticatorFlow": false, - "userSetupAllowed": false - } - ] - }, - { - "id": "814fe89f-9d8d-4d69-bee5-73819e59b067", - "alias": "Org Browser Flow", - "description": "Browser flow with select organization step.", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticatorFlow": true, - "requirement": "ALTERNATIVE", - "priority": 10, - "autheticatorFlow": true, - "flowAlias": "Cookies Sub-Flow", - "userSetupAllowed": false - }, - { - "authenticatorFlow": true, - "requirement": "ALTERNATIVE", - "priority": 20, - "autheticatorFlow": true, - "flowAlias": "IDP Sub-Flow", - "userSetupAllowed": false - }, - { - "authenticatorFlow": true, - "requirement": "ALTERNATIVE", - "priority": 30, - "autheticatorFlow": true, - "flowAlias": "Forms Sub-Flow", - "userSetupAllowed": false - } - ] - }, - { - "id": "8474c11c-3ae9-4166-9259-7774c4ac1630", - "alias": "Org Direct Grant - Conditional OTP", - "description": "Flow to determine if the OTP is required for the authentication", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "conditional-user-configured", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "auth-otp-form", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 20, - "autheticatorFlow": false, - "userSetupAllowed": false - } - ] - }, - { - "id": "665e9934-860a-4c85-bf4f-fd7811456702", - "alias": "Org Direct Grant Flow", - "description": "Direct grant flow with select organization step.", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "direct-grant-validate-username", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "direct-grant-validate-password", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 20, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticatorFlow": true, - "requirement": "CONDITIONAL", - "priority": 30, - "autheticatorFlow": true, - "flowAlias": "Org Direct Grant - Conditional OTP", - "userSetupAllowed": false - }, - { - "authenticator": "ext-select-org", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 30, - "autheticatorFlow": false, - "userSetupAllowed": false - } - ] - }, - { - "id": "f3476a0f-c796-40cb-beba-716f5542fab8", - "alias": "Organization", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticatorFlow": true, - "requirement": "CONDITIONAL", - "priority": 10, - "autheticatorFlow": true, - "flowAlias": "Browser - Conditional Organization", - "userSetupAllowed": false - } - ] - }, - { - "id": "9984e4ed-07e6-4088-b62f-9c78dcc9bb04", - "alias": "Reset - Conditional OTP", - "description": "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "conditional-user-configured", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "reset-otp", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 20, - "autheticatorFlow": false, - "userSetupAllowed": false - } - ] - }, - { - "id": "b61fc983-ae44-4110-b60b-efaa6b0e091e", - "alias": "User creation or linking", - "description": "Flow for the existing/non-existing user alternatives", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticatorConfig": "create unique user config", - "authenticator": "idp-create-user-if-unique", - "authenticatorFlow": false, - "requirement": "ALTERNATIVE", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticatorFlow": true, - "requirement": "ALTERNATIVE", - "priority": 20, - "autheticatorFlow": true, - "flowAlias": "Handle Existing Account", - "userSetupAllowed": false - } - ] - }, - { - "id": "f1ce0698-6ed6-4374-a834-9d31eb17d489", - "alias": "Verify Existing Account by Re-authentication", - "description": "Reauthentication of existing account", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "idp-username-password-form", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticatorFlow": true, - "requirement": "CONDITIONAL", - "priority": 20, - "autheticatorFlow": true, - "flowAlias": "First broker login - Conditional 2FA", - "userSetupAllowed": false - } - ] - }, - { - "id": "acced864-c2c9-484f-8e90-639f2994731b", - "alias": "browser", - "description": "Browser based authentication", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "auth-cookie", - "authenticatorFlow": false, - "requirement": "ALTERNATIVE", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "auth-spnego", - "authenticatorFlow": false, - "requirement": "DISABLED", - "priority": 20, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "identity-provider-redirector", - "authenticatorFlow": false, - "requirement": "ALTERNATIVE", - "priority": 25, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticatorFlow": true, - "requirement": "ALTERNATIVE", - "priority": 26, - "autheticatorFlow": true, - "flowAlias": "Organization", - "userSetupAllowed": false - }, - { - "authenticatorFlow": true, - "requirement": "ALTERNATIVE", - "priority": 30, - "autheticatorFlow": true, - "flowAlias": "forms", - "userSetupAllowed": false - } - ] - }, - { - "id": "519f6224-da61-49f6-9a42-5e7a9b766118", - "alias": "clients", - "description": "Base authentication for clients", - "providerId": "client-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "client-secret", - "authenticatorFlow": false, - "requirement": "ALTERNATIVE", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "client-jwt", - "authenticatorFlow": false, - "requirement": "ALTERNATIVE", - "priority": 20, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "client-secret-jwt", - "authenticatorFlow": false, - "requirement": "ALTERNATIVE", - "priority": 30, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "client-x509", - "authenticatorFlow": false, - "requirement": "ALTERNATIVE", - "priority": 40, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "federated-jwt", - "authenticatorFlow": false, - "requirement": "ALTERNATIVE", - "priority": 50, - "autheticatorFlow": false, - "userSetupAllowed": false - } - ] - }, - { - "id": "8f53d8c1-379c-4ec6-96a0-0c2ec168c8f8", - "alias": "direct grant", - "description": "OpenID Connect Resource Owner Grant", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "direct-grant-validate-username", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "direct-grant-validate-password", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 20, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticatorFlow": true, - "requirement": "CONDITIONAL", - "priority": 30, - "autheticatorFlow": true, - "flowAlias": "Direct Grant - Conditional OTP", - "userSetupAllowed": false - } - ] - }, - { - "id": "fa5e8e61-7d82-4dc3-ac8e-a9d3b5e630d3", - "alias": "docker auth", - "description": "Used by Docker clients to authenticate against the IDP", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "docker-http-basic-authenticator", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - } - ] - }, - { - "id": "a923c89d-fa04-4560-8587-1b1e6f12a496", - "alias": "first broker login", - "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticatorConfig": "review profile config", - "authenticator": "idp-review-profile", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticatorFlow": true, - "requirement": "REQUIRED", - "priority": 20, - "autheticatorFlow": true, - "flowAlias": "User creation or linking", - "userSetupAllowed": false - }, - { - "authenticatorFlow": true, - "requirement": "CONDITIONAL", - "priority": 60, - "autheticatorFlow": true, - "flowAlias": "First Broker Login - Conditional Organization", - "userSetupAllowed": false - } - ] - }, - { - "id": "ac4b0ce5-bd3e-4f8a-8db2-2a3c5f5ef46e", - "alias": "forms", - "description": "Username, password, otp and other auth forms.", - "providerId": "basic-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "auth-username-password-form", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticatorFlow": true, - "requirement": "CONDITIONAL", - "priority": 20, - "autheticatorFlow": true, - "flowAlias": "Browser - Conditional 2FA", - "userSetupAllowed": false - } - ] - }, - { - "id": "1a27d5cc-da95-469f-b78e-d535bb9d4713", - "alias": "registration", - "description": "Registration flow", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "registration-page-form", - "authenticatorFlow": true, - "requirement": "REQUIRED", - "priority": 10, - "autheticatorFlow": true, - "flowAlias": "registration form", - "userSetupAllowed": false - } - ] - }, - { - "id": "b020c213-18ae-4812-bc27-961db8667346", - "alias": "registration form", - "description": "Registration form", - "providerId": "form-flow", - "topLevel": false, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "registration-user-creation", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 20, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "registration-password-action", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 50, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "registration-recaptcha-action", - "authenticatorFlow": false, - "requirement": "DISABLED", - "priority": 60, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "registration-terms-and-conditions", - "authenticatorFlow": false, - "requirement": "DISABLED", - "priority": 70, - "autheticatorFlow": false, - "userSetupAllowed": false - } - ] - }, - { - "id": "a2f440c0-dc4b-486a-8299-12909c7cd71a", - "alias": "reset credentials", - "description": "Reset credentials for a user if they forgot their password or something", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "reset-credentials-choose-user", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "reset-credential-email", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 20, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticator": "reset-password", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 30, - "autheticatorFlow": false, - "userSetupAllowed": false - }, - { - "authenticatorFlow": true, - "requirement": "CONDITIONAL", - "priority": 40, - "autheticatorFlow": true, - "flowAlias": "Reset - Conditional OTP", - "userSetupAllowed": false - } - ] - }, - { - "id": "ef4201b1-d166-47c3-8fdc-c254b11d32b1", - "alias": "saml ecp", - "description": "SAML ECP Profile Authentication Flow", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [ - { - "authenticator": "http-basic-authenticator", - "authenticatorFlow": false, - "requirement": "REQUIRED", - "priority": 10, - "autheticatorFlow": false, - "userSetupAllowed": false - } - ] - } - ], - "authenticatorConfig": [ - { - "id": "b87b8c69-9a49-4f42-a1e6-41c259d3de38", - "alias": "browser-conditional-credential", - "config": { - "credentials": "webauthn-passwordless" - } - }, - { - "id": "33a0b22e-9103-44ac-8813-48a596f6b021", - "alias": "create unique user config", - "config": { - "require.password.update.after.registration": "false" - } - }, - { - "id": "a26f5110-d2c5-4235-a0ad-c40185a64599", - "alias": "first-broker-login-conditional-credential", - "config": { - "credentials": "webauthn-passwordless" - } - }, - { - "id": "4d1e7d2f-cc4f-4d03-a639-c0b61c58f478", - "alias": "review profile config", - "config": { - "update.profile.on.first.login": "missing" - } - } - ], - "requiredActions": [ - { - "alias": "TERMS_AND_CONDITIONS", - "name": "Terms and Conditions", - "providerId": "TERMS_AND_CONDITIONS", - "enabled": false, - "defaultAction": false, - "priority": 20, - "config": {} - }, - { - "alias": "UPDATE_PROFILE", - "name": "Update Profile", - "providerId": "UPDATE_PROFILE", - "enabled": true, - "defaultAction": false, - "priority": 40, - "config": {} - }, - { - "alias": "VERIFY_EMAIL", - "name": "Verify Email", - "providerId": "VERIFY_EMAIL", - "enabled": true, - "defaultAction": false, - "priority": 50, - "config": {} - }, - { - "alias": "CONFIGURE_TOTP", - "name": "Configure OTP", - "providerId": "CONFIGURE_TOTP", - "enabled": true, - "defaultAction": false, - "priority": 54, - "config": {} - }, - { - "alias": "UPDATE_PASSWORD", - "name": "Update Password", - "providerId": "UPDATE_PASSWORD", - "enabled": true, - "defaultAction": false, - "priority": 57, - "config": {} - }, - { - "alias": "delete_account", - "name": "Delete Account", - "providerId": "delete_account", - "enabled": false, - "defaultAction": false, - "priority": 60, - "config": {} - }, - { - "alias": "UPDATE_EMAIL", - "name": "Update Email", - "providerId": "UPDATE_EMAIL", - "enabled": false, - "defaultAction": false, - "priority": 70, - "config": {} - }, - { - "alias": "webauthn-register", - "name": "Webauthn Register", - "providerId": "webauthn-register", - "enabled": true, - "defaultAction": false, - "priority": 80, - "config": {} - }, - { - "alias": "webauthn-register-passwordless", - "name": "Webauthn Register Passwordless", - "providerId": "webauthn-register-passwordless", - "enabled": true, - "defaultAction": false, - "priority": 90, - "config": {} - }, - { - "alias": "VERIFY_PROFILE", - "name": "Verify Profile", - "providerId": "VERIFY_PROFILE", - "enabled": true, - "defaultAction": false, - "priority": 100, - "config": {} - }, - { - "alias": "delete_credential", - "name": "Delete Credential", - "providerId": "delete_credential", - "enabled": true, - "defaultAction": false, - "priority": 110, - "config": {} - }, - { - "alias": "idp_link", - "name": "Linking Identity Provider", - "providerId": "idp_link", - "enabled": true, - "defaultAction": false, - "priority": 120, - "config": {} - }, - { - "alias": "CONFIGURE_RECOVERY_AUTHN_CODES", - "name": "Recovery Authentication Codes", - "providerId": "CONFIGURE_RECOVERY_AUTHN_CODES", - "enabled": true, - "defaultAction": false, - "priority": 130, - "config": {} - }, - { - "alias": "update_user_locale", - "name": "Update User Locale", - "providerId": "update_user_locale", - "enabled": true, - "defaultAction": false, - "priority": 1000, - "config": {} - } - ], - "browserFlow": "browser", - "registrationFlow": "registration", - "directGrantFlow": "direct grant", - "resetCredentialsFlow": "reset credentials", - "clientAuthenticationFlow": "clients", - "dockerAuthenticationFlow": "docker auth", - "firstBrokerLoginFlow": "first broker login", - "attributes": { - "cibaBackchannelTokenDeliveryMode": "poll", - "cibaAuthRequestedUserHint": "login_hint", - "oauth2DevicePollingInterval": "5", - "clientOfflineSessionMaxLifespan": "0", - "clientSessionIdleTimeout": "0", - "clientOfflineSessionIdleTimeout": "0", - "cibaInterval": "5", - "realmReusableOtpCode": "false", - "cibaExpiresIn": "120", - "oauth2DeviceCodeLifespan": "600", - "parRequestUriLifespan": "60", - "clientSessionMaxLifespan": "0", - "scimApiEnabled": "false", - "darkMode": "true" - }, - "keycloakVersion": "26.7.0", - "userManagedAccessAllowed": false, - "organizationsEnabled": false, - "verifiableCredentialsEnabled": false, - "adminPermissionsEnabled": false, - "scimApiEnabled": false, - "clientProfiles": { - "profiles": [] - }, - "clientPolicies": { - "policies": [] - } -} \ No newline at end of file diff --git a/deploy/oauth_proxy.toml b/deploy/oauth_proxy.toml deleted file mode 100644 index 8acbeb7..0000000 --- a/deploy/oauth_proxy.toml +++ /dev/null @@ -1,25 +0,0 @@ -upstreams=["http://api:8000"] -http_address="0.0.0.0:4180" -reverse_proxy=true -cookie_httponly=true -request_logging=true -auth_logging=true -standard_logging=true -skip_provider_button=true -set_xauthrequest=true -pass_authorization_header=true -set_authorization_header=true -pass_access_token=true -insecure_oidc_allow_unverified_email=true -provider="oidc" -redirect_url="http://api.conode.com/oauth2/callback" -oidc_issuer_url="http://keycloak.conode.com/realms/conode" -scope="openid email profile offline_access" -code_challenge_method="S256" -email_domains="*" -cookie_domains=[".conode.com"] -whitelist_domains=[".conode.com"] -cookie_refresh="0h4m0s" -cookie_expire="720h0m0s" -cookie_secure=false -cookie_csrf_per_request = true \ No newline at end of file diff --git a/deploy/persistence/init.sql b/deploy/persistence/init.sql deleted file mode 100644 index 06aabe8..0000000 --- a/deploy/persistence/init.sql +++ /dev/null @@ -1,27 +0,0 @@ --- Initialize user and database for KeyCloak - -CREATE ROLE keycloak -WITH - LOGIN - PASSWORD 'keycloakPassword'; - -CREATE DATABASE keycloak - OWNER keycloak - ENCODING 'UTF8' - TEMPLATE template0; - -\connect keycloak; - -GRANT ALL PRIVILEGES ON DATABASE keycloak TO keycloak; - -GRANT ALL ON SCHEMA public TO keycloak; -ALTER SCHEMA public OWNER TO keycloak; - -ALTER DEFAULT PRIVILEGES IN SCHEMA public -GRANT ALL ON TABLES TO keycloak; - -ALTER DEFAULT PRIVILEGES IN SCHEMA public -GRANT ALL ON SEQUENCES TO keycloak; - -ALTER DEFAULT PRIVILEGES IN SCHEMA public -GRANT ALL ON FUNCTIONS TO keycloak; \ No newline at end of file diff --git a/src/conode/application/errors.py b/src/conode/application/errors.py index 12ea6cd..a38c40b 100644 --- a/src/conode/application/errors.py +++ b/src/conode/application/errors.py @@ -101,3 +101,6 @@ class EmailsOfAllParticipantsMustBeVerifiedError(ApplicationError): ... class TargetUserAccountCreatedTooRecentlyError(ApplicationError): ... + + +class TokenExpiredError(ApplicationError): ... diff --git a/src/conode/application/services/__init__.py b/src/conode/application/services/__init__.py index 0fbde12..dd9a25b 100644 --- a/src/conode/application/services/__init__.py +++ b/src/conode/application/services/__init__.py @@ -1,10 +1,13 @@ from .access_control import AccessControlService +from .authorization import AuthorizationService, AuthorizationServiceResponse from .offer_acceptance import OfferAcceptanceService, OfferAcceptanceServiceResponse from .offer_sending import OfferSendingService, OfferSendingServiceResponse from .role_managment import RoleManagmentService, RoleManagmentServiceResponse __all__ = ( "AccessControlService", + "AuthorizationService", + "AuthorizationServiceResponse", "OfferAcceptanceService", "OfferAcceptanceServiceResponse", "OfferSendingService", diff --git a/src/conode/application/services/access_control.py b/src/conode/application/services/access_control.py index 69adff1..a3128a8 100644 --- a/src/conode/application/services/access_control.py +++ b/src/conode/application/services/access_control.py @@ -1,10 +1,7 @@ from dataclasses import dataclass from typing import Final -from uuid import uuid4 -from conode.application.errors import ( - NotEnoughRightsError, -) +from conode.application.errors import NotEnoughRightsError, UserNotFoundError from conode.application.interfaces.identity_provider import IdentityProvider from conode.application.interfaces.repositories import ( CompanyRepository, @@ -25,7 +22,7 @@ RolePermission, RolePermissionEntityId, ) -from conode.domain.user import Email, User, UserId +from conode.domain.user import Email, User type RolesPermissions = list[RolePermission] @@ -55,20 +52,11 @@ async def get_authorized_user(self) -> User: meta = self.identity_provider.get_current_user_meta() user = await self.user_repository.get_by_email(Email(meta["email"])) - async with self.transaction_manager: - if user is None: - user = User.new( - user_id=UserId(uuid4()), - first_name=meta["first_name"], - last_name=meta["last_name"], - username=meta["username"], - email=meta["email"], - bio="Beautiful description about me!", - ) - await self.user_repository.create(user) - - user.email_verified = meta["email_verified"] - await self.user_repository.update(user) + if user is None: + raise UserNotFoundError( + "User with this email not found", + [{"key": "email", "value": meta["email"]}], + ) return user diff --git a/src/conode/application/services/authorization.py b/src/conode/application/services/authorization.py new file mode 100644 index 0000000..01b1435 --- /dev/null +++ b/src/conode/application/services/authorization.py @@ -0,0 +1,44 @@ +from dataclasses import dataclass +from typing import NamedTuple +from uuid import uuid4 + +from conode.application.interfaces.password_hasher import PasswordHasher +from conode.application.interfaces.token_managers import ( + AccessTokenManager, + RefreshTokenManager, +) +from conode.domain.auth import Authorization, AuthorizationId, Session, SessionId +from conode.domain.user import User + + +class AuthorizationServiceResponse(NamedTuple): + session: Session + authorization: Authorization + expires_in: int + refresh_token: str + + +@dataclass +class AuthorizationService: + access_token_manager: AccessTokenManager + refresh_token_manager: RefreshTokenManager + password_hasher: PasswordHasher + + def generate_authorization_stuff( + self, user: User, password: str, ip: str + ) -> AuthorizationServiceResponse: + access_token, expires_in = self.access_token_manager.encode(user) + refresh_token = self.refresh_token_manager.encode() + hashed_password = self.password_hasher.hash(password) + + session = Session.new(SessionId(uuid4()), refresh_token, access_token, user, ip) + authorization = Authorization.new( + AuthorizationId(uuid4()), hashed_password, user + ) + + return AuthorizationServiceResponse( + session=session, + authorization=authorization, + expires_in=expires_in, + refresh_token=refresh_token, + ) diff --git a/src/conode/bootstrap/di/container.py b/src/conode/bootstrap/di/container.py index f0c4ff2..05b4582 100644 --- a/src/conode/bootstrap/di/container.py +++ b/src/conode/bootstrap/di/container.py @@ -5,7 +5,6 @@ ApplicationProvider, ConnectionProvider, InfrastructureProvider, - SecretsProvider, ) from conode.infrastructure.config import ( APIConfig, @@ -20,7 +19,6 @@ def get_async_container(config: Config) -> AsyncContainer: FastapiProvider(), InfrastructureProvider(), ApplicationProvider(), - SecretsProvider(), ConnectionProvider(), context={ APIConfig: config.api, diff --git a/src/conode/bootstrap/di/providers/__init__.py b/src/conode/bootstrap/di/providers/__init__.py index 6c35744..34e62a5 100644 --- a/src/conode/bootstrap/di/providers/__init__.py +++ b/src/conode/bootstrap/di/providers/__init__.py @@ -1,11 +1,9 @@ from .application import ApplicationProvider from .connection import ConnectionProvider from .infrastructure import InfrastructureProvider -from .secrets import SecretsProvider __all__ = ( "ApplicationProvider", "ConnectionProvider", "InfrastructureProvider", - "SecretsProvider", ) diff --git a/src/conode/bootstrap/di/providers/application.py b/src/conode/bootstrap/di/providers/application.py index bcc4c79..3c0f4d5 100644 --- a/src/conode/bootstrap/di/providers/application.py +++ b/src/conode/bootstrap/di/providers/application.py @@ -3,6 +3,7 @@ from conode.application.attach_node_to_group import ( AttachNodeInteractor, ) +from conode.application.auth import LoginInteractor, RegisterInteractor from conode.application.detach_node_from_group import ( DetachNodeInteractor, ) @@ -48,6 +49,7 @@ ) from conode.application.services import ( AccessControlService, + AuthorizationService, OfferAcceptanceService, OfferSendingService, RoleManagmentService, @@ -73,6 +75,9 @@ class ApplicationProvider(Provider): UpdateCurrentUserProfileInteractor, IncrementEdgeWeightInteractor, DecrementEdgeWeightInteractor, + AuthorizationService, + RegisterInteractor, + LoginInteractor, SendOfferToCompanyInteractor, RevokeRoleFromUserInteractor, UpdateUserProfileInteractor, diff --git a/src/conode/bootstrap/di/providers/infrastructure.py b/src/conode/bootstrap/di/providers/infrastructure.py index 4ad41a3..bc22d27 100644 --- a/src/conode/bootstrap/di/providers/infrastructure.py +++ b/src/conode/bootstrap/di/providers/infrastructure.py @@ -3,6 +3,7 @@ from conode.infrastructure.identity_provider import IdentityProviderImpl from conode.infrastructure.password_hasher import PasswordHasherImpl from conode.infrastructure.repositories import ( + AuthorizationRepositoryImpl, CompanyRepositoryImpl, ContextRepositoryImpl, ContractRepositoryImpl, @@ -16,9 +17,14 @@ OfferRepositoryImpl, RolePermissionsRepositoryImpl, RoleRepositoryImpl, + SessionRepositoryImpl, UserGrantRepositoryImpl, UserRepositoryImpl, ) +from conode.infrastructure.token_managers import ( + AccessTokenManagerImpl, + RefreshTokenManagerImpl, +) class InfrastructureProvider(Provider): @@ -30,6 +36,8 @@ class InfrastructureProvider(Provider): WithParents[CompanyRepositoryImpl], WithParents[UserRepositoryImpl], WithParents[NodeRepositoryImpl], + WithParents[AccessTokenManagerImpl], + WithParents[RefreshTokenManagerImpl], WithParents[ContextRepositoryImpl], WithParents[EdgeRepositoryImpl], WithParents[UserGrantRepositoryImpl], @@ -40,5 +48,7 @@ class InfrastructureProvider(Provider): WithParents[OfferGroupRepositoryImpl], WithParents[OfferLinkRepositoryImpl], WithParents[OfferRepositoryImpl], + WithParents[AuthorizationRepositoryImpl], + WithParents[SessionRepositoryImpl], scope=Scope.REQUEST, ) diff --git a/src/conode/bootstrap/di/providers/secrets.py b/src/conode/bootstrap/di/providers/secrets.py deleted file mode 100644 index 76a11e6..0000000 --- a/src/conode/bootstrap/di/providers/secrets.py +++ /dev/null @@ -1,10 +0,0 @@ -from dishka import Provider, Scope, WithParents, provide_all - -from conode.infrastructure.token_manager import TokenManagerImpl - - -class SecretsProvider(Provider): - provides = provide_all( - WithParents[TokenManagerImpl], - scope=Scope.REQUEST, - ) diff --git a/src/conode/domain/auth/__init__.py b/src/conode/domain/auth/__init__.py new file mode 100644 index 0000000..fe6d904 --- /dev/null +++ b/src/conode/domain/auth/__init__.py @@ -0,0 +1,4 @@ +from .authorization import Authorization, AuthorizationId, HashedPassword +from .session import Session, SessionId + +__all__ = ("Authorization", "AuthorizationId", "HashedPassword", "Session", "SessionId") diff --git a/src/conode/domain/auth/authorization/__init__.py b/src/conode/domain/auth/authorization/__init__.py new file mode 100644 index 0000000..598c682 --- /dev/null +++ b/src/conode/domain/auth/authorization/__init__.py @@ -0,0 +1,3 @@ +from .model import Authorization, AuthorizationId, HashedPassword + +__all__ = ("Authorization", "AuthorizationId", "HashedPassword") diff --git a/src/conode/domain/auth/authorization/errors.py b/src/conode/domain/auth/authorization/errors.py new file mode 100644 index 0000000..1c0e9dc --- /dev/null +++ b/src/conode/domain/auth/authorization/errors.py @@ -0,0 +1,7 @@ +from conode.application.errors import ApplicationError + + +class AuthorizationDomainValidationError(ApplicationError): ... + + +class InvalidAuthorizationPasswordFormatError(AuthorizationDomainValidationError): ... diff --git a/src/conode/domain/auth/authorization/model.py b/src/conode/domain/auth/authorization/model.py new file mode 100644 index 0000000..dba172d --- /dev/null +++ b/src/conode/domain/auth/authorization/model.py @@ -0,0 +1,50 @@ +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Final, NewType, Self +from uuid import UUID + +from conode.domain.auth.authorization.errors import ( + InvalidAuthorizationPasswordFormatError, +) +from conode.domain.shared import Entity, ValueObject +from conode.domain.user import User, UserId + +AuthorizationId = NewType("AuthorizationId", UUID) + +MAX_HASHED_PASSWORD_ALLOWED_LENGTH: Final = 30 +MIN_HASHED_PASSWORD_ALLOWED_LENGTH: Final = 5 + + +class HashedPassword(ValueObject[str]): + def __init__(self, value: str) -> None: + if ( + MIN_HASHED_PASSWORD_ALLOWED_LENGTH + >= len(value) + >= MAX_HASHED_PASSWORD_ALLOWED_LENGTH + ): + raise InvalidAuthorizationPasswordFormatError( + "Company description length must be between" + f"{MAX_HASHED_PASSWORD_ALLOWED_LENGTH} and " + f"{MIN_HASHED_PASSWORD_ALLOWED_LENGTH}", + [{"key": "password", "value": value}], + ) + super().__init__(value) + + +@dataclass +class Authorization(Entity[AuthorizationId]): + hashed_password: HashedPassword + user_id: UserId + + @classmethod + def new( + cls, authorization_id: AuthorizationId, hashed_password: str, user: User + ) -> Self: + now = datetime.now(UTC) + return cls( + id=authorization_id, + hashed_password=HashedPassword(hashed_password), + user_id=user.id, + created_at=now, + updated_at=now, + ) diff --git a/src/conode/domain/auth/session/__init__.py b/src/conode/domain/auth/session/__init__.py new file mode 100644 index 0000000..efc1295 --- /dev/null +++ b/src/conode/domain/auth/session/__init__.py @@ -0,0 +1,3 @@ +from .model import Session, SessionId + +__all__ = ("Session", "SessionId") diff --git a/src/conode/domain/auth/session/model.py b/src/conode/domain/auth/session/model.py new file mode 100644 index 0000000..8073fa5 --- /dev/null +++ b/src/conode/domain/auth/session/model.py @@ -0,0 +1,43 @@ +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import NewType, Self +from uuid import UUID + +from conode.domain.shared import Entity +from conode.domain.user import User, UserId + +SessionId = NewType("SessionId", UUID) + + +@dataclass +class Session(Entity[SessionId]): + access_token: str + refresh_token: str + user_id: UserId + ip: str + + @classmethod + def new( + cls, + session_id: SessionId, + refresh_token: str, + access_token: str, + user: User, + ip: str, + ) -> Self: + now = datetime.now(UTC) + return cls( + id=session_id, + ip=ip, + access_token=access_token, + refresh_token=refresh_token, + user_id=user.id, + created_at=now, + updated_at=now, + ) + + def set_access_token(self, token: str) -> None: + self.access_token = token + + def set_refresh_token(self, token: str) -> None: + self.refresh_token = token diff --git a/src/conode/domain/company/model.py b/src/conode/domain/company/model.py index 18f1ca4..3e75a9b 100644 --- a/src/conode/domain/company/model.py +++ b/src/conode/domain/company/model.py @@ -45,7 +45,7 @@ def __init__(self, value: str) -> None: <= MIN_ALLOWED_COMPANY_DESCRIPTION_LENGTH ): raise InvalidCompanyDescriptionFormatError( - "Company description must be between" + "Company description length must be between" f"{MIN_ALLOWED_COMPANY_DESCRIPTION_LENGTH} and " f"{MAX_ALLOWED_COMPANY_DESCRIPTION_LENGTH}", [{"key": "name", "value": value}], diff --git a/src/conode/domain/context/model.py b/src/conode/domain/context/model.py index b37e70c..dd068cc 100644 --- a/src/conode/domain/context/model.py +++ b/src/conode/domain/context/model.py @@ -27,7 +27,7 @@ def __init__(self, value: str) -> None: <= MAX_ALLOWED_CONTEXT_NAME_LENGTH ): raise InvalidContextNameFormatError( - "Context name must be between" + "Context name length must be between" f"{MIN_ALLOWED_CONTEXT_NAME_LENGTH} and " f"{MAX_ALLOWED_CONTEXT_NAME_LENGTH}", [{"key": "name", "value": value}], diff --git a/src/conode/domain/group/model.py b/src/conode/domain/group/model.py index b5adc30..8b0b3c3 100644 --- a/src/conode/domain/group/model.py +++ b/src/conode/domain/group/model.py @@ -26,7 +26,7 @@ def __init__(self, value: str) -> None: MIN_ALLOWED_GROUP_NAME_LENGTH <= len(value) <= MAX_ALLOWED_GROUP_NAME_LENGTH ): raise InvalidGroupNameFormatError( - "Group name must be between" + "Group name length must be between" f"{MIN_ALLOWED_GROUP_NAME_LENGTH} and " f"{MAX_ALLOWED_GROUP_NAME_LENGTH}", [{"key": "name", "value": value}], diff --git a/src/conode/domain/node/model.py b/src/conode/domain/node/model.py index 7b85b89..4dac801 100644 --- a/src/conode/domain/node/model.py +++ b/src/conode/domain/node/model.py @@ -27,7 +27,7 @@ def __init__(self, value: str) -> None: MIN_ALLOWED_NODE_NAME_LENGTH <= len(value) <= MAX_ALLOWED_NODE_NAME_LENGTH ): raise InvalidNodeNameFormatError( - "Node name must be between" + "Node name length must be between" f"{MIN_ALLOWED_NODE_NAME_LENGTH} and " f"{MAX_ALLOWED_NODE_NAME_LENGTH}", [{"key": "name", "value": value}], @@ -42,7 +42,7 @@ def __init__(self, value: str) -> None: if len(value) >= MAX_ALLOWED_NODE_DESCRIPTION_LENGTH: raise InvalidNodeDescriptionFormatError( - "Node description must be shorter than " + "Node description length must be shorter than " f"{MAX_ALLOWED_NODE_DESCRIPTION_LENGTH}", [{"key": "name", "value": value}], ) diff --git a/src/conode/domain/offer/model.py b/src/conode/domain/offer/model.py index 7569b4b..ecfc367 100644 --- a/src/conode/domain/offer/model.py +++ b/src/conode/domain/offer/model.py @@ -51,7 +51,7 @@ def __init__(self, value: str) -> None: <= MIN_ALLOWED_OFFER_TITLE_LENGTH ): raise InvalidOfferTitleFormatError( - "Offer name must be between " + "Offer name length must be between " f"{MIN_ALLOWED_OFFER_TITLE_LENGTH} and " f"{MAX_ALLOWED_OFFER_TITLE_LENGTH}", [{"key": "name", "value": value}], diff --git a/src/conode/domain/role/model.py b/src/conode/domain/role/model.py index 416c9f6..f807c57 100644 --- a/src/conode/domain/role/model.py +++ b/src/conode/domain/role/model.py @@ -40,7 +40,7 @@ def __init__(self, value: str) -> None: if MAX_ALLOWED_ROLE_NAME_LENGTH <= len(value) <= MIN_ALLOWED_ROLE_NAME_LENGTH: raise InvalidRoleNameFormatError( - "Role name must be between " + "Role name length must be between " f"{MIN_ALLOWED_ROLE_NAME_LENGTH} and " f"{MAX_ALLOWED_ROLE_NAME_LENGTH}", [{"key": "name", "value": value}], diff --git a/src/conode/domain/user/model.py b/src/conode/domain/user/model.py index 0a597b3..3d2d254 100644 --- a/src/conode/domain/user/model.py +++ b/src/conode/domain/user/model.py @@ -14,6 +14,7 @@ ) UserId = NewType("UserId", UUID) +AuthorizationId = NewType("AuthorizationId", UUID) MIN_ALLOWED_USERNAME_LENGTH: Final = 5 MAX_ALLOWED_USERNAME_LENGTH: Final = 30 diff --git a/src/conode/infrastructure/config.py b/src/conode/infrastructure/config.py index cafaf40..8434a53 100644 --- a/src/conode/infrastructure/config.py +++ b/src/conode/infrastructure/config.py @@ -14,9 +14,9 @@ class APIConfig: @dataclass(slots=True, frozen=True, kw_only=True) class SecretsConfig: - secret: str expires_in: int - jwks_url: str + public_key: str + private_key: str audience: str issuer: str @@ -48,6 +48,17 @@ def load_config(path: str = "config.toml") -> Config: config_path = Path(path) with config_path.open("rb") as file: config = tomllib.load(file) + + private_key_file_path = Path(config["secrets"]["private_key_file_path"]) + public_key_file_path = Path(config["secrets"]["public_key_file_path"]) + + with ( + private_key_file_path.open("r") as private_key_file, + public_key_file_path.open("r") as public_key_file, + ): + private_key = private_key_file.read() + public_key = public_key_file.read() + return Config( api=APIConfig( host=config["api"]["host"], @@ -62,9 +73,9 @@ def load_config(path: str = "config.toml") -> Config: port=config["database"]["port"], ), secrets=SecretsConfig( - secret=config["secrets"]["secret"], expires_in=config["secrets"]["expires_in"], - jwks_url=config["secrets"]["jwks_url"], + public_key=public_key, + private_key=private_key, audience=config["secrets"]["audience"], issuer=config["secrets"]["issuer"], ), diff --git a/src/conode/infrastructure/identity_provider.py b/src/conode/infrastructure/identity_provider.py index 1386f8f..433048a 100644 --- a/src/conode/infrastructure/identity_provider.py +++ b/src/conode/infrastructure/identity_provider.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from typing import Final import structlog from fastapi import Request @@ -6,15 +7,17 @@ from conode.application.errors import FailedToReadClientError, InvalidTokenError from conode.application.interfaces.identity_provider import IdentityProvider -from conode.application.interfaces.token_manager import TokenManager, UserMeta +from conode.application.interfaces.token_managers import AccessTokenManager, UserMeta logger = structlog.get_logger() +AUTHORIZATION_HEADER_NAME: Final = "Authorization" + @dataclass class IdentityProviderImpl(IdentityProvider): request: Request - access_token_manager: TokenManager + access_token_manager: AccessTokenManager def get_current_ip(self) -> str: if self.request.client is None: @@ -23,7 +26,7 @@ def get_current_ip(self) -> str: return self.request.client.host def get_current_user_meta(self) -> UserMeta: - token = self.request.headers.get("X-Forwarded-Access-Token") + token = self.request.headers.get(AUTHORIZATION_HEADER_NAME) if token is None: raise InvalidTokenError("Authorization token not found", None) @@ -32,7 +35,7 @@ def get_current_user_meta(self) -> UserMeta: except PyJWTError as e: raise InvalidTokenError( "Invalid authorization token", - [{"key": "Authorization", "value": token}], + [{"key": AUTHORIZATION_HEADER_NAME, "value": token}], ) from e return content diff --git a/src/conode/infrastructure/password_hasher.py b/src/conode/infrastructure/password_hasher.py index 753a802..b1f714b 100644 --- a/src/conode/infrastructure/password_hasher.py +++ b/src/conode/infrastructure/password_hasher.py @@ -4,6 +4,7 @@ from argon2.exceptions import Argon2Error, InvalidHashError from conode.application.interfaces.password_hasher import PasswordHasher +from conode.domain.auth import HashedPassword @dataclass @@ -13,8 +14,8 @@ class PasswordHasherImpl(PasswordHasher): def hash(self, password: str) -> str: return self._hasher.hash(password) - def verify(self, hashed_password: str, password: str) -> bool: + def verify(self, hashed_password: HashedPassword, password: str) -> bool: try: - return self._hasher.verify(hashed_password, password) + return self._hasher.verify(hashed_password.value, password) except (InvalidHashError, Argon2Error): return False diff --git a/src/conode/infrastructure/persistence/migrations/versions/b5793b879b16_restore_auth_models.py b/src/conode/infrastructure/persistence/migrations/versions/b5793b879b16_restore_auth_models.py new file mode 100644 index 0000000..a60107d --- /dev/null +++ b/src/conode/infrastructure/persistence/migrations/versions/b5793b879b16_restore_auth_models.py @@ -0,0 +1,50 @@ +"""restore-auth-models + +Revision ID: b5793b879b16 +Revises: d196baf978b0 +Create Date: 2026-08-13 17:35:52.427192 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'b5793b879b16' +down_revision: Union[str, Sequence[str], None] = 'd196baf978b0' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('authorization_record', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('user_id', sa.UUID(), nullable=False), + sa.Column('hashed_password', sa.String(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user_record.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('session_record', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('user_id', sa.UUID(), nullable=False), + sa.Column('access_token', sa.String(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user_record.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('session_record') + op.drop_table('authorization_record') + # ### end Alembic commands ### diff --git a/src/conode/infrastructure/persistence/migrations/versions/be8366314bd6_object_with_validation.py b/src/conode/infrastructure/persistence/migrations/versions/be8366314bd6_object_with_validation.py new file mode 100644 index 0000000..9550993 --- /dev/null +++ b/src/conode/infrastructure/persistence/migrations/versions/be8366314bd6_object_with_validation.py @@ -0,0 +1,32 @@ +"""object-with-validation + +Revision ID: be8366314bd6 +Revises: b5793b879b16 +Create Date: 2026-08-13 17:59:49.456769 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'be8366314bd6' +down_revision: Union[str, Sequence[str], None] = 'b5793b879b16' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/src/conode/infrastructure/persistence/migrations/versions/bf7551bea9b4_refresh_token_and_unique_constrait.py b/src/conode/infrastructure/persistence/migrations/versions/bf7551bea9b4_refresh_token_and_unique_constrait.py new file mode 100644 index 0000000..fd84d5b --- /dev/null +++ b/src/conode/infrastructure/persistence/migrations/versions/bf7551bea9b4_refresh_token_and_unique_constrait.py @@ -0,0 +1,36 @@ +"""refresh-token-and-unique-constrait + +Revision ID: bf7551bea9b4 +Revises: be8366314bd6 +Create Date: 2026-08-17 12:43:15.774966 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'bf7551bea9b4' +down_revision: Union[str, Sequence[str], None] = 'be8366314bd6' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('session_record', sa.Column('ip', sa.String(), nullable=False)) + op.add_column('session_record', sa.Column('refresh_token', sa.String(), nullable=False)) + op.create_unique_constraint('uq_session_record_user_id_ip', 'session_record', ['user_id', 'ip']) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint('uq_session_record_user_id_ip', 'session_record', type_='unique') + op.drop_column('session_record', 'refresh_token') + op.drop_column('session_record', 'ip') + # ### end Alembic commands ### diff --git a/src/conode/infrastructure/persistence/registry.py b/src/conode/infrastructure/persistence/registry.py index 3e8def9..5d948b4 100644 --- a/src/conode/infrastructure/persistence/registry.py +++ b/src/conode/infrastructure/persistence/registry.py @@ -7,11 +7,13 @@ Float, ForeignKey, MetaData, + String, Table, UniqueConstraint, ) from sqlalchemy.orm import registry +from conode.domain.auth import Authorization, Session from conode.domain.company import Company from conode.domain.context import Context from conode.domain.contract import Contract, ContractStatus @@ -39,6 +41,7 @@ FirstNameType, GroupDescriptionType, GroupNameType, + HashedPasswordType, LastNameType, NodeDescriptionType, NodeNameType, @@ -66,6 +69,29 @@ Column("updated_at", DateTime(timezone=True), nullable=False), ) +authorization_record_table = Table( + "authorization_record", + metadata, + Column("id", UUID, primary_key=True, nullable=False), + Column("user_id", ForeignKey("user_record.id", ondelete="CASCADE"), nullable=False), + Column("hashed_password", HashedPasswordType, nullable=False), + Column("created_at", DateTime(timezone=True), nullable=False), + Column("updated_at", DateTime(timezone=True), nullable=False), +) + +session_record_table = Table( + "session_record", + metadata, + Column("id", UUID, primary_key=True, nullable=False), + Column("ip", String, primary_key=True, nullable=False), + Column("user_id", ForeignKey("user_record.id", ondelete="CASCADE"), nullable=False), + Column("access_token", String, nullable=False), + Column("refresh_token", String, nullable=False), + Column("created_at", DateTime(timezone=True), nullable=False), + Column("updated_at", DateTime(timezone=True), nullable=False), + UniqueConstraint("user_id", "ip", name="uq_session_record_user_id_ip"), +) + company_record_table = Table( "company_record", metadata, @@ -379,3 +405,5 @@ def start_mapper() -> None: registry_mapper.map_imperatively(OfferGroup, offer_group_record_table) registry_mapper.map_imperatively(OfferLink, offer_link_record_table) registry_mapper.map_imperatively(Contract, contract_record_table) + registry_mapper.map_imperatively(Session, session_record_table) + registry_mapper.map_imperatively(Authorization, authorization_record_table) diff --git a/src/conode/infrastructure/persistence/types.py b/src/conode/infrastructure/persistence/types.py index c0527c7..2d5cdb3 100644 --- a/src/conode/infrastructure/persistence/types.py +++ b/src/conode/infrastructure/persistence/types.py @@ -3,6 +3,7 @@ from sqlalchemy import Dialect, String from sqlalchemy.types import TypeDecorator +from conode.domain.auth import HashedPassword from conode.domain.company import CompanyDescription, CompanyName from conode.domain.context import ContextDescription, ContextName from conode.domain.group import GroupDescription, GroupName @@ -122,3 +123,9 @@ class OfferDescriptionType(BaseVOTypeDecorator[OfferDescription]): impl = String cache_ok = True vo_class = OfferDescription + + +class HashedPasswordType(BaseVOTypeDecorator[HashedPassword]): + impl = String + cache_ok = True + vo_class = HashedPassword diff --git a/src/conode/infrastructure/repositories/__init__.py b/src/conode/infrastructure/repositories/__init__.py index a0b0e75..4321c46 100644 --- a/src/conode/infrastructure/repositories/__init__.py +++ b/src/conode/infrastructure/repositories/__init__.py @@ -1,3 +1,4 @@ +from .auth import AuthorizationRepositoryImpl, SessionRepositoryImpl from .company import CompanyRepositoryImpl from .context import ContextRepositoryImpl from .contract import ContractRepositoryImpl @@ -16,6 +17,7 @@ from .user import UserRepositoryImpl __all__ = ( + "AuthorizationRepositoryImpl", "CompanyRepositoryImpl", "ContextRepositoryImpl", "ContractRepositoryImpl", @@ -29,6 +31,7 @@ "OfferRepositoryImpl", "RolePermissionsRepositoryImpl", "RoleRepositoryImpl", + "SessionRepositoryImpl", "UserGrantRepositoryImpl", "UserRepositoryImpl", ) diff --git a/src/conode/infrastructure/repositories/auth/__init__.py b/src/conode/infrastructure/repositories/auth/__init__.py new file mode 100644 index 0000000..47c4292 --- /dev/null +++ b/src/conode/infrastructure/repositories/auth/__init__.py @@ -0,0 +1,7 @@ +from .authorization import AuthorizationRepositoryImpl +from .session import SessionRepositoryImpl + +__all__ = ( + "AuthorizationRepositoryImpl", + "SessionRepositoryImpl", +) diff --git a/src/conode/infrastructure/repositories/auth/authorization.py b/src/conode/infrastructure/repositories/auth/authorization.py new file mode 100644 index 0000000..ecc3216 --- /dev/null +++ b/src/conode/infrastructure/repositories/auth/authorization.py @@ -0,0 +1,53 @@ +from dataclasses import dataclass + +import structlog +from sqlalchemy import insert, select +from sqlalchemy.ext.asyncio import AsyncSession + +from conode.application.errors import AuthorizationNotFoundError +from conode.application.interfaces.repositories import AuthorizationRepository +from conode.domain.auth import Authorization +from conode.domain.user.model import User + +logger = structlog.get_logger() + + +@dataclass +class AuthorizationRepositoryImpl(AuthorizationRepository): + session: AsyncSession + + async def create(self, authorization: Authorization) -> None: + logger.debug( + "Repository create authorization", authorization_id=authorization.id + ) + await self.session.execute( + insert(Authorization).values( + id=authorization.id, + user_id=authorization.user_id, + hashed_password=authorization.hashed_password, + created_at=authorization.created_at, + updated_at=authorization.updated_at, + ), + ) + + async def get_by_user(self, user: User) -> Authorization: + logger.debug("Repository get authorization by user", user_id=user.id) + authorization = await self.session.execute( + select(Authorization).where( + Authorization.user_id == user.id # type: ignore + ), + ) + + result = authorization.scalar_one_or_none() + logger.debug( + "Repository fetched authorization by user", + found=result is not None, + ) + + if result is None: + raise AuthorizationNotFoundError( + "Authorization associated with this user not found", + meta=[{"key": "id", "value": user.id}], + ) + + return result diff --git a/src/conode/infrastructure/repositories/auth/session.py b/src/conode/infrastructure/repositories/auth/session.py new file mode 100644 index 0000000..3c21f2e --- /dev/null +++ b/src/conode/infrastructure/repositories/auth/session.py @@ -0,0 +1,86 @@ +from dataclasses import dataclass + +import structlog +from sqlalchemy import and_, insert, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from conode.application.errors import SessionNotFoundError +from conode.application.interfaces.repositories import SessionRepository +from conode.domain.auth import Session +from conode.domain.user import User + +logger = structlog.get_logger() + + +@dataclass +class SessionRepositoryImpl(SessionRepository): + session: AsyncSession + + async def create(self, session: Session) -> None: + logger.debug("Repository create session", session_id=session.id) + await self.session.execute( + insert(Session).values( + id=session.id, + ip=session.ip, + user_id=session.user_id, + refresh_token=session.refresh_token, + access_token=session.access_token, + created_at=session.created_at, + updated_at=session.updated_at, + ), + ) + + async def update(self, session: Session) -> None: + logger.debug("Repository update session", session_id=session.id) + await self.session.execute( + update(Session) + .where( + Session.id == session.id, # type: ignore + ) + .values( + access_token=session.access_token, + refresh_token=session.refresh_token, + updated_at=session.updated_at, + ), + ) + + async def get_by_refresh_token(self, token: str) -> Session: + logger.debug("Repository get session by refresh token", token=token) + session = await self.session.execute( + select(Session).where( + Session.refresh_token == token # type: ignore + ), + ) + + result = session.scalar_one_or_none() + logger.debug( + "Repository fetched session by ip and user", + found=result is not None, + ) + + if result is None: + raise SessionNotFoundError( + "Session associated with this refresh token not found", + meta=[{"key": "token", "value": token}], + ) + + return result + + async def get_by_ip_and_user(self, user: User, ip: str) -> Session | None: + logger.debug("Repository get session by ip and user", ip=ip, user_id=user.id) + session = await self.session.execute( + select(Session).where( + and_( + Session.ip == ip, # type: ignore + Session.user_id == user.id, # type: ignore + ) + ), + ) + + result = session.scalar_one_or_none() + logger.debug( + "Repository fetched session by ip and user", + found=result is not None, + ) + + return result diff --git a/src/conode/infrastructure/repositories/company.py b/src/conode/infrastructure/repositories/company.py index 3bbe13b..11a46bb 100644 --- a/src/conode/infrastructure/repositories/company.py +++ b/src/conode/infrastructure/repositories/company.py @@ -49,6 +49,7 @@ async def update(self, company: Company) -> None: description=company.description, verified=company.verified, owner_id=company.owner_id, + updated_at=company.updated_at, ), ) diff --git a/src/conode/infrastructure/token_manager.py b/src/conode/infrastructure/token_manager.py deleted file mode 100644 index 83a34fd..0000000 --- a/src/conode/infrastructure/token_manager.py +++ /dev/null @@ -1,40 +0,0 @@ -import jwt - -from conode.application.interfaces.token_manager import ( - TokenManager, - UserMeta, -) -from conode.domain.user import UserSystemRole -from conode.domain.user.model import User -from conode.infrastructure.config import SecretsConfig - - -class TokenManagerImpl(TokenManager): - def __init__(self, config: SecretsConfig) -> None: - self._client = jwt.PyJWKClient(config.jwks_url) - self._config = config - - def encode(self, user: User) -> str: - raise NotImplementedError - - def decode(self, token: str) -> UserMeta: - signing_key = self._client.get_signing_key_from_jwt(token) - payload = jwt.decode( - token, - signing_key.key, - algorithms=["RS256"], - audience=self._config.audience, - issuer=self._config.issuer, - ) - role = UserSystemRole.USER - if "realm-admin" in payload["resource_access"]["realm-management"]["roles"]: - role = UserSystemRole.ADMIN - - return UserMeta( - email=payload["email"], - first_name=payload["given_name"], - last_name=payload["family_name"], - username=payload["preferred_username"], - email_verified=payload["email_verified"], - system_role=role, - ) diff --git a/src/conode/infrastructure/token_managers/__init__.py b/src/conode/infrastructure/token_managers/__init__.py new file mode 100644 index 0000000..c7d5612 --- /dev/null +++ b/src/conode/infrastructure/token_managers/__init__.py @@ -0,0 +1,7 @@ +from .access import AccessTokenManagerImpl +from .refresh import RefreshTokenManagerImpl + +__all__ = ( + "AccessTokenManagerImpl", + "RefreshTokenManagerImpl", +) diff --git a/src/conode/infrastructure/token_managers/access.py b/src/conode/infrastructure/token_managers/access.py new file mode 100644 index 0000000..1ae5661 --- /dev/null +++ b/src/conode/infrastructure/token_managers/access.py @@ -0,0 +1,48 @@ +from datetime import UTC, datetime, timedelta + +import jwt + +from conode.application.interfaces.token_managers import ( + AccessTokenManager, + AccessTokenManagerResponse, + UserMeta, +) +from conode.domain.user.model import User, UserSystemRole +from conode.infrastructure.config import SecretsConfig + + +class AccessTokenManagerImpl(AccessTokenManager): + def __init__(self, config: SecretsConfig) -> None: + self.config = config + + def encode(self, user: User) -> AccessTokenManagerResponse: + now = datetime.now(UTC) + token = jwt.encode( + { + "sub": str(user.id), + "email": str(user.email.value), + "role": str(user.system_role), + "iss": self.config.issuer, + "aud": self.config.audience, + "exp": now + timedelta(seconds=self.config.expires_in), + }, + self.config.private_key, + algorithm="RS256", + ) + return AccessTokenManagerResponse( + expires_in=self.config.expires_in, + token=token, + ) + + def decode(self, token: str) -> UserMeta: + payload = jwt.decode( + token, + key=self.config.public_key, + audience=self.config.audience, + algorithms=["RS256"], + ) + + return UserMeta( + email=payload["email"], + system_role=UserSystemRole[payload["role"]], + ) diff --git a/src/conode/infrastructure/token_managers/refresh.py b/src/conode/infrastructure/token_managers/refresh.py new file mode 100644 index 0000000..f88985c --- /dev/null +++ b/src/conode/infrastructure/token_managers/refresh.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass +from secrets import token_urlsafe + +from conode.application.interfaces.token_managers import RefreshTokenManager + + +@dataclass +class RefreshTokenManagerImpl(RefreshTokenManager): + def encode(self) -> str: + return token_urlsafe(32) diff --git a/src/conode/presentation/common.py b/src/conode/presentation/common.py index dbf4319..eb4100c 100644 --- a/src/conode/presentation/common.py +++ b/src/conode/presentation/common.py @@ -8,6 +8,7 @@ ) from conode.presentation.middlewares import LoggerMiddleware from conode.presentation.views import ( + auth, company, context, edge, @@ -32,6 +33,7 @@ def include_handlers(app: FastAPI) -> None: app.include_router(user) app.include_router(node) app.include_router(edge) + app.include_router(auth) def include_middlewares(app: FastAPI) -> None: diff --git a/src/conode/presentation/schemas/auth.py b/src/conode/presentation/schemas/auth.py new file mode 100644 index 0000000..19850cb --- /dev/null +++ b/src/conode/presentation/schemas/auth.py @@ -0,0 +1,67 @@ +from typing import Annotated + +from pydantic import BaseModel, EmailStr, Field, SecretStr + +from conode.domain.auth.authorization.model import ( + MAX_HASHED_PASSWORD_ALLOWED_LENGTH, + MIN_HASHED_PASSWORD_ALLOWED_LENGTH, +) +from conode.domain.user.model import ( + MAX_ALLOWED_BIO_LENGTH, + MAX_ALLOWED_FIRST_NAME_LENGTH, + MAX_ALLOWED_LAST_NAME_LENGTH, + MAX_ALLOWED_USERNAME_LENGTH, + MIN_ALLOWED_FIRST_NAME_LENGTH, + MIN_ALLOWED_LAST_NAME_LENGTH, + MIN_ALLOWED_USERNAME_LENGTH, +) + + +class RegisterRequest(BaseModel): + username: Annotated[ + str, + Field( + min_length=MIN_ALLOWED_USERNAME_LENGTH, + max_length=MAX_ALLOWED_USERNAME_LENGTH, + ), + ] + first_name: Annotated[ + str, + Field( + min_length=MIN_ALLOWED_FIRST_NAME_LENGTH, + max_length=MAX_ALLOWED_FIRST_NAME_LENGTH, + ), + ] + last_name: Annotated[ + str, + Field( + min_length=MIN_ALLOWED_LAST_NAME_LENGTH, + max_length=MAX_ALLOWED_LAST_NAME_LENGTH, + ), + ] + bio: Annotated[str, Field(max_length=MAX_ALLOWED_BIO_LENGTH)] + email: EmailStr + password: Annotated[ + SecretStr, + Field( + min_length=MIN_HASHED_PASSWORD_ALLOWED_LENGTH, + max_length=MAX_HASHED_PASSWORD_ALLOWED_LENGTH, + ), + ] + + +class LoginRequest(BaseModel): + email: EmailStr + password: Annotated[ + SecretStr, + Field( + min_length=MIN_HASHED_PASSWORD_ALLOWED_LENGTH, + max_length=MAX_HASHED_PASSWORD_ALLOWED_LENGTH, + ), + ] + + +class AuthorizedResponse(BaseModel): + access_token: str + refresh_token: str + expires_in: int diff --git a/src/conode/presentation/views/__init__.py b/src/conode/presentation/views/__init__.py index d76ac65..a1e07ef 100644 --- a/src/conode/presentation/views/__init__.py +++ b/src/conode/presentation/views/__init__.py @@ -1,3 +1,4 @@ +from .auth import router as auth from .company import router as company from .context import router as context from .edge import router as edge @@ -9,6 +10,7 @@ from .user import router as user __all__ = ( + "auth", "company", "context", "edge", diff --git a/src/conode/presentation/views/auth.py b/src/conode/presentation/views/auth.py new file mode 100644 index 0000000..58a80e4 --- /dev/null +++ b/src/conode/presentation/views/auth.py @@ -0,0 +1,56 @@ +from dishka.integrations.fastapi import DishkaRoute, FromDishka +from fastapi import APIRouter + +from conode.application.auth import ( + LoginInteractor, + LoginRequestDTO, + RegisterInteractor, + RegisterRequestDTO, +) +from conode.presentation.schemas.auth import ( + AuthorizedResponse, + LoginRequest, + RegisterRequest, +) + +router = APIRouter(prefix="/auth", tags=["Authorization"], route_class=DishkaRoute) + + +@router.post("/register") +async def register( + interactor: FromDishka[RegisterInteractor], request: RegisterRequest +) -> AuthorizedResponse: + result = await interactor.execute( + RegisterRequestDTO( + username=request.username, + password=request.password.get_secret_value(), + first_name=request.first_name, + last_name=request.last_name, + email=request.email, + bio=request.bio, + ) + ) + + return AuthorizedResponse( + access_token=result.access_token, + refresh_token=result.refresh_token, + expires_in=result.expires_in, + ) + + +@router.post("/login") +async def login( + interactor: FromDishka[LoginInteractor], request: LoginRequest +) -> AuthorizedResponse: + result = await interactor.execute( + LoginRequestDTO( + password=request.password.get_secret_value(), + email=request.email, + ) + ) + + return AuthorizedResponse( + access_token=result.access_token, + refresh_token=result.refresh_token, + expires_in=result.expires_in, + ) diff --git a/tests/conftest.py b/tests/conftest.py index 0d49c92..1213517 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,7 +2,6 @@ from dataclasses import dataclass from types import TracebackType -import jwt import pytest from dishka import ( AsyncContainer, @@ -21,8 +20,10 @@ create_async_engine, ) +from conode.application.interfaces.identity_provider import IdentityProvider from conode.application.interfaces.password_hasher import PasswordHasher from conode.application.interfaces.repositories import ( + AuthorizationRepository, CompanyRepository, ContextRepository, EdgeRepository, @@ -31,21 +32,21 @@ NodeRepository, RolePermissionsRepository, RoleRepository, + SessionRepository, UserGrantRepository, UserRepository, ) -from conode.application.interfaces.token_manager import ( - TokenManager, - UserMeta, +from conode.application.interfaces.token_managers import ( + AccessTokenManager, + RefreshTokenManager, ) from conode.application.interfaces.transaction_manager import TransactionManager -from conode.application.services import RoleManagmentService +from conode.application.services import AuthorizationService, RoleManagmentService from conode.bootstrap.api.run import create_app from conode.bootstrap.di.providers import ( ApplicationProvider, InfrastructureProvider, ) -from conode.domain.user import User, UserSystemRole from conode.infrastructure.config import ( APIConfig, Config, @@ -65,46 +66,6 @@ ) -@dataclass -class MockTokenManager(TokenManager): - config: SecretsConfig - - def decode(self, token: str) -> UserMeta: - payload = jwt.decode( - token, - key=self.config.secret, - algorithms=["HS256"], - ) - role = UserSystemRole.USER - if "realm-admin" in payload["resource_access"]["realm-management"]["roles"]: - role = UserSystemRole.ADMIN - - return UserMeta( - email=payload["email"], - first_name=payload["given_name"], - last_name=payload["family_name"], - email_verified=payload["email_verified"], - username=payload["preferred_username"], - system_role=role, - ) - - def encode(self, user: User, *, admin: bool = False) -> str: - return jwt.encode( - payload={ - "email": user.email.value, - "given_name": user.first_name.value, - "family_name": user.last_name.value, - "email_verified": user.email_verified, - "preferred_username": user.username.value, - "resource_access": { - "realm-management": {"roles": ["realm-admin" if admin else "user"]} - }, - }, - algorithm="HS256", - key=self.config.secret, - ) - - @pytest.fixture(scope="session") def config() -> Config: return load_config("test.config.toml") @@ -222,7 +183,12 @@ async def context_factory(container: AsyncContainer) -> ContextFactory: async def user_factory(container: AsyncContainer) -> UserFactory: async with container() as test_container: return UserFactory( - token_manager=await test_container.get(TokenManager), + identity_provider=await test_container.get(IdentityProvider), + authorization_service=await test_container.get(AuthorizationService), + authorization_repository=await test_container.get(AuthorizationRepository), + session_repository=await test_container.get(SessionRepository), + refresh_token_manager=await test_container.get(RefreshTokenManager), + access_token_manager=await test_container.get(AccessTokenManager), transaction_manager=await test_container.get(TransactionManager), password_hasher=await test_container.get(PasswordHasher), user_repository=await test_container.get(UserRepository), @@ -303,15 +269,11 @@ class TestConnectionProvider(Provider): async def provide_async_session(self) -> AsyncSession: return session - class TestSecretsProvider(Provider): - provides = provide_all(WithParents[MockTokenManager], scope=Scope.REQUEST) - container = make_async_container( FastapiProvider(), ApplicationProvider(), TestConnectionProvider(), InfrastructureProvider(), - TestSecretsProvider(), context={ APIConfig: config.api, DatabaseConfig: config.database, diff --git a/tests/e2e/user/test_verified_email.py b/tests/e2e/user/test_verified_email.py deleted file mode 100644 index b6aed28..0000000 --- a/tests/e2e/user/test_verified_email.py +++ /dev/null @@ -1,29 +0,0 @@ -from http import HTTPStatus - -import pytest -from httpx import AsyncClient - -from tests.factories.common import authorization_headers -from tests.factories.models import UserFactory - - -@pytest.mark.asyncio -async def test_update_verified_flag_after_request_ok( - transport: AsyncClient, user_factory: UserFactory -) -> None: - user = await user_factory.build() - - response = await transport.get( - "users/me/profile", headers=authorization_headers(user.access_token) - ) - assert response.status_code == HTTPStatus.OK - assert not response.json()["email_verified"] - - user.user.email_verified = True - user.access_token = user_factory.generate_access_token(user.user) - - response = await transport.get( - "users/me/profile", headers=authorization_headers(user.access_token) - ) - assert response.status_code == HTTPStatus.OK - assert response.json()["email_verified"] diff --git a/tests/factories/common.py b/tests/factories/common.py index ec5c616..d4c181a 100644 --- a/tests/factories/common.py +++ b/tests/factories/common.py @@ -29,4 +29,4 @@ def generate_random_ip() -> str: def authorization_headers(access_token: str) -> dict[str, str]: - return {"X-Forwarded-Access-Token": access_token} + return {"Authorization": access_token} diff --git a/tests/factories/models/user.py b/tests/factories/models/user.py index 0f1ba4d..90779c8 100644 --- a/tests/factories/models/user.py +++ b/tests/factories/models/user.py @@ -2,10 +2,19 @@ from datetime import timedelta from uuid import uuid4 +from conode.application.interfaces.identity_provider import IdentityProvider from conode.application.interfaces.password_hasher import PasswordHasher -from conode.application.interfaces.repositories import UserRepository -from conode.application.interfaces.token_manager import TokenManager +from conode.application.interfaces.repositories import ( + AuthorizationRepository, + SessionRepository, + UserRepository, +) +from conode.application.interfaces.token_managers import ( + AccessTokenManager, + RefreshTokenManager, +) from conode.application.interfaces.transaction_manager import TransactionManager +from conode.application.services import AuthorizationService from conode.domain.user import User, UserId, UserSystemRole from tests.factories.common import generate_random_string @@ -14,17 +23,23 @@ class UserFactoryResponse: user: User access_token: str + password: str @dataclass class UserFactory: - token_manager: TokenManager + authorization_repository: AuthorizationRepository + session_repository: SessionRepository + authorization_service: AuthorizationService + refresh_token_manager: RefreshTokenManager + access_token_manager: AccessTokenManager transaction_manager: TransactionManager user_repository: UserRepository + identity_provider: IdentityProvider password_hasher: PasswordHasher def generate_access_token(self, user: User) -> str: - return self.token_manager.encode(user) + return self.access_token_manager.encode(user).token async def build( self, @@ -56,9 +71,23 @@ async def build( user.email_verified = verified + password = generate_random_string(15) + authorization_service_response = ( + self.authorization_service.generate_authorization_stuff( + user, + password, + self.identity_provider.get_current_ip(), + ) + ) + await self.user_repository.create(user) + await self.authorization_repository.create( + authorization_service_response.authorization + ) + await self.session_repository.create(authorization_service_response.session) return UserFactoryResponse( user=user, access_token=self.generate_access_token(user), + password=password, ) From 273c5b600723766d8b1a72e472193dbefc75722c Mon Sep 17 00:00:00 2001 From: Davlat Davydov Date: Thu, 20 Aug 2026 01:35:08 +0300 Subject: [PATCH 10/13] feat: custom A/A flow --- src/conode/application/auth/__init__.py | 2 + src/conode/application/auth/login.py | 15 +--- src/conode/application/auth/refresh_token.py | 23 ++--- src/conode/application/auth/register.py | 2 +- .../interfaces/identity_provider.py | 1 - .../application/interfaces/password_hasher.py | 4 +- .../interfaces/repositories/auth/session.py | 1 + .../application/services/authorization.py | 4 +- .../application/update_node/update_node.py | 2 + .../bootstrap/di/providers/application.py | 7 +- src/conode/domain/auth/__init__.py | 4 +- .../domain/auth/authorization/__init__.py | 4 +- .../domain/auth/authorization/errors.py | 7 -- src/conode/domain/auth/authorization/model.py | 30 +------ src/conode/domain/auth/session/model.py | 6 -- src/conode/infrastructure/password_hasher.py | 5 +- .../807cb62be0ae_unique_username_email.py | 30 +++++++ .../b210c4de87de_remove_access_token.py | 32 +++++++ .../infrastructure/persistence/registry.py | 6 +- .../infrastructure/persistence/types.py | 7 -- .../repositories/auth/session.py | 22 ++++- .../infrastructure/repositories/user.py | 40 +++++---- src/conode/presentation/schemas/auth.py | 27 +++--- src/conode/presentation/views/auth.py | 23 ++++- tests/conftest.py | 18 +++- tests/e2e/auth/__init__.py | 0 tests/e2e/auth/test_login.py | 52 ++++++++++++ tests/e2e/auth/test_refresh_token.py | 52 ++++++++++++ tests/e2e/auth/test_register.py | 84 +++++++++++++++++++ tests/factories/models/user.py | 8 +- tests/factories/schemas/__init__.py | 8 ++ tests/factories/schemas/auth.py | 17 ++++ 32 files changed, 414 insertions(+), 129 deletions(-) delete mode 100644 src/conode/domain/auth/authorization/errors.py create mode 100644 src/conode/infrastructure/persistence/migrations/versions/807cb62be0ae_unique_username_email.py create mode 100644 src/conode/infrastructure/persistence/migrations/versions/b210c4de87de_remove_access_token.py create mode 100644 tests/e2e/auth/__init__.py create mode 100644 tests/e2e/auth/test_login.py create mode 100644 tests/e2e/auth/test_refresh_token.py create mode 100644 tests/e2e/auth/test_register.py create mode 100644 tests/factories/schemas/auth.py diff --git a/src/conode/application/auth/__init__.py b/src/conode/application/auth/__init__.py index b010e6e..6407e8e 100644 --- a/src/conode/application/auth/__init__.py +++ b/src/conode/application/auth/__init__.py @@ -1,4 +1,5 @@ from .login import LoginInteractor, LoginRequestDTO +from .refresh_token import RefreshTokenInteractor from .register import RegisterInteractor, RegisterRequestDTO from .shared import AuthorizedResponseDTO @@ -6,6 +7,7 @@ "AuthorizedResponseDTO", "LoginInteractor", "LoginRequestDTO", + "RefreshTokenInteractor", "RegisterInteractor", "RegisterRequestDTO", ) diff --git a/src/conode/application/auth/login.py b/src/conode/application/auth/login.py index d82a6e3..3d74854 100644 --- a/src/conode/application/auth/login.py +++ b/src/conode/application/auth/login.py @@ -58,21 +58,10 @@ async def execute(self, request: LoginRequestDTO) -> AuthorizedResponseDTO: current_user_ip, ) ) - session = await self.session_repository.get_by_ip_and_user( - user, current_user_ip - ) - if session is None: - await self.session_repository.create( - authorization_service_response.session - ) - else: - session.set_access_token( - authorization_service_response.session.access_token - ) - await self.session_repository.update(session) + await self.session_repository.upsert(authorization_service_response.session) return AuthorizedResponseDTO( - access_token=authorization_service_response.session.access_token, + access_token=authorization_service_response.access_token, refresh_token=authorization_service_response.refresh_token, expires_in=authorization_service_response.expires_in, ) diff --git a/src/conode/application/auth/refresh_token.py b/src/conode/application/auth/refresh_token.py index 3efffc3..affd479 100644 --- a/src/conode/application/auth/refresh_token.py +++ b/src/conode/application/auth/refresh_token.py @@ -2,13 +2,13 @@ from conode.application.auth.shared import AuthorizedResponseDTO from conode.application.interfaces.identity_provider import IdentityProvider -from conode.application.interfaces.repositories import SessionRepository +from conode.application.interfaces.repositories import SessionRepository, UserRepository from conode.application.interfaces.token_managers import ( AccessTokenManager, RefreshTokenManager, ) from conode.application.interfaces.transaction_manager import TransactionManager -from conode.application.services import AccessControlService, AuthorizationService +from conode.application.services import AuthorizationService @dataclass @@ -18,28 +18,23 @@ class RefreshTokenInteractor: transaction_manager: TransactionManager refresh_token_manager: RefreshTokenManager access_token_manager: AccessTokenManager - access_control_service: AccessControlService authorization_service: AuthorizationService + user_repository: UserRepository - async def execute(self) -> AuthorizedResponseDTO: + async def execute(self, refresh_token: str) -> AuthorizedResponseDTO: async with self.transaction_manager: - current_refresh_token = self.identity_provider.get_current_refresh_token() - - user = await self.access_control_service.get_authorized_user() - session = await self.session_repository.get_by_refresh_token( - current_refresh_token - ) + session = await self.session_repository.get_by_refresh_token(refresh_token) + user = await self.user_repository.get_by_id(session.user_id) access_token, expires_in = self.access_token_manager.encode(user) - refresh_token = self.refresh_token_manager.encode() + new_refresh_token = self.refresh_token_manager.encode() - session.set_access_token(access_token) - session.set_refresh_token(refresh_token) + session.set_refresh_token(new_refresh_token) await self.session_repository.update(session) return AuthorizedResponseDTO( access_token=access_token, - refresh_token=refresh_token, + refresh_token=new_refresh_token, expires_in=expires_in, ) diff --git a/src/conode/application/auth/register.py b/src/conode/application/auth/register.py index b718727..867e9c4 100644 --- a/src/conode/application/auth/register.py +++ b/src/conode/application/auth/register.py @@ -74,7 +74,7 @@ async def execute(self, request: RegisterRequestDTO) -> AuthorizedResponseDTO: await self.session_repository.create(authorization_service_response.session) return AuthorizedResponseDTO( - access_token=authorization_service_response.session.access_token, + access_token=authorization_service_response.access_token, refresh_token=authorization_service_response.refresh_token, expires_in=authorization_service_response.expires_in, ) diff --git a/src/conode/application/interfaces/identity_provider.py b/src/conode/application/interfaces/identity_provider.py index 91364ad..e0b6a7a 100644 --- a/src/conode/application/interfaces/identity_provider.py +++ b/src/conode/application/interfaces/identity_provider.py @@ -5,5 +5,4 @@ class IdentityProvider(Protocol): def get_current_ip(self) -> str: ... - def get_current_refresh_token(self) -> str: ... def get_current_user_meta(self) -> UserMeta: ... diff --git a/src/conode/application/interfaces/password_hasher.py b/src/conode/application/interfaces/password_hasher.py index 11ac835..68c5dd3 100644 --- a/src/conode/application/interfaces/password_hasher.py +++ b/src/conode/application/interfaces/password_hasher.py @@ -1,8 +1,6 @@ from typing import Protocol -from conode.domain.auth import HashedPassword - class PasswordHasher(Protocol): def hash(self, password: str) -> str: ... - def verify(self, hashed_password: HashedPassword, password: str) -> bool: ... + def verify(self, hashed_password: str, password: str) -> bool: ... diff --git a/src/conode/application/interfaces/repositories/auth/session.py b/src/conode/application/interfaces/repositories/auth/session.py index 04cd088..06e8944 100644 --- a/src/conode/application/interfaces/repositories/auth/session.py +++ b/src/conode/application/interfaces/repositories/auth/session.py @@ -7,5 +7,6 @@ class SessionRepository(Protocol): async def create(self, session: Session) -> None: ... async def update(self, session: Session) -> None: ... + async def upsert(self, session: Session) -> None: ... async def get_by_ip_and_user(self, user: User, ip: str) -> Session | None: ... async def get_by_refresh_token(self, token: str) -> Session: ... diff --git a/src/conode/application/services/authorization.py b/src/conode/application/services/authorization.py index 01b1435..aa62072 100644 --- a/src/conode/application/services/authorization.py +++ b/src/conode/application/services/authorization.py @@ -16,6 +16,7 @@ class AuthorizationServiceResponse(NamedTuple): authorization: Authorization expires_in: int refresh_token: str + access_token: str @dataclass @@ -31,7 +32,7 @@ def generate_authorization_stuff( refresh_token = self.refresh_token_manager.encode() hashed_password = self.password_hasher.hash(password) - session = Session.new(SessionId(uuid4()), refresh_token, access_token, user, ip) + session = Session.new(SessionId(uuid4()), refresh_token, user, ip) authorization = Authorization.new( AuthorizationId(uuid4()), hashed_password, user ) @@ -40,5 +41,6 @@ def generate_authorization_stuff( session=session, authorization=authorization, expires_in=expires_in, + access_token=access_token, refresh_token=refresh_token, ) diff --git a/src/conode/application/update_node/update_node.py b/src/conode/application/update_node/update_node.py index 801d1c5..2b9356d 100644 --- a/src/conode/application/update_node/update_node.py +++ b/src/conode/application/update_node/update_node.py @@ -47,3 +47,5 @@ async def execute(self, request: UpdateNodeRequestDTO) -> None: node.set_name(request.name) node.set_description(request.description) + + await self.node_repository.update(node) diff --git a/src/conode/bootstrap/di/providers/application.py b/src/conode/bootstrap/di/providers/application.py index 3c0f4d5..319f12e 100644 --- a/src/conode/bootstrap/di/providers/application.py +++ b/src/conode/bootstrap/di/providers/application.py @@ -3,7 +3,11 @@ from conode.application.attach_node_to_group import ( AttachNodeInteractor, ) -from conode.application.auth import LoginInteractor, RegisterInteractor +from conode.application.auth import ( + LoginInteractor, + RefreshTokenInteractor, + RegisterInteractor, +) from conode.application.detach_node_from_group import ( DetachNodeInteractor, ) @@ -76,6 +80,7 @@ class ApplicationProvider(Provider): IncrementEdgeWeightInteractor, DecrementEdgeWeightInteractor, AuthorizationService, + RefreshTokenInteractor, RegisterInteractor, LoginInteractor, SendOfferToCompanyInteractor, diff --git a/src/conode/domain/auth/__init__.py b/src/conode/domain/auth/__init__.py index fe6d904..5dd0858 100644 --- a/src/conode/domain/auth/__init__.py +++ b/src/conode/domain/auth/__init__.py @@ -1,4 +1,4 @@ -from .authorization import Authorization, AuthorizationId, HashedPassword +from .authorization import Authorization, AuthorizationId from .session import Session, SessionId -__all__ = ("Authorization", "AuthorizationId", "HashedPassword", "Session", "SessionId") +__all__ = ("Authorization", "AuthorizationId", "Session", "SessionId") diff --git a/src/conode/domain/auth/authorization/__init__.py b/src/conode/domain/auth/authorization/__init__.py index 598c682..e7a1b53 100644 --- a/src/conode/domain/auth/authorization/__init__.py +++ b/src/conode/domain/auth/authorization/__init__.py @@ -1,3 +1,3 @@ -from .model import Authorization, AuthorizationId, HashedPassword +from .model import Authorization, AuthorizationId -__all__ = ("Authorization", "AuthorizationId", "HashedPassword") +__all__ = ("Authorization", "AuthorizationId") diff --git a/src/conode/domain/auth/authorization/errors.py b/src/conode/domain/auth/authorization/errors.py deleted file mode 100644 index 1c0e9dc..0000000 --- a/src/conode/domain/auth/authorization/errors.py +++ /dev/null @@ -1,7 +0,0 @@ -from conode.application.errors import ApplicationError - - -class AuthorizationDomainValidationError(ApplicationError): ... - - -class InvalidAuthorizationPasswordFormatError(AuthorizationDomainValidationError): ... diff --git a/src/conode/domain/auth/authorization/model.py b/src/conode/domain/auth/authorization/model.py index dba172d..202f97f 100644 --- a/src/conode/domain/auth/authorization/model.py +++ b/src/conode/domain/auth/authorization/model.py @@ -1,39 +1,17 @@ from dataclasses import dataclass from datetime import UTC, datetime -from typing import Final, NewType, Self +from typing import NewType, Self from uuid import UUID -from conode.domain.auth.authorization.errors import ( - InvalidAuthorizationPasswordFormatError, -) -from conode.domain.shared import Entity, ValueObject +from conode.domain.shared import Entity from conode.domain.user import User, UserId AuthorizationId = NewType("AuthorizationId", UUID) -MAX_HASHED_PASSWORD_ALLOWED_LENGTH: Final = 30 -MIN_HASHED_PASSWORD_ALLOWED_LENGTH: Final = 5 - - -class HashedPassword(ValueObject[str]): - def __init__(self, value: str) -> None: - if ( - MIN_HASHED_PASSWORD_ALLOWED_LENGTH - >= len(value) - >= MAX_HASHED_PASSWORD_ALLOWED_LENGTH - ): - raise InvalidAuthorizationPasswordFormatError( - "Company description length must be between" - f"{MAX_HASHED_PASSWORD_ALLOWED_LENGTH} and " - f"{MIN_HASHED_PASSWORD_ALLOWED_LENGTH}", - [{"key": "password", "value": value}], - ) - super().__init__(value) - @dataclass class Authorization(Entity[AuthorizationId]): - hashed_password: HashedPassword + hashed_password: str user_id: UserId @classmethod @@ -43,7 +21,7 @@ def new( now = datetime.now(UTC) return cls( id=authorization_id, - hashed_password=HashedPassword(hashed_password), + hashed_password=hashed_password, user_id=user.id, created_at=now, updated_at=now, diff --git a/src/conode/domain/auth/session/model.py b/src/conode/domain/auth/session/model.py index 8073fa5..1a7a70d 100644 --- a/src/conode/domain/auth/session/model.py +++ b/src/conode/domain/auth/session/model.py @@ -11,7 +11,6 @@ @dataclass class Session(Entity[SessionId]): - access_token: str refresh_token: str user_id: UserId ip: str @@ -21,7 +20,6 @@ def new( cls, session_id: SessionId, refresh_token: str, - access_token: str, user: User, ip: str, ) -> Self: @@ -29,15 +27,11 @@ def new( return cls( id=session_id, ip=ip, - access_token=access_token, refresh_token=refresh_token, user_id=user.id, created_at=now, updated_at=now, ) - def set_access_token(self, token: str) -> None: - self.access_token = token - def set_refresh_token(self, token: str) -> None: self.refresh_token = token diff --git a/src/conode/infrastructure/password_hasher.py b/src/conode/infrastructure/password_hasher.py index b1f714b..753a802 100644 --- a/src/conode/infrastructure/password_hasher.py +++ b/src/conode/infrastructure/password_hasher.py @@ -4,7 +4,6 @@ from argon2.exceptions import Argon2Error, InvalidHashError from conode.application.interfaces.password_hasher import PasswordHasher -from conode.domain.auth import HashedPassword @dataclass @@ -14,8 +13,8 @@ class PasswordHasherImpl(PasswordHasher): def hash(self, password: str) -> str: return self._hasher.hash(password) - def verify(self, hashed_password: HashedPassword, password: str) -> bool: + def verify(self, hashed_password: str, password: str) -> bool: try: - return self._hasher.verify(hashed_password.value, password) + return self._hasher.verify(hashed_password, password) except (InvalidHashError, Argon2Error): return False diff --git a/src/conode/infrastructure/persistence/migrations/versions/807cb62be0ae_unique_username_email.py b/src/conode/infrastructure/persistence/migrations/versions/807cb62be0ae_unique_username_email.py new file mode 100644 index 0000000..4fb371d --- /dev/null +++ b/src/conode/infrastructure/persistence/migrations/versions/807cb62be0ae_unique_username_email.py @@ -0,0 +1,30 @@ +"""unique-username-email + +Revision ID: 807cb62be0ae +Revises: b210c4de87de +Create Date: 2026-08-20 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '807cb62be0ae' +down_revision: Union[str, Sequence[str], None] = 'b210c4de87de' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.create_unique_constraint('uq_user_record_username', 'user_record', ['username']) + op.create_unique_constraint('uq_user_record_email', 'user_record', ['email']) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_constraint('uq_user_record_email', 'user_record', type_='unique') + op.drop_constraint('uq_user_record_username', 'user_record', type_='unique') diff --git a/src/conode/infrastructure/persistence/migrations/versions/b210c4de87de_remove_access_token.py b/src/conode/infrastructure/persistence/migrations/versions/b210c4de87de_remove_access_token.py new file mode 100644 index 0000000..d438df2 --- /dev/null +++ b/src/conode/infrastructure/persistence/migrations/versions/b210c4de87de_remove_access_token.py @@ -0,0 +1,32 @@ +"""remove-access-token + +Revision ID: b210c4de87de +Revises: bf7551bea9b4 +Create Date: 2026-08-19 16:01:31.046762 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'b210c4de87de' +down_revision: Union[str, Sequence[str], None] = 'bf7551bea9b4' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('session_record', 'access_token') + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('session_record', sa.Column('access_token', sa.VARCHAR(), autoincrement=False, nullable=False)) + # ### end Alembic commands ### diff --git a/src/conode/infrastructure/persistence/registry.py b/src/conode/infrastructure/persistence/registry.py index 5d948b4..91a18e4 100644 --- a/src/conode/infrastructure/persistence/registry.py +++ b/src/conode/infrastructure/persistence/registry.py @@ -41,7 +41,6 @@ FirstNameType, GroupDescriptionType, GroupNameType, - HashedPasswordType, LastNameType, NodeDescriptionType, NodeNameType, @@ -67,6 +66,8 @@ Column("bio", BioType, nullable=False), Column("created_at", DateTime(timezone=True), nullable=False), Column("updated_at", DateTime(timezone=True), nullable=False), + UniqueConstraint("username", name="uq_user_record_username"), + UniqueConstraint("email", name="uq_user_record_email"), ) authorization_record_table = Table( @@ -74,7 +75,7 @@ metadata, Column("id", UUID, primary_key=True, nullable=False), Column("user_id", ForeignKey("user_record.id", ondelete="CASCADE"), nullable=False), - Column("hashed_password", HashedPasswordType, nullable=False), + Column("hashed_password", String, nullable=False), Column("created_at", DateTime(timezone=True), nullable=False), Column("updated_at", DateTime(timezone=True), nullable=False), ) @@ -85,7 +86,6 @@ Column("id", UUID, primary_key=True, nullable=False), Column("ip", String, primary_key=True, nullable=False), Column("user_id", ForeignKey("user_record.id", ondelete="CASCADE"), nullable=False), - Column("access_token", String, nullable=False), Column("refresh_token", String, nullable=False), Column("created_at", DateTime(timezone=True), nullable=False), Column("updated_at", DateTime(timezone=True), nullable=False), diff --git a/src/conode/infrastructure/persistence/types.py b/src/conode/infrastructure/persistence/types.py index 2d5cdb3..c0527c7 100644 --- a/src/conode/infrastructure/persistence/types.py +++ b/src/conode/infrastructure/persistence/types.py @@ -3,7 +3,6 @@ from sqlalchemy import Dialect, String from sqlalchemy.types import TypeDecorator -from conode.domain.auth import HashedPassword from conode.domain.company import CompanyDescription, CompanyName from conode.domain.context import ContextDescription, ContextName from conode.domain.group import GroupDescription, GroupName @@ -123,9 +122,3 @@ class OfferDescriptionType(BaseVOTypeDecorator[OfferDescription]): impl = String cache_ok = True vo_class = OfferDescription - - -class HashedPasswordType(BaseVOTypeDecorator[HashedPassword]): - impl = String - cache_ok = True - vo_class = HashedPassword diff --git a/src/conode/infrastructure/repositories/auth/session.py b/src/conode/infrastructure/repositories/auth/session.py index 3c21f2e..55cb203 100644 --- a/src/conode/infrastructure/repositories/auth/session.py +++ b/src/conode/infrastructure/repositories/auth/session.py @@ -2,6 +2,7 @@ import structlog from sqlalchemy import and_, insert, select, update +from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession from conode.application.errors import SessionNotFoundError @@ -24,12 +25,30 @@ async def create(self, session: Session) -> None: ip=session.ip, user_id=session.user_id, refresh_token=session.refresh_token, - access_token=session.access_token, created_at=session.created_at, updated_at=session.updated_at, ), ) + async def upsert(self, session: Session) -> None: + logger.debug("Repository upsert session", session_id=session.id) + stmt = pg_insert(Session).values( + id=session.id, + ip=session.ip, + user_id=session.user_id, + refresh_token=session.refresh_token, + created_at=session.created_at, + updated_at=session.updated_at, + ) + stmt = stmt.on_conflict_do_update( + constraint="uq_session_record_user_id_ip", + set_={ + "refresh_token": stmt.excluded.refresh_token, + "updated_at": stmt.excluded.updated_at, + }, + ) + await self.session.execute(stmt) + async def update(self, session: Session) -> None: logger.debug("Repository update session", session_id=session.id) await self.session.execute( @@ -38,7 +57,6 @@ async def update(self, session: Session) -> None: Session.id == session.id, # type: ignore ) .values( - access_token=session.access_token, refresh_token=session.refresh_token, updated_at=session.updated_at, ), diff --git a/src/conode/infrastructure/repositories/user.py b/src/conode/infrastructure/repositories/user.py index 7ce68b2..b230a87 100644 --- a/src/conode/infrastructure/repositories/user.py +++ b/src/conode/infrastructure/repositories/user.py @@ -2,9 +2,10 @@ import structlog from sqlalchemy import insert, or_, select, update +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession -from conode.application.errors import UserNotFoundError +from conode.application.errors import UserAlreadyExistsError, UserNotFoundError from conode.application.interfaces.repositories import UserRepository from conode.domain.user import Email, User, UserId, Username @@ -17,20 +18,29 @@ class UserRepositoryImpl(UserRepository): async def create(self, user: User) -> None: logger.debug("Repository create user", user_id=user.id) - await self.session.execute( - insert(User).values( - id=user.id, - system_role=user.system_role, - first_name=user.first_name, - last_name=user.last_name, - username=user.username, - email=user.email, - email_verified=user.email_verified, - bio=user.bio, - created_at=user.created_at, - updated_at=user.updated_at, - ), - ) + try: + await self.session.execute( + insert(User).values( + id=user.id, + system_role=user.system_role, + first_name=user.first_name, + last_name=user.last_name, + username=user.username, + email=user.email, + email_verified=user.email_verified, + bio=user.bio, + created_at=user.created_at, + updated_at=user.updated_at, + ), + ) + except IntegrityError as e: + raise UserAlreadyExistsError( + "User with this username or email already exists", + [ + {"key": "email", "value": user.email.value}, + {"key": "username", "value": user.username.value}, + ], + ) from e async def update(self, user: User) -> None: logger.debug("Repository update user", user_id=user.id) diff --git a/src/conode/presentation/schemas/auth.py b/src/conode/presentation/schemas/auth.py index 19850cb..8d9f6cd 100644 --- a/src/conode/presentation/schemas/auth.py +++ b/src/conode/presentation/schemas/auth.py @@ -1,11 +1,7 @@ -from typing import Annotated +from typing import Annotated, Final -from pydantic import BaseModel, EmailStr, Field, SecretStr +from pydantic import BaseModel, EmailStr, Field -from conode.domain.auth.authorization.model import ( - MAX_HASHED_PASSWORD_ALLOWED_LENGTH, - MIN_HASHED_PASSWORD_ALLOWED_LENGTH, -) from conode.domain.user.model import ( MAX_ALLOWED_BIO_LENGTH, MAX_ALLOWED_FIRST_NAME_LENGTH, @@ -16,6 +12,9 @@ MIN_ALLOWED_USERNAME_LENGTH, ) +MAX_ALLOWED_PASSWORD_LENGTH: Final = 30 +MIN_ALLOWED_PASSWORD_LENGTH: Final = 5 + class RegisterRequest(BaseModel): username: Annotated[ @@ -42,10 +41,10 @@ class RegisterRequest(BaseModel): bio: Annotated[str, Field(max_length=MAX_ALLOWED_BIO_LENGTH)] email: EmailStr password: Annotated[ - SecretStr, + str, Field( - min_length=MIN_HASHED_PASSWORD_ALLOWED_LENGTH, - max_length=MAX_HASHED_PASSWORD_ALLOWED_LENGTH, + min_length=MIN_ALLOWED_PASSWORD_LENGTH, + max_length=MAX_ALLOWED_PASSWORD_LENGTH, ), ] @@ -53,14 +52,18 @@ class RegisterRequest(BaseModel): class LoginRequest(BaseModel): email: EmailStr password: Annotated[ - SecretStr, + str, Field( - min_length=MIN_HASHED_PASSWORD_ALLOWED_LENGTH, - max_length=MAX_HASHED_PASSWORD_ALLOWED_LENGTH, + min_length=MIN_ALLOWED_PASSWORD_LENGTH, + max_length=MAX_ALLOWED_PASSWORD_LENGTH, ), ] +class RefreshTokenRequest(BaseModel): + refresh_token: str + + class AuthorizedResponse(BaseModel): access_token: str refresh_token: str diff --git a/src/conode/presentation/views/auth.py b/src/conode/presentation/views/auth.py index 58a80e4..4474752 100644 --- a/src/conode/presentation/views/auth.py +++ b/src/conode/presentation/views/auth.py @@ -1,29 +1,33 @@ +from http import HTTPStatus + from dishka.integrations.fastapi import DishkaRoute, FromDishka from fastapi import APIRouter from conode.application.auth import ( LoginInteractor, LoginRequestDTO, + RefreshTokenInteractor, RegisterInteractor, RegisterRequestDTO, ) from conode.presentation.schemas.auth import ( AuthorizedResponse, LoginRequest, + RefreshTokenRequest, RegisterRequest, ) router = APIRouter(prefix="/auth", tags=["Authorization"], route_class=DishkaRoute) -@router.post("/register") +@router.post("/register", status_code=HTTPStatus.CREATED) async def register( interactor: FromDishka[RegisterInteractor], request: RegisterRequest ) -> AuthorizedResponse: result = await interactor.execute( RegisterRequestDTO( username=request.username, - password=request.password.get_secret_value(), + password=request.password, first_name=request.first_name, last_name=request.last_name, email=request.email, @@ -44,7 +48,7 @@ async def login( ) -> AuthorizedResponse: result = await interactor.execute( LoginRequestDTO( - password=request.password.get_secret_value(), + password=request.password, email=request.email, ) ) @@ -54,3 +58,16 @@ async def login( refresh_token=result.refresh_token, expires_in=result.expires_in, ) + + +@router.post("/refresh") +async def refresh_token( + interactor: FromDishka[RefreshTokenInteractor], request: RefreshTokenRequest +) -> AuthorizedResponse: + result = await interactor.execute(request.refresh_token) + + return AuthorizedResponse( + access_token=result.access_token, + refresh_token=result.refresh_token, + expires_in=result.expires_in, + ) diff --git a/tests/conftest.py b/tests/conftest.py index 1213517..c170ae9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,7 +20,6 @@ create_async_engine, ) -from conode.application.interfaces.identity_provider import IdentityProvider from conode.application.interfaces.password_hasher import PasswordHasher from conode.application.interfaces.repositories import ( AuthorizationRepository, @@ -118,6 +117,20 @@ async def company_repository(container: AsyncContainer) -> CompanyRepository: return await test_contaner.get(CompanyRepository) # type: ignore[no-any-return] +@pytest.fixture +async def authorization_repository( + container: AsyncContainer, +) -> AuthorizationRepository: + async with container() as test_contaner: + return await test_contaner.get(AuthorizationRepository) # type: ignore[no-any-return] + + +@pytest.fixture +async def session_repository(container: AsyncContainer) -> SessionRepository: + async with container() as test_contaner: + return await test_contaner.get(SessionRepository) # type: ignore[no-any-return] + + @pytest.fixture async def context_repository(container: AsyncContainer) -> ContextRepository: async with container() as test_container: @@ -183,7 +196,6 @@ async def context_factory(container: AsyncContainer) -> ContextFactory: async def user_factory(container: AsyncContainer) -> UserFactory: async with container() as test_container: return UserFactory( - identity_provider=await test_container.get(IdentityProvider), authorization_service=await test_container.get(AuthorizationService), authorization_repository=await test_container.get(AuthorizationRepository), session_repository=await test_container.get(SessionRepository), @@ -288,7 +300,7 @@ async def provide_async_session(self) -> AsyncSession: @pytest.fixture async def transport(container: AsyncContainer, config: Config) -> AsyncClient: app = create_app(config) - setup_dishka(container, app) + setup_dishka(app=app, container=container) return AsyncClient( base_url="http://test.environment.org", transport=ASGITransport(app) diff --git a/tests/e2e/auth/__init__.py b/tests/e2e/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/auth/test_login.py b/tests/e2e/auth/test_login.py new file mode 100644 index 0000000..46297eb --- /dev/null +++ b/tests/e2e/auth/test_login.py @@ -0,0 +1,52 @@ +from http import HTTPStatus + +import pytest +from dirty_equals import IsInt, IsPartialDict, IsStr +from httpx import AsyncClient + +from conode.application.interfaces.repositories import ( + SessionRepository, +) +from tests.factories.models import UserFactory +from tests.factories.schemas import LoginRequestFactory + + +@pytest.mark.asyncio +async def test_login_ok( + transport: AsyncClient, + user_factory: UserFactory, + session_repository: SessionRepository, +) -> None: + user = await user_factory.build() + + request = LoginRequestFactory.build( + email=user.user.email.value, password=user.password + ) + + response = await transport.post("/auth/login", json=request.model_dump(mode="json")) + + session = await session_repository.get_by_ip_and_user(user.user, "127.0.0.1") + + assert session is not None + assert response.status_code == HTTPStatus.OK + assert response.json() == IsPartialDict( + access_token=IsStr(), refresh_token=session.refresh_token, expires_in=IsInt() + ) + + +@pytest.mark.asyncio +async def test_login_invalid_password( + transport: AsyncClient, + user_factory: UserFactory, +) -> None: + user = await user_factory.build() + + request = LoginRequestFactory.build(email=user.user.email.value) + + response = await transport.post("/auth/login", json=request.model_dump(mode="json")) + + assert response.status_code == HTTPStatus.UNAUTHORIZED + assert response.json() == IsPartialDict( + detail="Passwords doesn't match", + meta=[{"key": "password", "value": request.password}], + ) diff --git a/tests/e2e/auth/test_refresh_token.py b/tests/e2e/auth/test_refresh_token.py new file mode 100644 index 0000000..ec36777 --- /dev/null +++ b/tests/e2e/auth/test_refresh_token.py @@ -0,0 +1,52 @@ +from http import HTTPStatus + +import pytest +from dirty_equals import IsInt, IsPartialDict, IsStr +from httpx import AsyncClient + +from conode.application.interfaces.repositories import SessionRepository +from tests.factories.models import UserFactory +from tests.factories.schemas import RefreshTokenRequestFactory + + +@pytest.mark.asyncio +async def test_refresh_token_ok( + transport: AsyncClient, + user_factory: UserFactory, + session_repository: SessionRepository, +) -> None: + user = await user_factory.build() + + request = RefreshTokenRequestFactory.build(refresh_token=user.refresh_token) + + session = await session_repository.get_by_ip_and_user(user.user, "127.0.0.1") + old_refresh_token = session.refresh_token # type: ignore + + response = await transport.post( + "/auth/refresh", + json=request.model_dump(mode="json"), + ) + content = response.json() + + assert response.status_code == HTTPStatus.OK + assert response.json() == IsPartialDict( + access_token=IsStr(), refresh_token=IsStr(), expires_in=IsInt() + ) + assert content["refresh_token"] != old_refresh_token + + +@pytest.mark.asyncio +async def test_refresh_token_with_invalid_token( + transport: AsyncClient, +) -> None: + request = RefreshTokenRequestFactory.build(refresh_token="invalid_token") # noqa: S106 + + response = await transport.post( + "/auth/refresh", json=request.model_dump(mode="json") + ) + + assert response.status_code == HTTPStatus.UNAUTHORIZED + assert response.json() == IsPartialDict( + detail="Session associated with this refresh token not found", + meta=[{"key": "token", "value": request.refresh_token}], + ) diff --git a/tests/e2e/auth/test_register.py b/tests/e2e/auth/test_register.py new file mode 100644 index 0000000..b90dd0d --- /dev/null +++ b/tests/e2e/auth/test_register.py @@ -0,0 +1,84 @@ +from http import HTTPStatus + +import pytest +from dirty_equals import IsInt, IsPartialDict, IsStr +from httpx import AsyncClient + +from conode.application.interfaces.repositories import ( + AuthorizationRepository, + SessionRepository, + UserRepository, +) +from conode.domain.user import Email +from tests.factories.models import UserFactory +from tests.factories.schemas import RegisterRequestFactory + + +@pytest.mark.asyncio +async def test_register_ok( + transport: AsyncClient, + user_repository: UserRepository, + authorization_repository: AuthorizationRepository, + session_repository: SessionRepository, +) -> None: + request = RegisterRequestFactory.build() + + response = await transport.post( + "/auth/register", json=request.model_dump(mode="json") + ) + content = response.json() + + user = await user_repository.get_by_email(Email(request.email)) + assert user is not None + + await authorization_repository.get_by_user(user) + session = await session_repository.get_by_refresh_token(content["refresh_token"]) + + assert response.status_code == HTTPStatus.CREATED + assert content == IsPartialDict( + access_token=IsStr(), refresh_token=session.refresh_token, expires_in=IsInt() + ) + + +@pytest.mark.asyncio +async def test_register_user_with_this_email_already_exists( + transport: AsyncClient, user_factory: UserFactory +) -> None: + user = await user_factory.build() + + request = RegisterRequestFactory.build(email=user.user.email.value) + + response = await transport.post( + "/auth/register", json=request.model_dump(mode="json") + ) + + assert response.status_code == HTTPStatus.CONFLICT + assert response.json() == IsPartialDict( + detail="User with this username or email already exists", + meta=[ + {"key": "email", "value": request.email}, + {"key": "username", "value": request.username}, + ], + ) + + +@pytest.mark.asyncio +async def test_register_user_with_this_username_already_exists( + transport: AsyncClient, user_factory: UserFactory +) -> None: + user = await user_factory.build() + + request = RegisterRequestFactory.build(username=user.user.username.value) + + response = await transport.post( + "/auth/register", json=request.model_dump(mode="json") + ) + + assert response.status_code == HTTPStatus.CONFLICT + assert response.json() == IsPartialDict( + detail="User with this username or email already exists", + meta=[ + {"key": "email", "value": request.email}, + {"key": "username", "value": request.username}, + ], + ) diff --git a/tests/factories/models/user.py b/tests/factories/models/user.py index 90779c8..472df9c 100644 --- a/tests/factories/models/user.py +++ b/tests/factories/models/user.py @@ -2,7 +2,6 @@ from datetime import timedelta from uuid import uuid4 -from conode.application.interfaces.identity_provider import IdentityProvider from conode.application.interfaces.password_hasher import PasswordHasher from conode.application.interfaces.repositories import ( AuthorizationRepository, @@ -23,6 +22,7 @@ class UserFactoryResponse: user: User access_token: str + refresh_token: str password: str @@ -35,7 +35,6 @@ class UserFactory: access_token_manager: AccessTokenManager transaction_manager: TransactionManager user_repository: UserRepository - identity_provider: IdentityProvider password_hasher: PasswordHasher def generate_access_token(self, user: User) -> str: @@ -76,7 +75,7 @@ async def build( self.authorization_service.generate_authorization_stuff( user, password, - self.identity_provider.get_current_ip(), + "127.0.0.1", ) ) @@ -88,6 +87,7 @@ async def build( return UserFactoryResponse( user=user, - access_token=self.generate_access_token(user), + access_token=authorization_service_response.access_token, + refresh_token=authorization_service_response.refresh_token, password=password, ) diff --git a/tests/factories/schemas/__init__.py b/tests/factories/schemas/__init__.py index 9033483..f09c713 100644 --- a/tests/factories/schemas/__init__.py +++ b/tests/factories/schemas/__init__.py @@ -1,3 +1,8 @@ +from .auth import ( + LoginRequestFactory, + RefreshTokenRequestFactory, + RegisterRequestFactory, +) from .company import ( RegisterCompanyRequestFactory, TransferCompanyRequestFactory, @@ -22,7 +27,10 @@ "CreateEdgeRequestFactory", "CreateGroupRequestFactory", "CreateNodeRequestFactory", + "LoginRequestFactory", + "RefreshTokenRequestFactory", "RegisterCompanyRequestFactory", + "RegisterRequestFactory", "TransferCompanyRequestFactory", "UpdateCompanyRequestFactory", "UpdateCurrentUserProfileRequestFactory", diff --git a/tests/factories/schemas/auth.py b/tests/factories/schemas/auth.py new file mode 100644 index 0000000..66ba598 --- /dev/null +++ b/tests/factories/schemas/auth.py @@ -0,0 +1,17 @@ +from polyfactory.factories.pydantic_factory import ModelFactory + +from conode.presentation.schemas.auth import ( + LoginRequest, + RefreshTokenRequest, + RegisterRequest, +) + + +class RegisterRequestFactory(ModelFactory[RegisterRequest]): + __use_examples__ = True + + +class LoginRequestFactory(ModelFactory[LoginRequest]): ... + + +class RefreshTokenRequestFactory(ModelFactory[RefreshTokenRequest]): ... From 5e81b8375192c1dc6707fc1e6cda231a6c8761a4 Mon Sep 17 00:00:00 2001 From: Davlat Davydov Date: Thu, 20 Aug 2026 01:53:02 +0300 Subject: [PATCH 11/13] chore(all): changed project structure --- .../attach_node_to_group/attach_node.py | 6 +- .../detach_node_from_group/detach_node.py | 6 +- .../manage_company/transfer_company.py | 10 +- .../manage_company/update_company.py | 6 +- .../manage_context/create_context.py | 6 +- .../manage_context/delete_context.py | 6 +- .../application/manage_edge/create_edge.py | 6 +- .../application/manage_edge/delete_edge.py | 6 +- .../application/manage_group/create_group.py | 6 +- .../application/manage_group/delete_group.py | 6 +- .../application/manage_node/create_node.py | 6 +- .../application/manage_node/delete_node.py | 6 +- .../update_current_user_profile.py | 7 +- .../manage_profile/update_user_profile.py | 6 +- .../application/manage_role/create_role.py | 10 +- .../application/manage_role/delete_role.py | 6 +- .../application/manage_role/update_role.py | 6 +- .../manage_user_rights/give_role_to_user.py | 5 +- .../revoke_role_from_user.py | 5 +- .../receive_context_info/get_context_by_id.py | 5 +- .../receive_graph_statistics/neighbours.py | 5 +- .../receive_graph_statistics/shortest_path.py | 5 +- .../receive_group_info/get_group_by_id.py | 5 +- .../receive_node_info/get_nodes_by_group.py | 5 +- .../receive_user_info/get_current_user.py | 6 +- .../receive_user_info/get_user_by_username.py | 6 +- .../register_company/register_company.py | 7 +- .../application/services/access_control.py | 20 +- .../application/services/authorization.py | 19 +- .../application/share_graph/accept_offer.py | 5 +- .../application/share_graph/decline_offer.py | 6 +- .../share_graph/send_offer_to_company.py | 10 +- .../decrement_edge_weight.py | 6 +- .../increment_edge_weight.py | 6 +- .../update_edge_weight/update_edge_weight.py | 6 +- .../application/update_node/update_node.py | 6 +- .../verify_company/verify_company.py | 6 +- .../infrastructure/persistence/registry.py | 397 +----------------- .../infrastructure/persistence/schemas.py | 370 ++++++++++++++++ tests/conftest.py | 3 + 40 files changed, 533 insertions(+), 492 deletions(-) create mode 100644 src/conode/infrastructure/persistence/schemas.py diff --git a/src/conode/application/attach_node_to_group/attach_node.py b/src/conode/application/attach_node_to_group/attach_node.py index 2add15a..e8e2f9f 100644 --- a/src/conode/application/attach_node_to_group/attach_node.py +++ b/src/conode/application/attach_node_to_group/attach_node.py @@ -11,7 +11,7 @@ NodeRepository, ) from conode.application.interfaces.transaction_manager import TransactionManager -from conode.application.services import AccessControlService +from conode.application.services import AccessControlService, AuthorizationService from conode.domain.group import GroupId from conode.domain.node import NodeAssociation, NodeAssociationId, NodeId @@ -26,15 +26,15 @@ class AttachNodeRequestDTO: class AttachNodeInteractor: node_association_repository: NodeAssociationRepository access_control_service: AccessControlService + authorization_service: AuthorizationService transaction_manager: TransactionManager group_repository: GroupRepository node_repository: NodeRepository company_repository: CompanyRepository async def execute(self, request: AttachNodeRequestDTO) -> list[NodeAssociation]: - user = await self.access_control_service.get_authorized_user() - async with self.transaction_manager: + user = await self.authorization_service.get_authorized_user() group = await self.group_repository.get_by_id(request.group_id) request_nodes = set(request.nodes) diff --git a/src/conode/application/detach_node_from_group/detach_node.py b/src/conode/application/detach_node_from_group/detach_node.py index 5f3616b..a942c05 100644 --- a/src/conode/application/detach_node_from_group/detach_node.py +++ b/src/conode/application/detach_node_from_group/detach_node.py @@ -10,7 +10,7 @@ NodeRepository, ) from conode.application.interfaces.transaction_manager import TransactionManager -from conode.application.services import AccessControlService +from conode.application.services import AccessControlService, AuthorizationService from conode.domain.node import NodeAssociationId @@ -19,14 +19,14 @@ class DetachNodeInteractor: node_repository: NodeRepository node_association_repository: NodeAssociationRepository access_control_service: AccessControlService + authorization_service: AuthorizationService transaction_manager: TransactionManager group_repository: GroupRepository company_repository: CompanyRepository async def execute(self, association_id: NodeAssociationId) -> None: - user = await self.access_control_service.get_authorized_user() - async with self.transaction_manager: + user = await self.authorization_service.get_authorized_user() association = await self.node_association_repository.get_by_id( association_id, ) diff --git a/src/conode/application/manage_company/transfer_company.py b/src/conode/application/manage_company/transfer_company.py index a8a8fee..f6444b3 100644 --- a/src/conode/application/manage_company/transfer_company.py +++ b/src/conode/application/manage_company/transfer_company.py @@ -13,7 +13,11 @@ UserRepository, ) from conode.application.interfaces.transaction_manager import TransactionManager -from conode.application.services import AccessControlService, RoleManagmentService +from conode.application.services import ( + AccessControlService, + AuthorizationService, + RoleManagmentService, +) from conode.domain.company import CompanyId from conode.domain.grant import UserGrant, UserGrantId from conode.domain.user import UserId @@ -29,15 +33,15 @@ class TransferCompanyRequestDTO: class TransferCompanyInteractor: user_repository: UserRepository access_control_service: AccessControlService + authorization_service: AuthorizationService user_grant_repository: UserGrantRepository company_repository: CompanyRepository role_managment_service: RoleManagmentService transaction_manager: TransactionManager async def execute(self, request: TransferCompanyRequestDTO) -> None: - user = await self.access_control_service.get_authorized_user() - async with self.transaction_manager: + user = await self.authorization_service.get_authorized_user() if not user.email_verified: raise EmailsOfAllParticipantsMustBeVerifiedError( "Emails of all participants must be verified", None diff --git a/src/conode/application/manage_company/update_company.py b/src/conode/application/manage_company/update_company.py index 5e22dec..9ce00f4 100644 --- a/src/conode/application/manage_company/update_company.py +++ b/src/conode/application/manage_company/update_company.py @@ -2,7 +2,7 @@ from conode.application.interfaces.repositories import CompanyRepository from conode.application.interfaces.transaction_manager import TransactionManager -from conode.application.services import AccessControlService +from conode.application.services import AccessControlService, AuthorizationService from conode.domain.company import CompanyId @@ -16,14 +16,14 @@ class UpdateCompanyRequestDTO: class UpdateCompanyInteractor: transaction_manager: TransactionManager access_control_service: AccessControlService + authorization_service: AuthorizationService company_repository: CompanyRepository async def execute( self, company_id: CompanyId, request: UpdateCompanyRequestDTO ) -> None: - user = await self.access_control_service.get_authorized_user() - async with self.transaction_manager: + user = await self.authorization_service.get_authorized_user() company = await self.company_repository.get_by_id(company_id) await self.access_control_service.ensure_user_can_manipulate_company( diff --git a/src/conode/application/manage_context/create_context.py b/src/conode/application/manage_context/create_context.py index b24b4ec..2ed0d72 100644 --- a/src/conode/application/manage_context/create_context.py +++ b/src/conode/application/manage_context/create_context.py @@ -6,7 +6,7 @@ ContextRepository, ) from conode.application.interfaces.transaction_manager import TransactionManager -from conode.application.services import AccessControlService +from conode.application.services import AccessControlService, AuthorizationService from conode.domain.company import CompanyId from conode.domain.context import Context, ContextId @@ -24,11 +24,11 @@ class CreateContextInteractor: company_repository: CompanyRepository context_repository: ContextRepository access_control_service: AccessControlService + authorization_service: AuthorizationService async def execute(self, request: CreateContextRequestDTO) -> Context: - user = await self.access_control_service.get_authorized_user() - async with self.transaction_manager: + user = await self.authorization_service.get_authorized_user() company = await self.company_repository.get_by_id(request.company_id) await self.access_control_service.ensure_user_can_create_contexts( diff --git a/src/conode/application/manage_context/delete_context.py b/src/conode/application/manage_context/delete_context.py index 8adcb07..912533c 100644 --- a/src/conode/application/manage_context/delete_context.py +++ b/src/conode/application/manage_context/delete_context.py @@ -5,7 +5,7 @@ ContextRepository, ) from conode.application.interfaces.transaction_manager import TransactionManager -from conode.application.services import AccessControlService +from conode.application.services import AccessControlService, AuthorizationService from conode.domain.context import ContextId @@ -14,12 +14,12 @@ class DeleteContextInteractor: company_repository: CompanyRepository context_repository: ContextRepository access_control_service: AccessControlService + authorization_service: AuthorizationService transaction_manager: TransactionManager async def execute(self, context_id: ContextId) -> None: - user = await self.access_control_service.get_authorized_user() - async with self.transaction_manager: + user = await self.authorization_service.get_authorized_user() context = await self.context_repository.get_by_id(context_id) await self.access_control_service.ensure_user_can_manipulate_context( diff --git a/src/conode/application/manage_edge/create_edge.py b/src/conode/application/manage_edge/create_edge.py index 90706fd..98b17e0 100644 --- a/src/conode/application/manage_edge/create_edge.py +++ b/src/conode/application/manage_edge/create_edge.py @@ -12,7 +12,7 @@ NodeRepository, ) from conode.application.interfaces.transaction_manager import TransactionManager -from conode.application.services import AccessControlService +from conode.application.services import AccessControlService, AuthorizationService from conode.domain.context import ContextId from conode.domain.edge import Edge, EdgeId from conode.domain.node import NodeId @@ -30,6 +30,7 @@ class CreateEdgeRequestDTO: @dataclass class CreateEdgeInteractor: access_control_service: AccessControlService + authorization_service: AuthorizationService transaction_manager: TransactionManager context_repository: ContextRepository company_repository: CompanyRepository @@ -37,9 +38,8 @@ class CreateEdgeInteractor: edge_repository: EdgeRepository async def execute(self, request: CreateEdgeRequestDTO) -> Edge: - user = await self.access_control_service.get_authorized_user() - async with self.transaction_manager: + user = await self.authorization_service.get_authorized_user() context = await self.context_repository.get_by_id(request.context_id) company = await self.company_repository.get_by_id(context.company_id) diff --git a/src/conode/application/manage_edge/delete_edge.py b/src/conode/application/manage_edge/delete_edge.py index bbef042..78505fc 100644 --- a/src/conode/application/manage_edge/delete_edge.py +++ b/src/conode/application/manage_edge/delete_edge.py @@ -6,7 +6,7 @@ EdgeRepository, ) from conode.application.interfaces.transaction_manager import TransactionManager -from conode.application.services import AccessControlService +from conode.application.services import AccessControlService, AuthorizationService from conode.domain.edge import EdgeId @@ -16,12 +16,12 @@ class DeleteEdgeInteractor: edge_repository: EdgeRepository transaction_manager: TransactionManager access_control_service: AccessControlService + authorization_service: AuthorizationService context_repository: ContextRepository async def execute(self, edge_id: EdgeId) -> None: - user = await self.access_control_service.get_authorized_user() - async with self.transaction_manager: + user = await self.authorization_service.get_authorized_user() edge = await self.edge_repository.get_by_id(edge_id) context = await self.context_repository.get_by_id(edge.context_id) diff --git a/src/conode/application/manage_group/create_group.py b/src/conode/application/manage_group/create_group.py index 831cd1f..872145a 100644 --- a/src/conode/application/manage_group/create_group.py +++ b/src/conode/application/manage_group/create_group.py @@ -6,7 +6,7 @@ GroupRepository, ) from conode.application.interfaces.transaction_manager import TransactionManager -from conode.application.services import AccessControlService +from conode.application.services import AccessControlService, AuthorizationService from conode.domain.company import CompanyId from conode.domain.group import Group, GroupId @@ -25,11 +25,11 @@ class CreateGroupInteractor: group_repository: GroupRepository transaction_manager: TransactionManager access_control_service: AccessControlService + authorization_service: AuthorizationService async def execute(self, request: CreateGroupRequestDTO) -> Group: - user = await self.access_control_service.get_authorized_user() - async with self.transaction_manager: + user = await self.authorization_service.get_authorized_user() company = await self.company_repository.get_by_id(request.company_id) await self.access_control_service.ensure_user_can_create_groups( diff --git a/src/conode/application/manage_group/delete_group.py b/src/conode/application/manage_group/delete_group.py index 8beaad3..8048f75 100644 --- a/src/conode/application/manage_group/delete_group.py +++ b/src/conode/application/manage_group/delete_group.py @@ -5,7 +5,7 @@ GroupRepository, ) from conode.application.interfaces.transaction_manager import TransactionManager -from conode.application.services import AccessControlService +from conode.application.services import AccessControlService, AuthorizationService from conode.domain.group import GroupId @@ -15,11 +15,11 @@ class DeleteGroupInteractor: group_repository: GroupRepository company_repository: CompanyRepository access_control_service: AccessControlService + authorization_service: AuthorizationService async def execute(self, group_id: GroupId) -> None: - user = await self.access_control_service.get_authorized_user() - async with self.transaction_manager: + user = await self.authorization_service.get_authorized_user() group = await self.group_repository.get_by_id(group_id) await self.access_control_service.ensure_user_can_manipulate_group( diff --git a/src/conode/application/manage_node/create_node.py b/src/conode/application/manage_node/create_node.py index 3ab9d4f..958e327 100644 --- a/src/conode/application/manage_node/create_node.py +++ b/src/conode/application/manage_node/create_node.py @@ -8,7 +8,7 @@ NodeRepository, ) from conode.application.interfaces.transaction_manager import TransactionManager -from conode.application.services import AccessControlService +from conode.application.services import AccessControlService, AuthorizationService from conode.domain.group import GroupId from conode.domain.node import Node, NodeAssociation, NodeAssociationId, NodeId @@ -28,11 +28,11 @@ class CreateNodeInteractor: node_repository: NodeRepository transaction_manager: TransactionManager access_control_service: AccessControlService + authorization_service: AuthorizationService async def execute(self, request: CreateNodeRequestDTO) -> Node: - user = await self.access_control_service.get_authorized_user() - async with self.transaction_manager: + user = await self.authorization_service.get_authorized_user() group = await self.group_repository.get_by_id(request.group_id) company = await self.company_repository.get_by_id(group.company_id) diff --git a/src/conode/application/manage_node/delete_node.py b/src/conode/application/manage_node/delete_node.py index 90ef0e1..049d44a 100644 --- a/src/conode/application/manage_node/delete_node.py +++ b/src/conode/application/manage_node/delete_node.py @@ -7,7 +7,7 @@ NodeRepository, ) from conode.application.interfaces.transaction_manager import TransactionManager -from conode.application.services import AccessControlService +from conode.application.services import AccessControlService, AuthorizationService from conode.domain.node import NodeId @@ -19,11 +19,11 @@ class DeleteNodeInteractor: node_association_repository: NodeAssociationRepository group_repository: GroupRepository access_control_service: AccessControlService + authorization_service: AuthorizationService async def execute(self, node_id: NodeId) -> None: - user = await self.access_control_service.get_authorized_user() - async with self.transaction_manager: + user = await self.authorization_service.get_authorized_user() node = await self.node_repository.get_by_id(node_id) existing_associations = ( diff --git a/src/conode/application/manage_profile/update_current_user_profile.py b/src/conode/application/manage_profile/update_current_user_profile.py index b50ebf4..e3d870b 100644 --- a/src/conode/application/manage_profile/update_current_user_profile.py +++ b/src/conode/application/manage_profile/update_current_user_profile.py @@ -2,7 +2,7 @@ from conode.application.interfaces.repositories import UserRepository from conode.application.interfaces.transaction_manager import TransactionManager -from conode.application.services import AccessControlService +from conode.application.services import AuthorizationService @dataclass @@ -17,12 +17,11 @@ class UpdateCurrentUserProfileRequestDTO: class UpdateCurrentUserProfileInteractor: user_repository: UserRepository transaction_manager: TransactionManager - access_control_service: AccessControlService + authorization_service: AuthorizationService async def execute(self, request: UpdateCurrentUserProfileRequestDTO) -> None: - user = await self.access_control_service.get_authorized_user() - async with self.transaction_manager: + user = await self.authorization_service.get_authorized_user() user.update_profile( first_name=request.first_name, last_name=request.last_name, diff --git a/src/conode/application/manage_profile/update_user_profile.py b/src/conode/application/manage_profile/update_user_profile.py index d6a04ff..b91f80b 100644 --- a/src/conode/application/manage_profile/update_user_profile.py +++ b/src/conode/application/manage_profile/update_user_profile.py @@ -2,7 +2,7 @@ from conode.application.interfaces.repositories import UserRepository from conode.application.interfaces.transaction_manager import TransactionManager -from conode.application.services.access_control import AccessControlService +from conode.application.services import AccessControlService, AuthorizationService from conode.domain.user import UserId @@ -17,15 +17,15 @@ class UpdateUserProfileRequestDTO: @dataclass class UpdateUserProfileInteractor: access_control_service: AccessControlService + authorization_service: AuthorizationService transaction_manager: TransactionManager user_repository: UserRepository async def execute( self, user_id: UserId, request: UpdateUserProfileRequestDTO ) -> None: - user = await self.access_control_service.get_authorized_user() - async with self.transaction_manager: + user = await self.authorization_service.get_authorized_user() if user.id == user_id: target = user else: diff --git a/src/conode/application/manage_role/create_role.py b/src/conode/application/manage_role/create_role.py index e161586..ae898c3 100644 --- a/src/conode/application/manage_role/create_role.py +++ b/src/conode/application/manage_role/create_role.py @@ -7,7 +7,11 @@ RoleRepository, ) from conode.application.interfaces.transaction_manager import TransactionManager -from conode.application.services import AccessControlService, RoleManagmentService +from conode.application.services import ( + AccessControlService, + AuthorizationService, + RoleManagmentService, +) from conode.domain.company import CompanyId from conode.domain.role import ( EntityType, @@ -38,13 +42,13 @@ class CreateRoleInteractor: company_repository: CompanyRepository transaction_manager: TransactionManager access_control_service: AccessControlService + authorization_service: AuthorizationService role_managment_service: RoleManagmentService role_permissions_repository: RolePermissionsRepository async def execute(self, request: CreateRoleRequestDTO) -> Role: - user = await self.access_control_service.get_authorized_user() - async with self.transaction_manager: + user = await self.authorization_service.get_authorized_user() company = await self.company_repository.get_by_id(request.company_id) await self.access_control_service.ensure_user_can_create_roles( diff --git a/src/conode/application/manage_role/delete_role.py b/src/conode/application/manage_role/delete_role.py index 97ce91a..c6d9df9 100644 --- a/src/conode/application/manage_role/delete_role.py +++ b/src/conode/application/manage_role/delete_role.py @@ -2,7 +2,7 @@ from conode.application.interfaces.repositories import RoleRepository from conode.application.interfaces.transaction_manager import TransactionManager -from conode.application.services import AccessControlService +from conode.application.services import AccessControlService, AuthorizationService from conode.domain.role import RoleId @@ -11,11 +11,11 @@ class DeleteRoleInteractor: role_repository: RoleRepository transaction_manager: TransactionManager access_control_service: AccessControlService + authorization_service: AuthorizationService async def execute(self, role_id: RoleId) -> None: - user = await self.access_control_service.get_authorized_user() - async with self.transaction_manager: + user = await self.authorization_service.get_authorized_user() role = await self.role_repository.get_by_id(role_id) await self.access_control_service.ensure_user_can_manipulate_role( diff --git a/src/conode/application/manage_role/update_role.py b/src/conode/application/manage_role/update_role.py index b73b046..4c68f10 100644 --- a/src/conode/application/manage_role/update_role.py +++ b/src/conode/application/manage_role/update_role.py @@ -5,7 +5,7 @@ RoleRepository, ) from conode.application.interfaces.transaction_manager import TransactionManager -from conode.application.services import AccessControlService +from conode.application.services import AccessControlService, AuthorizationService from conode.domain.role import ( EntityType, PermissionType, @@ -41,13 +41,13 @@ class UpdateRoleResponseDTO: class UpdateRoleInteractor: role_permissions_repository: RolePermissionsRepository access_control_service: AccessControlService + authorization_service: AuthorizationService transaction_manager: TransactionManager role_repository: RoleRepository async def execute(self, request: UpdateRoleRequestDTO) -> UpdateRoleResponseDTO: - user = await self.access_control_service.get_authorized_user() - async with self.transaction_manager: + user = await self.authorization_service.get_authorized_user() role = await self.role_repository.get_by_id(request.role_id) await self.access_control_service.ensure_user_can_manipulate_role( diff --git a/src/conode/application/manage_user_rights/give_role_to_user.py b/src/conode/application/manage_user_rights/give_role_to_user.py index 1ad1d18..6ad3df2 100644 --- a/src/conode/application/manage_user_rights/give_role_to_user.py +++ b/src/conode/application/manage_user_rights/give_role_to_user.py @@ -10,7 +10,7 @@ UserRepository, ) from conode.application.interfaces.transaction_manager import TransactionManager -from conode.application.services import AccessControlService +from conode.application.services import AccessControlService, AuthorizationService from conode.domain.grant import UserGrant, UserGrantId from conode.domain.role import RoleId from conode.domain.user import UserId @@ -19,6 +19,7 @@ @dataclass class GiveRoleToUserInteractor: access_control_service: AccessControlService + authorization_service: AuthorizationService user_repository: UserRepository user_grant_repository: UserGrantRepository transaction_manager: TransactionManager @@ -26,7 +27,7 @@ class GiveRoleToUserInteractor: async def execute(self, user_id: UserId, role_id: RoleId) -> UserGrant: async with self.transaction_manager: - executor = await self.access_control_service.get_authorized_user() + executor = await self.authorization_service.get_authorized_user() role = await self.role_repository.get_by_id(role_id) user = await self.user_repository.get_by_id(user_id) diff --git a/src/conode/application/manage_user_rights/revoke_role_from_user.py b/src/conode/application/manage_user_rights/revoke_role_from_user.py index 455911a..61f0eca 100644 --- a/src/conode/application/manage_user_rights/revoke_role_from_user.py +++ b/src/conode/application/manage_user_rights/revoke_role_from_user.py @@ -9,7 +9,7 @@ UserRepository, ) from conode.application.interfaces.transaction_manager import TransactionManager -from conode.application.services import AccessControlService +from conode.application.services import AccessControlService, AuthorizationService from conode.domain.role import RoleId from conode.domain.user import UserId @@ -20,11 +20,12 @@ class RevokeRoleFromUserInteractor: role_repository: RoleRepository user_repository: UserRepository access_control_service: AccessControlService + authorization_service: AuthorizationService transaction_manager: TransactionManager async def execute(self, user_id: UserId, role_id: RoleId) -> None: async with self.transaction_manager: - executor = await self.access_control_service.get_authorized_user() + executor = await self.authorization_service.get_authorized_user() role = await self.role_repository.get_by_id(role_id) diff --git a/src/conode/application/receive_context_info/get_context_by_id.py b/src/conode/application/receive_context_info/get_context_by_id.py index 0668b38..88ba4ef 100644 --- a/src/conode/application/receive_context_info/get_context_by_id.py +++ b/src/conode/application/receive_context_info/get_context_by_id.py @@ -1,17 +1,18 @@ from dataclasses import dataclass from conode.application.interfaces.repositories import ContextRepository -from conode.application.services import AccessControlService +from conode.application.services import AccessControlService, AuthorizationService from conode.domain.context import Context, ContextId @dataclass class GetContextByIdInteractor: access_control_service: AccessControlService + authorization_service: AuthorizationService context_repository: ContextRepository async def execute(self, context_id: ContextId) -> Context: - user = await self.access_control_service.get_authorized_user() + user = await self.authorization_service.get_authorized_user() context = await self.context_repository.get_by_id(context_id) diff --git a/src/conode/application/receive_graph_statistics/neighbours.py b/src/conode/application/receive_graph_statistics/neighbours.py index bf378ff..06824ee 100644 --- a/src/conode/application/receive_graph_statistics/neighbours.py +++ b/src/conode/application/receive_graph_statistics/neighbours.py @@ -7,7 +7,7 @@ EdgeRepository, NodeRepository, ) -from conode.application.services import AccessControlService +from conode.application.services import AccessControlService, AuthorizationService from conode.domain.context import ContextId from conode.domain.edge import Edge from conode.domain.node import Node, NodeId @@ -23,6 +23,7 @@ class GetNodeNeighboursRequestDTO: @dataclass class GetNodeNeighboursInteractor: access_control_service: AccessControlService + authorization_service: AuthorizationService node_repository: NodeRepository context_repository: ContextRepository edge_repository: EdgeRepository @@ -30,7 +31,7 @@ class GetNodeNeighboursInteractor: async def execute( self, request: GetNodeNeighboursRequestDTO ) -> list[tuple[Node, Edge]]: - user = await self.access_control_service.get_authorized_user() + user = await self.authorization_service.get_authorized_user() context = await self.context_repository.get_by_id(request.context_id) diff --git a/src/conode/application/receive_graph_statistics/shortest_path.py b/src/conode/application/receive_graph_statistics/shortest_path.py index 68ae5cb..1c43255 100644 --- a/src/conode/application/receive_graph_statistics/shortest_path.py +++ b/src/conode/application/receive_graph_statistics/shortest_path.py @@ -4,7 +4,7 @@ from math import inf from conode.application.interfaces.repositories import ContextRepository, EdgeRepository -from conode.application.services import AccessControlService +from conode.application.services import AccessControlService, AuthorizationService from conode.domain.context import ContextId from conode.domain.edge import Edge from conode.domain.node import NodeId @@ -20,6 +20,7 @@ class FindShortestPathRequestDTO: @dataclass class FindShortestPathInteractor: access_control_service: AccessControlService + authorization_service: AuthorizationService context_repository: ContextRepository edge_repository: EdgeRepository @@ -27,7 +28,7 @@ async def execute(self, request: FindShortestPathRequestDTO) -> list[Edge]: if request.from_node_id == request.to_node_id: return [] - user = await self.access_control_service.get_authorized_user() + user = await self.authorization_service.get_authorized_user() context = await self.context_repository.get_by_id(request.context_id) await self.access_control_service.ensure_user_can_view_context(user, context) diff --git a/src/conode/application/receive_group_info/get_group_by_id.py b/src/conode/application/receive_group_info/get_group_by_id.py index 8529b43..d4462d7 100644 --- a/src/conode/application/receive_group_info/get_group_by_id.py +++ b/src/conode/application/receive_group_info/get_group_by_id.py @@ -1,17 +1,18 @@ from dataclasses import dataclass from conode.application.interfaces.repositories import GroupRepository -from conode.application.services import AccessControlService +from conode.application.services import AccessControlService, AuthorizationService from conode.domain.group import Group, GroupId @dataclass class GetGroupByIdInteractor: access_control_service: AccessControlService + authorization_service: AuthorizationService group_repository: GroupRepository async def execute(self, group_id: GroupId) -> Group: - user = await self.access_control_service.get_authorized_user() + user = await self.authorization_service.get_authorized_user() group = await self.group_repository.get_by_id(group_id) diff --git a/src/conode/application/receive_node_info/get_nodes_by_group.py b/src/conode/application/receive_node_info/get_nodes_by_group.py index f500eb3..9801fc0 100644 --- a/src/conode/application/receive_node_info/get_nodes_by_group.py +++ b/src/conode/application/receive_node_info/get_nodes_by_group.py @@ -6,7 +6,7 @@ NodeAssociationRepository, NodeRepository, ) -from conode.application.services import AccessControlService +from conode.application.services import AccessControlService, AuthorizationService from conode.domain.group import GroupId from conode.domain.node import Node @@ -15,12 +15,13 @@ class GetNodesByGroupInteractor: node_association_repository: NodeAssociationRepository access_control_service: AccessControlService + authorization_service: AuthorizationService company_repository: CompanyRepository group_repository: GroupRepository node_repository: NodeRepository async def execute(self, group_id: GroupId) -> list[Node]: - user = await self.access_control_service.get_authorized_user() + user = await self.authorization_service.get_authorized_user() group = await self.group_repository.get_by_id(group_id) diff --git a/src/conode/application/receive_user_info/get_current_user.py b/src/conode/application/receive_user_info/get_current_user.py index 80f32e8..5812c74 100644 --- a/src/conode/application/receive_user_info/get_current_user.py +++ b/src/conode/application/receive_user_info/get_current_user.py @@ -1,12 +1,12 @@ from dataclasses import dataclass -from conode.application.services import AccessControlService +from conode.application.services import AuthorizationService from conode.domain.user import User @dataclass class GetCurrentUserInteractor: - access_control_service: AccessControlService + authorization_service: AuthorizationService async def execute(self) -> User: - return await self.access_control_service.get_authorized_user() + return await self.authorization_service.get_authorized_user() diff --git a/src/conode/application/receive_user_info/get_user_by_username.py b/src/conode/application/receive_user_info/get_user_by_username.py index 48a867d..717c9fd 100644 --- a/src/conode/application/receive_user_info/get_user_by_username.py +++ b/src/conode/application/receive_user_info/get_user_by_username.py @@ -2,17 +2,17 @@ from conode.application.errors import UserNotFoundError from conode.application.interfaces.repositories import UserRepository -from conode.application.services import AccessControlService +from conode.application.services import AuthorizationService from conode.domain.user import User, Username @dataclass class GetUserByUsernameInteractor: - access_control_service: AccessControlService + authorization_service: AuthorizationService user_repository: UserRepository async def execute(self, username: str) -> User: - await self.access_control_service.get_authorized_user() + await self.authorization_service.get_authorized_user() user = await self.user_repository.get_by_username(Username(username)) if user is None: diff --git a/src/conode/application/register_company/register_company.py b/src/conode/application/register_company/register_company.py index ffd39fe..91b7239 100644 --- a/src/conode/application/register_company/register_company.py +++ b/src/conode/application/register_company/register_company.py @@ -8,7 +8,7 @@ UserGrantRepository, ) from conode.application.interfaces.transaction_manager import TransactionManager -from conode.application.services import AccessControlService, RoleManagmentService +from conode.application.services import AuthorizationService, RoleManagmentService from conode.domain.company import Company, CompanyId from conode.domain.grant import UserGrant, UserGrantId from conode.domain.role import ( @@ -27,16 +27,15 @@ class RegisterCompanyRequestDTO: class RegisterCompanyInteractor: company_repository: CompanyRepository transaction_manager: TransactionManager - access_control_service: AccessControlService + authorization_service: AuthorizationService role_managment_service: RoleManagmentService role_permissions_repository: RolePermissionsRepository user_grant_repository: UserGrantRepository role_repository: RoleRepository async def execute(self, request: RegisterCompanyRequestDTO) -> Company: - user = await self.access_control_service.get_authorized_user() - async with self.transaction_manager: + user = await self.authorization_service.get_authorized_user() company = Company.new( company_id=CompanyId(uuid4()), name=request.name, diff --git a/src/conode/application/services/access_control.py b/src/conode/application/services/access_control.py index a3128a8..20fad47 100644 --- a/src/conode/application/services/access_control.py +++ b/src/conode/application/services/access_control.py @@ -1,14 +1,12 @@ from dataclasses import dataclass from typing import Final -from conode.application.errors import NotEnoughRightsError, UserNotFoundError -from conode.application.interfaces.identity_provider import IdentityProvider +from conode.application.errors import NotEnoughRightsError from conode.application.interfaces.repositories import ( CompanyRepository, RolePermissionsRepository, RoleRepository, UserGrantRepository, - UserRepository, ) from conode.application.interfaces.transaction_manager import TransactionManager from conode.domain.company import Company @@ -22,7 +20,7 @@ RolePermission, RolePermissionEntityId, ) -from conode.domain.user import Email, User +from conode.domain.user import User type RolesPermissions = list[RolePermission] @@ -38,9 +36,7 @@ class AccessControlService: user_grant_repository: UserGrantRepository role_repository: RoleRepository company_repository: CompanyRepository - identity_provider: IdentityProvider transaction_manager: TransactionManager - user_repository: UserRepository async def _get_all_permissions(self, user: User) -> list[RolePermission]: grants = await self.user_grant_repository.get_all_by_user_id(user.id) @@ -48,18 +44,6 @@ async def _get_all_permissions(self, user: User) -> list[RolePermission]: return await self.role_permissions_repository.get_all_by_role_ids(role_ids) - async def get_authorized_user(self) -> User: - meta = self.identity_provider.get_current_user_meta() - - user = await self.user_repository.get_by_email(Email(meta["email"])) - if user is None: - raise UserNotFoundError( - "User with this email not found", - [{"key": "email", "value": meta["email"]}], - ) - - return user - def check( self, *, diff --git a/src/conode/application/services/authorization.py b/src/conode/application/services/authorization.py index aa62072..85f778a 100644 --- a/src/conode/application/services/authorization.py +++ b/src/conode/application/services/authorization.py @@ -2,13 +2,16 @@ from typing import NamedTuple from uuid import uuid4 +from conode.application.errors import UserNotFoundError +from conode.application.interfaces.identity_provider import IdentityProvider from conode.application.interfaces.password_hasher import PasswordHasher +from conode.application.interfaces.repositories import UserRepository from conode.application.interfaces.token_managers import ( AccessTokenManager, RefreshTokenManager, ) from conode.domain.auth import Authorization, AuthorizationId, Session, SessionId -from conode.domain.user import User +from conode.domain.user import Email, User class AuthorizationServiceResponse(NamedTuple): @@ -24,6 +27,20 @@ class AuthorizationService: access_token_manager: AccessTokenManager refresh_token_manager: RefreshTokenManager password_hasher: PasswordHasher + identity_provider: IdentityProvider + user_repository: UserRepository + + async def get_authorized_user(self) -> User: + meta = self.identity_provider.get_current_user_meta() + + user = await self.user_repository.get_by_email(Email(meta["email"])) + if user is None: + raise UserNotFoundError( + "User with this email not found", + [{"key": "email", "value": meta["email"]}], + ) + + return user def generate_authorization_stuff( self, user: User, password: str, ip: str diff --git a/src/conode/application/share_graph/accept_offer.py b/src/conode/application/share_graph/accept_offer.py index af38e44..3a596b7 100644 --- a/src/conode/application/share_graph/accept_offer.py +++ b/src/conode/application/share_graph/accept_offer.py @@ -19,6 +19,7 @@ from conode.application.interfaces.transaction_manager import TransactionManager from conode.application.services import ( AccessControlService, + AuthorizationService, OfferAcceptanceService, RoleManagmentService, ) @@ -31,6 +32,7 @@ @dataclass class AcceptOfferInteractor: access_control_service: AccessControlService + authorization_service: AuthorizationService role_managment_service: RoleManagmentService offer_acceptance_service: OfferAcceptanceService transaction_manager: TransactionManager @@ -44,9 +46,8 @@ class AcceptOfferInteractor: offer_context_repository: OfferContextRepository async def execute(self, offer_id: OfferId) -> Contract: - user = await self.access_control_service.get_authorized_user() - async with self.transaction_manager: + user = await self.authorization_service.get_authorized_user() offer = await self.offer_repository.get_by_id(offer_id) offer_from_company, offer_to_company = await self._get_offer_companies( diff --git a/src/conode/application/share_graph/decline_offer.py b/src/conode/application/share_graph/decline_offer.py index f6b5a8a..a62e7c2 100644 --- a/src/conode/application/share_graph/decline_offer.py +++ b/src/conode/application/share_graph/decline_offer.py @@ -9,7 +9,7 @@ OfferRepository, ) from conode.application.interfaces.transaction_manager import TransactionManager -from conode.application.services import AccessControlService +from conode.application.services import AccessControlService, AuthorizationService from conode.domain.offer import OfferId @@ -19,12 +19,12 @@ class DeclineOfferInteractor: offer_link_repository: OfferLinkRepository offer_repository: OfferRepository access_control_service: AccessControlService + authorization_service: AuthorizationService company_repository: CompanyRepository async def execute(self, offer_id: OfferId) -> None: - user = await self.access_control_service.get_authorized_user() - async with self.transaction_manager: + user = await self.authorization_service.get_authorized_user() offer = await self.offer_repository.get_by_id(offer_id) company = await self.company_repository.get_by_id(offer.to_company_id) diff --git a/src/conode/application/share_graph/send_offer_to_company.py b/src/conode/application/share_graph/send_offer_to_company.py index fa108a4..c0ad1dc 100644 --- a/src/conode/application/share_graph/send_offer_to_company.py +++ b/src/conode/application/share_graph/send_offer_to_company.py @@ -16,7 +16,11 @@ OfferRepository, ) from conode.application.interfaces.transaction_manager import TransactionManager -from conode.application.services import AccessControlService, OfferSendingService +from conode.application.services import ( + AccessControlService, + AuthorizationService, + OfferSendingService, +) from conode.domain.company import CompanyId from conode.domain.context import ContextId from conode.domain.group import GroupId @@ -47,13 +51,13 @@ class SendOfferToCompanyInteractor: offer_link_repository: OfferLinkRepository transaction_manager: TransactionManager access_control_service: AccessControlService + authorization_service: AuthorizationService group_repository: GroupRepository context_repository: ContextRepository async def execute(self, request: SendOfferToCompanyRequestDTO) -> Offer: - user = await self.access_control_service.get_authorized_user() - async with self.transaction_manager: + user = await self.authorization_service.get_authorized_user() from_company, to_company = ( await self.company_repository.get_by_id(request.from_company_id), await self.company_repository.get_by_id(request.to_company_id), diff --git a/src/conode/application/update_edge_weight/decrement_edge_weight.py b/src/conode/application/update_edge_weight/decrement_edge_weight.py index 44161f9..400e4be 100644 --- a/src/conode/application/update_edge_weight/decrement_edge_weight.py +++ b/src/conode/application/update_edge_weight/decrement_edge_weight.py @@ -6,7 +6,7 @@ EdgeRepository, ) from conode.application.interfaces.transaction_manager import TransactionManager -from conode.application.services import AccessControlService +from conode.application.services import AccessControlService, AuthorizationService from conode.domain.edge import EdgeId @@ -17,11 +17,11 @@ class DecrementEdgeWeightInteractor: context_repository: ContextRepository transaction_manager: TransactionManager access_control_service: AccessControlService + authorization_service: AuthorizationService async def execute(self, edge_id: EdgeId) -> None: - user = await self.access_control_service.get_authorized_user() - async with self.transaction_manager: + user = await self.authorization_service.get_authorized_user() edge = await self.edge_repository.get_by_id(edge_id) context = await self.context_repository.get_by_id(edge.context_id) diff --git a/src/conode/application/update_edge_weight/increment_edge_weight.py b/src/conode/application/update_edge_weight/increment_edge_weight.py index d980849..ceeb974 100644 --- a/src/conode/application/update_edge_weight/increment_edge_weight.py +++ b/src/conode/application/update_edge_weight/increment_edge_weight.py @@ -6,7 +6,7 @@ EdgeRepository, ) from conode.application.interfaces.transaction_manager import TransactionManager -from conode.application.services import AccessControlService +from conode.application.services import AccessControlService, AuthorizationService from conode.domain.edge import EdgeId @@ -16,12 +16,12 @@ class IncrementEdgeWeightInteractor: edge_repository: EdgeRepository transaction_manager: TransactionManager access_control_service: AccessControlService + authorization_service: AuthorizationService context_repository: ContextRepository async def execute(self, edge_id: EdgeId) -> None: - user = await self.access_control_service.get_authorized_user() - async with self.transaction_manager: + user = await self.authorization_service.get_authorized_user() edge = await self.edge_repository.get_by_id(edge_id) context = await self.context_repository.get_by_id(edge.context_id) diff --git a/src/conode/application/update_edge_weight/update_edge_weight.py b/src/conode/application/update_edge_weight/update_edge_weight.py index 4544236..a44a094 100644 --- a/src/conode/application/update_edge_weight/update_edge_weight.py +++ b/src/conode/application/update_edge_weight/update_edge_weight.py @@ -6,7 +6,7 @@ EdgeRepository, ) from conode.application.interfaces.transaction_manager import TransactionManager -from conode.application.services import AccessControlService +from conode.application.services import AccessControlService, AuthorizationService from conode.domain.edge import EdgeId @@ -16,12 +16,12 @@ class UpdateEdgeWeightInteractor: edge_repository: EdgeRepository transaction_manager: TransactionManager access_control_service: AccessControlService + authorization_service: AuthorizationService context_repository: ContextRepository async def execute(self, edge_id: EdgeId, weight: float) -> None: - user = await self.access_control_service.get_authorized_user() - async with self.transaction_manager: + user = await self.authorization_service.get_authorized_user() edge = await self.edge_repository.get_by_id(edge_id) context = await self.context_repository.get_by_id(edge.context_id) diff --git a/src/conode/application/update_node/update_node.py b/src/conode/application/update_node/update_node.py index 2b9356d..dc13e04 100644 --- a/src/conode/application/update_node/update_node.py +++ b/src/conode/application/update_node/update_node.py @@ -7,7 +7,7 @@ NodeRepository, ) from conode.application.interfaces.transaction_manager import TransactionManager -from conode.application.services import AccessControlService +from conode.application.services import AccessControlService, AuthorizationService from conode.domain.node import NodeId @@ -21,6 +21,7 @@ class UpdateNodeRequestDTO: @dataclass class UpdateNodeInteractor: access_control_service: AccessControlService + authorization_service: AuthorizationService node_repository: NodeRepository company_repository: CompanyRepository node_association_repository: NodeAssociationRepository @@ -28,9 +29,8 @@ class UpdateNodeInteractor: transaction_manager: TransactionManager async def execute(self, request: UpdateNodeRequestDTO) -> None: - user = await self.access_control_service.get_authorized_user() - async with self.transaction_manager: + user = await self.authorization_service.get_authorized_user() node = await self.node_repository.get_by_id(request.node_id) existing_associations = ( diff --git a/src/conode/application/verify_company/verify_company.py b/src/conode/application/verify_company/verify_company.py index 3fdc4a6..6f70a83 100644 --- a/src/conode/application/verify_company/verify_company.py +++ b/src/conode/application/verify_company/verify_company.py @@ -5,7 +5,7 @@ ) from conode.application.interfaces.repositories import CompanyRepository from conode.application.interfaces.transaction_manager import TransactionManager -from conode.application.services import AccessControlService +from conode.application.services import AccessControlService, AuthorizationService from conode.domain.company import CompanyId @@ -14,11 +14,11 @@ class VerifyCompanyInteractor: transaction_manager: TransactionManager company_repository: CompanyRepository access_control_service: AccessControlService + authorization_service: AuthorizationService async def execute(self, company_id: CompanyId) -> None: - user = await self.access_control_service.get_authorized_user() - async with self.transaction_manager: + user = await self.authorization_service.get_authorized_user() self.access_control_service.ensure_user_can_verify_companies(user) company = await self.company_repository.get_by_id(company_id) diff --git a/src/conode/infrastructure/persistence/registry.py b/src/conode/infrastructure/persistence/registry.py index 91a18e4..d81bb62 100644 --- a/src/conode/infrastructure/persistence/registry.py +++ b/src/conode/infrastructure/persistence/registry.py @@ -1,390 +1,39 @@ -from sqlalchemy import ( - UUID, - Boolean, - Column, - DateTime, - Enum, - Float, - ForeignKey, - MetaData, - String, - Table, - UniqueConstraint, -) from sqlalchemy.orm import registry from conode.domain.auth import Authorization, Session from conode.domain.company import Company from conode.domain.context import Context -from conode.domain.contract import Contract, ContractStatus +from conode.domain.contract import Contract from conode.domain.edge import Edge from conode.domain.grant import UserGrant from conode.domain.group import Group from conode.domain.node import Node, NodeAssociation -from conode.domain.offer import ( - Offer, - OfferContext, - OfferGroup, - OfferLink, - OfferLinkStatus, - OfferStatus, -) -from conode.domain.role import EntityType, PermissionType, Role, RolePermission -from conode.domain.user import User, UserSystemRole -from conode.infrastructure.persistence.types import ( - BioType, - CompanyDescriptionType, - CompanyNameType, - ContextDescriptionType, - ContextNameType, - EmailType, - FirstNameType, - GroupDescriptionType, - GroupNameType, - LastNameType, - NodeDescriptionType, - NodeNameType, - OfferDescriptionType, - OfferTitleType, - RoleNameType, - UsernameType, +from conode.domain.offer import Offer, OfferContext, OfferGroup, OfferLink +from conode.domain.role import Role, RolePermission +from conode.domain.user import User +from conode.infrastructure.persistence.schemas import ( + authorization_record_table, + company_record_table, + context_record_table, + contract_record_table, + edge_record_table, + group_record_table, + metadata, + node_association_record_table, + node_record_table, + offer_context_record_table, + offer_group_record_table, + offer_link_record_table, + offer_record_table, + permission_record_table, + role_record_table, + session_record_table, + user_grant_record_table, + user_record_table, ) -metadata = MetaData() registry_mapper = registry(metadata=metadata) -user_record_table = Table( - "user_record", - metadata, - Column("id", UUID, primary_key=True, nullable=False), - Column("system_role", Enum(UserSystemRole), nullable=False), - Column("username", UsernameType, nullable=False), - Column("last_name", LastNameType, nullable=False), - Column("first_name", FirstNameType, nullable=False), - Column("email_verified", Boolean, nullable=False), - Column("email", EmailType, nullable=False), - Column("bio", BioType, nullable=False), - Column("created_at", DateTime(timezone=True), nullable=False), - Column("updated_at", DateTime(timezone=True), nullable=False), - UniqueConstraint("username", name="uq_user_record_username"), - UniqueConstraint("email", name="uq_user_record_email"), -) - -authorization_record_table = Table( - "authorization_record", - metadata, - Column("id", UUID, primary_key=True, nullable=False), - Column("user_id", ForeignKey("user_record.id", ondelete="CASCADE"), nullable=False), - Column("hashed_password", String, nullable=False), - Column("created_at", DateTime(timezone=True), nullable=False), - Column("updated_at", DateTime(timezone=True), nullable=False), -) - -session_record_table = Table( - "session_record", - metadata, - Column("id", UUID, primary_key=True, nullable=False), - Column("ip", String, primary_key=True, nullable=False), - Column("user_id", ForeignKey("user_record.id", ondelete="CASCADE"), nullable=False), - Column("refresh_token", String, nullable=False), - Column("created_at", DateTime(timezone=True), nullable=False), - Column("updated_at", DateTime(timezone=True), nullable=False), - UniqueConstraint("user_id", "ip", name="uq_session_record_user_id_ip"), -) - -company_record_table = Table( - "company_record", - metadata, - Column("id", UUID, primary_key=True, nullable=False), - Column( - "owner_id", - ForeignKey("user_record.id", ondelete="CASCADE"), - nullable=False, - ), - Column("name", CompanyNameType, nullable=False, unique=True), - Column("description", CompanyDescriptionType, nullable=False), - Column("verified", Boolean, nullable=False), - Column("created_at", DateTime(timezone=True), nullable=False), - Column("updated_at", DateTime(timezone=True), nullable=False), -) - -node_record_table = Table( - "node_record", - metadata, - Column("id", UUID, primary_key=True, nullable=False), - Column("name", NodeNameType, nullable=False), - Column("description", NodeDescriptionType, nullable=False), - Column( - "company_id", - ForeignKey("company_record.id", ondelete="CASCADE"), - nullable=False, - ), - Column("created_at", DateTime(timezone=True), nullable=False), - Column("updated_at", DateTime(timezone=True), nullable=False), -) - -group_record_table = Table( - "group_record", - metadata, - Column("id", UUID, primary_key=True, nullable=False), - Column("name", GroupNameType, nullable=False), - Column("description", GroupDescriptionType, nullable=False), - Column( - "company_id", - ForeignKey("company_record.id", ondelete="CASCADE"), - nullable=False, - ), - Column( - "parent_group_id", - ForeignKey("group_record.id", ondelete="CASCADE"), - nullable=True, - ), - Column("created_at", DateTime(timezone=True), nullable=False), - Column("updated_at", DateTime(timezone=True), nullable=False), -) - -node_association_record_table = Table( - "node_association_record", - metadata, - Column("id", UUID, primary_key=True, nullable=False), - Column( - "group_id", - ForeignKey("group_record.id", ondelete="CASCADE"), - nullable=False, - ), - Column("node_id", ForeignKey("node_record.id", ondelete="CASCADE"), nullable=False), - Column("created_at", DateTime(timezone=True), nullable=False), - Column("updated_at", DateTime(timezone=True), nullable=False), - UniqueConstraint( - "group_id", "node_id", name="uq_node_association_record_group_id_node_id" - ), -) - - -context_record_table = Table( - "context_record", - metadata, - Column("id", UUID, primary_key=True, nullable=False), - Column( - "company_id", - ForeignKey("company_record.id", ondelete="CASCADE"), - nullable=False, - ), - Column("name", ContextNameType, nullable=False), - Column("description", ContextDescriptionType, nullable=False), - Column("created_at", DateTime(timezone=True), nullable=False), - Column("updated_at", DateTime(timezone=True), nullable=False), -) - -edge_record_table = Table( - "edge_record", - metadata, - Column("id", UUID, primary_key=True, nullable=False), - Column( - "node_a_id", - ForeignKey("node_record.id", ondelete="CASCADE"), - nullable=False, - ), - Column( - "node_b_id", - ForeignKey("node_record.id", ondelete="CASCADE"), - nullable=False, - ), - Column( - "context_id", - ForeignKey("context_record.id", ondelete="CASCADE"), - nullable=False, - ), - Column( - "company_id", - ForeignKey("company_record.id", ondelete="CASCADE"), - nullable=False, - ), - Column("weight", Float, nullable=False), - Column("created_at", DateTime(timezone=True), nullable=False), - Column("updated_at", DateTime(timezone=True), nullable=False), - UniqueConstraint( - "context_id", - "node_a_id", - "node_b_id", - name="uq_edge_record_node_a_id_node_b_id_context_id", - ), -) - -role_record_table = Table( - "role_record", - metadata, - Column("id", UUID, primary_key=True, nullable=False), - Column("name", RoleNameType, nullable=False), - Column( - "owner_company_id", - ForeignKey("company_record.id", ondelete="CASCADE"), - nullable=False, - ), - Column("created_at", DateTime(timezone=True), nullable=False), - Column("updated_at", DateTime(timezone=True), nullable=False), -) - -permission_record_table = Table( - "permission_record", - metadata, - Column("id", UUID, primary_key=True, nullable=False), - Column( - "role_id", - ForeignKey("role_record.id", ondelete="CASCADE"), - nullable=False, - ), - Column("permission", Enum(PermissionType), nullable=False), - Column("entity_id", UUID, nullable=False), - Column("entity_type", Enum(EntityType), nullable=False), - Column("created_at", DateTime(timezone=True), nullable=False), - Column("updated_at", DateTime(timezone=True), nullable=False), -) - -user_grant_record_table = Table( - "user_grant_record", - metadata, - Column("id", UUID, primary_key=True, nullable=False), - Column( - "role_id", - ForeignKey("role_record.id", ondelete="CASCADE"), - nullable=False, - ), - Column( - "user_id", - ForeignKey("user_record.id", ondelete="CASCADE"), - nullable=False, - ), - Column("created_at", DateTime(timezone=True), nullable=False), - Column("updated_at", DateTime(timezone=True), nullable=False), -) - -offer_link_record_table = Table( - "offer_link_record", - metadata, - Column("id", UUID, primary_key=True, nullable=False), - Column( - "request_offer_id", - ForeignKey("offer_record.id", ondelete="CASCADE"), - nullable=False, - ), - Column( - "response_offer_id", - ForeignKey("offer_record.id", ondelete="CASCADE"), - nullable=False, - ), - Column("status", Enum(OfferLinkStatus), nullable=False), - Column("created_at", DateTime(timezone=True), nullable=False), - Column("updated_at", DateTime(timezone=True), nullable=False), -) - -offer_group_record_table = Table( - "offer_group_record", - metadata, - Column("id", UUID, primary_key=True, nullable=False), - Column( - "offer_id", - ForeignKey("offer_record.id", ondelete="CASCADE"), - nullable=False, - ), - Column( - "group_id", - ForeignKey("group_record.id", ondelete="CASCADE"), - nullable=False, - ), - Column("permission_type", Enum(PermissionType), nullable=False), - Column("created_at", DateTime(timezone=True), nullable=False), - Column("updated_at", DateTime(timezone=True), nullable=False), -) - -offer_context_record_table = Table( - "offer_context_record", - metadata, - Column("id", UUID, primary_key=True, nullable=False), - Column( - "offer_id", - ForeignKey("offer_record.id", ondelete="CASCADE"), - nullable=False, - ), - Column( - "context_id", - ForeignKey("context_record.id", ondelete="CASCADE"), - nullable=False, - ), - Column("permission_type", Enum(PermissionType), nullable=False), - Column("created_at", DateTime(timezone=True), nullable=False), - Column("updated_at", DateTime(timezone=True), nullable=False), -) - - -offer_record_table = Table( - "offer_record", - metadata, - Column("id", UUID, primary_key=True, nullable=False), - Column("title", OfferTitleType, nullable=False), - Column("description", OfferDescriptionType, nullable=False), - Column("status", Enum(OfferStatus), nullable=False), - Column( - "from_company_id", - ForeignKey("company_record.id", ondelete="CASCADE"), - nullable=True, - ), - Column( - "to_company_id", - ForeignKey("company_record.id", ondelete="CASCADE"), - nullable=False, - ), - Column( - "from_offer", - ForeignKey("offer_record.id", ondelete="CASCADE"), - nullable=True, - ), - Column("requires_counteroffer", Boolean, nullable=False), - Column("expires_in", DateTime(timezone=True)), - Column("created_at", DateTime(timezone=True), nullable=False), - Column("updated_at", DateTime(timezone=True), nullable=False), -) - -contract_record_table = Table( - "contract_record", - metadata, - Column("id", UUID, primary_key=True, nullable=False), - Column("status", Enum(ContractStatus), nullable=False), - Column( - "company_a_id", - ForeignKey("company_record.id", ondelete="CASCADE"), - nullable=False, - ), - Column( - "company_b_id", - ForeignKey("company_record.id", ondelete="CASCADE"), - nullable=False, - ), - Column( - "company_a_offer_id", - ForeignKey("offer_record.id", ondelete="CASCADE"), - nullable=False, - ), - Column( - "company_b_offer_id", - ForeignKey("offer_record.id", ondelete="CASCADE"), - nullable=True, - ), - Column( - "company_a_role_id", - ForeignKey("role_record.id", ondelete="CASCADE"), - nullable=False, - ), - Column( - "company_b_role_id", - ForeignKey("role_record.id", ondelete="CASCADE"), - nullable=True, - ), - Column("expires_in", DateTime(timezone=True)), - Column("created_at", DateTime(timezone=True), nullable=False), - Column("updated_at", DateTime(timezone=True), nullable=False), -) - def start_mapper() -> None: registry_mapper.map_imperatively(User, user_record_table) diff --git a/src/conode/infrastructure/persistence/schemas.py b/src/conode/infrastructure/persistence/schemas.py new file mode 100644 index 0000000..a5edce7 --- /dev/null +++ b/src/conode/infrastructure/persistence/schemas.py @@ -0,0 +1,370 @@ +from sqlalchemy import ( + UUID, + Boolean, + Column, + DateTime, + Enum, + Float, + ForeignKey, + MetaData, + String, + Table, + UniqueConstraint, +) + +from conode.domain.contract import ContractStatus +from conode.domain.offer import OfferLinkStatus, OfferStatus +from conode.domain.role import EntityType, PermissionType +from conode.domain.user import UserSystemRole +from conode.infrastructure.persistence.types import ( + BioType, + CompanyDescriptionType, + CompanyNameType, + ContextDescriptionType, + ContextNameType, + EmailType, + FirstNameType, + GroupDescriptionType, + GroupNameType, + LastNameType, + NodeDescriptionType, + NodeNameType, + OfferDescriptionType, + OfferTitleType, + RoleNameType, + UsernameType, +) + +metadata = MetaData() + +user_record_table = Table( + "user_record", + metadata, + Column("id", UUID, primary_key=True, nullable=False), + Column("system_role", Enum(UserSystemRole), nullable=False), + Column("username", UsernameType, nullable=False), + Column("last_name", LastNameType, nullable=False), + Column("first_name", FirstNameType, nullable=False), + Column("email_verified", Boolean, nullable=False), + Column("email", EmailType, nullable=False), + Column("bio", BioType, nullable=False), + Column("created_at", DateTime(timezone=True), nullable=False), + Column("updated_at", DateTime(timezone=True), nullable=False), + UniqueConstraint("username", name="uq_user_record_username"), + UniqueConstraint("email", name="uq_user_record_email"), +) + +authorization_record_table = Table( + "authorization_record", + metadata, + Column("id", UUID, primary_key=True, nullable=False), + Column("user_id", ForeignKey("user_record.id", ondelete="CASCADE"), nullable=False), + Column("hashed_password", String, nullable=False), + Column("created_at", DateTime(timezone=True), nullable=False), + Column("updated_at", DateTime(timezone=True), nullable=False), +) + +session_record_table = Table( + "session_record", + metadata, + Column("id", UUID, primary_key=True, nullable=False), + Column("ip", String, primary_key=True, nullable=False), + Column("user_id", ForeignKey("user_record.id", ondelete="CASCADE"), nullable=False), + Column("refresh_token", String, nullable=False), + Column("created_at", DateTime(timezone=True), nullable=False), + Column("updated_at", DateTime(timezone=True), nullable=False), + UniqueConstraint("user_id", "ip", name="uq_session_record_user_id_ip"), +) + +company_record_table = Table( + "company_record", + metadata, + Column("id", UUID, primary_key=True, nullable=False), + Column( + "owner_id", + ForeignKey("user_record.id", ondelete="CASCADE"), + nullable=False, + ), + Column("name", CompanyNameType, nullable=False, unique=True), + Column("description", CompanyDescriptionType, nullable=False), + Column("verified", Boolean, nullable=False), + Column("created_at", DateTime(timezone=True), nullable=False), + Column("updated_at", DateTime(timezone=True), nullable=False), +) + +node_record_table = Table( + "node_record", + metadata, + Column("id", UUID, primary_key=True, nullable=False), + Column("name", NodeNameType, nullable=False), + Column("description", NodeDescriptionType, nullable=False), + Column( + "company_id", + ForeignKey("company_record.id", ondelete="CASCADE"), + nullable=False, + ), + Column("created_at", DateTime(timezone=True), nullable=False), + Column("updated_at", DateTime(timezone=True), nullable=False), +) + +group_record_table = Table( + "group_record", + metadata, + Column("id", UUID, primary_key=True, nullable=False), + Column("name", GroupNameType, nullable=False), + Column("description", GroupDescriptionType, nullable=False), + Column( + "company_id", + ForeignKey("company_record.id", ondelete="CASCADE"), + nullable=False, + ), + Column( + "parent_group_id", + ForeignKey("group_record.id", ondelete="CASCADE"), + nullable=True, + ), + Column("created_at", DateTime(timezone=True), nullable=False), + Column("updated_at", DateTime(timezone=True), nullable=False), +) + +node_association_record_table = Table( + "node_association_record", + metadata, + Column("id", UUID, primary_key=True, nullable=False), + Column( + "group_id", + ForeignKey("group_record.id", ondelete="CASCADE"), + nullable=False, + ), + Column("node_id", ForeignKey("node_record.id", ondelete="CASCADE"), nullable=False), + Column("created_at", DateTime(timezone=True), nullable=False), + Column("updated_at", DateTime(timezone=True), nullable=False), + UniqueConstraint( + "group_id", "node_id", name="uq_node_association_record_group_id_node_id" + ), +) + + +context_record_table = Table( + "context_record", + metadata, + Column("id", UUID, primary_key=True, nullable=False), + Column( + "company_id", + ForeignKey("company_record.id", ondelete="CASCADE"), + nullable=False, + ), + Column("name", ContextNameType, nullable=False), + Column("description", ContextDescriptionType, nullable=False), + Column("created_at", DateTime(timezone=True), nullable=False), + Column("updated_at", DateTime(timezone=True), nullable=False), +) + +edge_record_table = Table( + "edge_record", + metadata, + Column("id", UUID, primary_key=True, nullable=False), + Column( + "node_a_id", + ForeignKey("node_record.id", ondelete="CASCADE"), + nullable=False, + ), + Column( + "node_b_id", + ForeignKey("node_record.id", ondelete="CASCADE"), + nullable=False, + ), + Column( + "context_id", + ForeignKey("context_record.id", ondelete="CASCADE"), + nullable=False, + ), + Column( + "company_id", + ForeignKey("company_record.id", ondelete="CASCADE"), + nullable=False, + ), + Column("weight", Float, nullable=False), + Column("created_at", DateTime(timezone=True), nullable=False), + Column("updated_at", DateTime(timezone=True), nullable=False), + UniqueConstraint( + "context_id", + "node_a_id", + "node_b_id", + name="uq_edge_record_node_a_id_node_b_id_context_id", + ), +) + +role_record_table = Table( + "role_record", + metadata, + Column("id", UUID, primary_key=True, nullable=False), + Column("name", RoleNameType, nullable=False), + Column( + "owner_company_id", + ForeignKey("company_record.id", ondelete="CASCADE"), + nullable=False, + ), + Column("created_at", DateTime(timezone=True), nullable=False), + Column("updated_at", DateTime(timezone=True), nullable=False), +) + +permission_record_table = Table( + "permission_record", + metadata, + Column("id", UUID, primary_key=True, nullable=False), + Column( + "role_id", + ForeignKey("role_record.id", ondelete="CASCADE"), + nullable=False, + ), + Column("permission", Enum(PermissionType), nullable=False), + Column("entity_id", UUID, nullable=False), + Column("entity_type", Enum(EntityType), nullable=False), + Column("created_at", DateTime(timezone=True), nullable=False), + Column("updated_at", DateTime(timezone=True), nullable=False), +) + +user_grant_record_table = Table( + "user_grant_record", + metadata, + Column("id", UUID, primary_key=True, nullable=False), + Column( + "role_id", + ForeignKey("role_record.id", ondelete="CASCADE"), + nullable=False, + ), + Column( + "user_id", + ForeignKey("user_record.id", ondelete="CASCADE"), + nullable=False, + ), + Column("created_at", DateTime(timezone=True), nullable=False), + Column("updated_at", DateTime(timezone=True), nullable=False), +) + +offer_link_record_table = Table( + "offer_link_record", + metadata, + Column("id", UUID, primary_key=True, nullable=False), + Column( + "request_offer_id", + ForeignKey("offer_record.id", ondelete="CASCADE"), + nullable=False, + ), + Column( + "response_offer_id", + ForeignKey("offer_record.id", ondelete="CASCADE"), + nullable=False, + ), + Column("status", Enum(OfferLinkStatus), nullable=False), + Column("created_at", DateTime(timezone=True), nullable=False), + Column("updated_at", DateTime(timezone=True), nullable=False), +) + +offer_group_record_table = Table( + "offer_group_record", + metadata, + Column("id", UUID, primary_key=True, nullable=False), + Column( + "offer_id", + ForeignKey("offer_record.id", ondelete="CASCADE"), + nullable=False, + ), + Column( + "group_id", + ForeignKey("group_record.id", ondelete="CASCADE"), + nullable=False, + ), + Column("permission_type", Enum(PermissionType), nullable=False), + Column("created_at", DateTime(timezone=True), nullable=False), + Column("updated_at", DateTime(timezone=True), nullable=False), +) + +offer_context_record_table = Table( + "offer_context_record", + metadata, + Column("id", UUID, primary_key=True, nullable=False), + Column( + "offer_id", + ForeignKey("offer_record.id", ondelete="CASCADE"), + nullable=False, + ), + Column( + "context_id", + ForeignKey("context_record.id", ondelete="CASCADE"), + nullable=False, + ), + Column("permission_type", Enum(PermissionType), nullable=False), + Column("created_at", DateTime(timezone=True), nullable=False), + Column("updated_at", DateTime(timezone=True), nullable=False), +) + + +offer_record_table = Table( + "offer_record", + metadata, + Column("id", UUID, primary_key=True, nullable=False), + Column("title", OfferTitleType, nullable=False), + Column("description", OfferDescriptionType, nullable=False), + Column("status", Enum(OfferStatus), nullable=False), + Column( + "from_company_id", + ForeignKey("company_record.id", ondelete="CASCADE"), + nullable=True, + ), + Column( + "to_company_id", + ForeignKey("company_record.id", ondelete="CASCADE"), + nullable=False, + ), + Column( + "from_offer", + ForeignKey("offer_record.id", ondelete="CASCADE"), + nullable=True, + ), + Column("requires_counteroffer", Boolean, nullable=False), + Column("expires_in", DateTime(timezone=True)), + Column("created_at", DateTime(timezone=True), nullable=False), + Column("updated_at", DateTime(timezone=True), nullable=False), +) + +contract_record_table = Table( + "contract_record", + metadata, + Column("id", UUID, primary_key=True, nullable=False), + Column("status", Enum(ContractStatus), nullable=False), + Column( + "company_a_id", + ForeignKey("company_record.id", ondelete="CASCADE"), + nullable=False, + ), + Column( + "company_b_id", + ForeignKey("company_record.id", ondelete="CASCADE"), + nullable=False, + ), + Column( + "company_a_offer_id", + ForeignKey("offer_record.id", ondelete="CASCADE"), + nullable=False, + ), + Column( + "company_b_offer_id", + ForeignKey("offer_record.id", ondelete="CASCADE"), + nullable=True, + ), + Column( + "company_a_role_id", + ForeignKey("role_record.id", ondelete="CASCADE"), + nullable=False, + ), + Column( + "company_b_role_id", + ForeignKey("role_record.id", ondelete="CASCADE"), + nullable=True, + ), + Column("expires_in", DateTime(timezone=True)), + Column("created_at", DateTime(timezone=True), nullable=False), + Column("updated_at", DateTime(timezone=True), nullable=False), +) diff --git a/tests/conftest.py b/tests/conftest.py index c170ae9..7c764e8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,6 +20,7 @@ create_async_engine, ) +from conode.application.interfaces.identity_provider import IdentityProvider from conode.application.interfaces.password_hasher import PasswordHasher from conode.application.interfaces.repositories import ( AuthorizationRepository, @@ -38,6 +39,7 @@ from conode.application.interfaces.token_managers import ( AccessTokenManager, RefreshTokenManager, + UserMeta, ) from conode.application.interfaces.transaction_manager import TransactionManager from conode.application.services import AuthorizationService, RoleManagmentService @@ -192,6 +194,7 @@ async def context_factory(container: AsyncContainer) -> ContextFactory: ) + @pytest.fixture async def user_factory(container: AsyncContainer) -> UserFactory: async with container() as test_container: From f5717f4286ab1baee34ac620d030fac1043fd9da Mon Sep 17 00:00:00 2001 From: Davlat Davydov Date: Thu, 20 Aug 2026 01:59:27 +0300 Subject: [PATCH 12/13] fix(tests): dishka dependency undefined --- tests/conftest.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 7c764e8..f6add8b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -194,12 +194,26 @@ async def context_factory(container: AsyncContainer) -> ContextFactory: ) +@dataclass +class NullIdentityProvider(IdentityProvider): + def get_current_ip(self) -> str: + raise NotImplementedError + + def get_current_user_meta(self) -> UserMeta: + raise NotImplementedError + @pytest.fixture async def user_factory(container: AsyncContainer) -> UserFactory: async with container() as test_container: return UserFactory( - authorization_service=await test_container.get(AuthorizationService), + authorization_service=AuthorizationService( + access_token_manager=await test_container.get(AccessTokenManager), + refresh_token_manager=await test_container.get(RefreshTokenManager), + password_hasher=await test_container.get(PasswordHasher), + identity_provider=NullIdentityProvider(), + user_repository=await test_container.get(UserRepository), + ), authorization_repository=await test_container.get(AuthorizationRepository), session_repository=await test_container.get(SessionRepository), refresh_token_manager=await test_container.get(RefreshTokenManager), From 7e353b347a6b542403c61bb69c9b39fa85cc5127 Mon Sep 17 00:00:00 2001 From: Davlat Davydov Date: Thu, 20 Aug 2026 02:08:53 +0300 Subject: [PATCH 13/13] chore(infrastructure): stardatize logging --- src/conode/infrastructure/repositories/context.py | 10 +++++----- .../infrastructure/repositories/grant/user.py | 4 ++-- src/conode/infrastructure/repositories/node.py | 4 ++-- src/conode/infrastructure/repositories/offer.py | 15 +++++++++------ .../infrastructure/repositories/permission.py | 4 +++- src/conode/infrastructure/repositories/role.py | 4 ++-- src/conode/infrastructure/repositories/user.py | 1 + 7 files changed, 24 insertions(+), 18 deletions(-) diff --git a/src/conode/infrastructure/repositories/context.py b/src/conode/infrastructure/repositories/context.py index e05f04c..7840609 100644 --- a/src/conode/infrastructure/repositories/context.py +++ b/src/conode/infrastructure/repositories/context.py @@ -61,19 +61,19 @@ async def delete(self, context: Context) -> None: ) async def get_all_by_company_id(self, company_id: CompanyId) -> list[Context]: - logger.debug("Repository get groups by contexts id", company_id=company_id) + logger.debug("Repository get contexts by company id", company_id=company_id) result = await self.session.execute( select(Context).where( Context.company_id == company_id, # type: ignore ), ) - result_groups = list(result.scalars()) + result_contexts = list(result.scalars()) logger.debug( - "Repository fetched contexts by company_id", - found_count=len(result_groups), + "Repository fetched contexts by company id", + found_count=len(result_contexts), ) - return result_groups + return result_contexts async def get_all_by_ids(self, context_ids: list[ContextId]) -> list[Context]: logger.debug("Repository get contexts by ids", request_count=len(context_ids)) diff --git a/src/conode/infrastructure/repositories/grant/user.py b/src/conode/infrastructure/repositories/grant/user.py index ea2a632..c85c911 100644 --- a/src/conode/infrastructure/repositories/grant/user.py +++ b/src/conode/infrastructure/repositories/grant/user.py @@ -82,7 +82,7 @@ async def get_all_by_user_id(self, user_id: UserId) -> list[UserGrant]: result_grants = list(result.scalars().all()) logger.debug( "Repository fetched grants by user id", - count=len(result_grants), + found_count=len(result_grants), ) return result_grants @@ -105,7 +105,7 @@ async def get_by_user_and_role_id( grant = result.scalar_one_or_none() logger.debug( - "Repository fetched context by id", + "Repository fetched user grant by user and role id", found=grant is not None, ) diff --git a/src/conode/infrastructure/repositories/node.py b/src/conode/infrastructure/repositories/node.py index 266eced..1f47fa4 100644 --- a/src/conode/infrastructure/repositories/node.py +++ b/src/conode/infrastructure/repositories/node.py @@ -254,7 +254,7 @@ async def get_all_by_group_id(self, group_id: GroupId) -> list[NodeAssociation]: result_associations = list(result.scalars()) logger.debug( "Repository fetched node associations by group id", - count=len(result_associations), + found_count=len(result_associations), ) return result_associations @@ -275,7 +275,7 @@ async def get_all_by_node_id( result_associations = list(result.scalars().all()) logger.debug( - "Repository fetched all node associations by node id", + "Repository fetched node associations by node id", found_count=len(result_associations), ) diff --git a/src/conode/infrastructure/repositories/offer.py b/src/conode/infrastructure/repositories/offer.py index 047a1c1..462c62f 100644 --- a/src/conode/infrastructure/repositories/offer.py +++ b/src/conode/infrastructure/repositories/offer.py @@ -132,9 +132,12 @@ async def get_by_offers_ids( ), ), ) - logger.debug("Repository fetched offer link by id", found=result is not None) + offer_link = result.scalar_one_or_none() + logger.debug( + "Repository fetched offer link by offer ids", found=offer_link is not None + ) - return result.scalar_one_or_none() + return offer_link @dataclass @@ -166,7 +169,7 @@ async def create_all(self, offer_groups: list[OfferGroup]) -> None: ) async def get_by_offer_id(self, offer_id: OfferId) -> list[OfferGroup]: - logger.debug("Repository get offer group by offer id") + logger.debug("Repository get offer groups by offer id", offer_id=offer_id) result = await self.session.execute( select(OfferGroup).where( OfferGroup.offer_id == offer_id, # type: ignore @@ -175,7 +178,7 @@ async def get_by_offer_id(self, offer_id: OfferId) -> list[OfferGroup]: result_offer_groups = list(result.scalars().all()) logger.debug( - "Repository offer groups by offer id", + "Repository fetched offer groups by offer id", found_count=len(result_offer_groups), ) @@ -212,7 +215,7 @@ async def create_all(self, offer_contexts: list[OfferContext]) -> None: ) async def get_by_offer_id(self, offer_id: OfferId) -> list[OfferContext]: - logger.debug("Repository get offer group by offer id") + logger.debug("Repository get offer contexts by offer id", offer_id=offer_id) result = await self.session.execute( select(OfferContext).where( OfferContext.offer_id == offer_id, # type: ignore @@ -221,7 +224,7 @@ async def get_by_offer_id(self, offer_id: OfferId) -> list[OfferContext]: result_offer_contexts = list(result.scalars().all()) logger.debug( - "Repository offer contexts by offer id", + "Repository fetched offer contexts by offer id", found_count=len(result_offer_contexts), ) diff --git a/src/conode/infrastructure/repositories/permission.py b/src/conode/infrastructure/repositories/permission.py index 16034ec..269effe 100644 --- a/src/conode/infrastructure/repositories/permission.py +++ b/src/conode/infrastructure/repositories/permission.py @@ -116,7 +116,9 @@ async def get_all_by_ids( self, permission_ids: list[RolePermissionId], ) -> list[RolePermission]: - logger.debug("Repository get nodes by ids", request_count=len(permission_ids)) + logger.debug( + "Repository get permissions by ids", request_count=len(permission_ids) + ) if not permission_ids: return [] diff --git a/src/conode/infrastructure/repositories/role.py b/src/conode/infrastructure/repositories/role.py index 1a7524f..52031c7 100644 --- a/src/conode/infrastructure/repositories/role.py +++ b/src/conode/infrastructure/repositories/role.py @@ -32,7 +32,7 @@ async def create(self, role: Role) -> None: async def get_all_by_ids(self, roles_ids: list[RoleId]) -> list[Role]: logger.debug( - "Repository get roles by roles id", + "Repository get roles by ids", request_count=len(roles_ids), ) if not roles_ids: @@ -41,7 +41,7 @@ async def get_all_by_ids(self, roles_ids: list[RoleId]) -> list[Role]: result = await self.session.execute(select(Role).where(Role.id.in_(roles_ids))) # type: ignore roles = list(result.scalars().all()) logger.debug( - "Repository fetched roles by role id", + "Repository fetched roles by ids", found_count=len(roles), ) diff --git a/src/conode/infrastructure/repositories/user.py b/src/conode/infrastructure/repositories/user.py index b230a87..3e29df7 100644 --- a/src/conode/infrastructure/repositories/user.py +++ b/src/conode/infrastructure/repositories/user.py @@ -82,6 +82,7 @@ async def get_by_username_or_email( user = result.scalar_one_or_none() logger.debug( "Repository fetched user by username or email", + found=user is not None, ) return user