From 6cbfeca4c4d8bcf7ebea0292e76802f7e37ad196 Mon Sep 17 00:00:00 2001 From: Shahar Glazner Date: Tue, 1 Oct 2024 16:34:09 +0300 Subject: [PATCH] feat(auth): oauth2proxy (#2014) Co-authored-by: Tal --- .gitignore | 3 + .../authentication/oauth2proxy-auth.mdx | 32 ++++ docs/deployment/authentication/overview.mdx | 14 +- docs/mint.json | 3 +- keep-ui/pages/api/auth/[...nextauth].ts | 1 + keep-ui/utils/authenticationType.ts | 1 + keep/api/api.py | 15 +- keep/api/config.py | 19 ++- keep/api/core/config.py | 8 - keep/api/core/db.py | 32 ++++ keep/api/core/db_on_start.py | 8 +- keep/api/routes/settings.py | 64 +------- .../identity_managers/db/db_authverifier.py | 1 + .../identity_managers/oauth2proxy/__init__.py | 0 .../oauth2proxy/oauth2proxy_authverifier.py | 152 ++++++++++++++++++ .../oauth2proxy_identitymanager.py | 38 +++++ .../identitymanager/identitymanagerfactory.py | 1 + keep/workflowmanager/workflowmanager.py | 25 +-- pyproject.toml | 2 +- tests/test_auth.py | 74 +++++++++ 20 files changed, 391 insertions(+), 102 deletions(-) create mode 100644 docs/deployment/authentication/oauth2proxy-auth.mdx create mode 100644 keep/identitymanager/identity_managers/oauth2proxy/__init__.py create mode 100644 keep/identitymanager/identity_managers/oauth2proxy/oauth2proxy_authverifier.py create mode 100644 keep/identitymanager/identity_managers/oauth2proxy/oauth2proxy_identitymanager.py diff --git a/.gitignore b/.gitignore index 57a86b596..4ff2fd406 100644 --- a/.gitignore +++ b/.gitignore @@ -198,6 +198,8 @@ tempo-data/ # docs docs/node_modules/ +oauth2.cfg + scripts/automatic_extraction_rules.py @@ -209,3 +211,4 @@ ee/experimental/ai_temp/* oauth2.cfg scripts/keep_slack_bot.py +keepnew.db diff --git a/docs/deployment/authentication/oauth2proxy-auth.mdx b/docs/deployment/authentication/oauth2proxy-auth.mdx new file mode 100644 index 000000000..dba5d87ad --- /dev/null +++ b/docs/deployment/authentication/oauth2proxy-auth.mdx @@ -0,0 +1,32 @@ +--- +title: "OAuth2Proxy Authentication" +--- + +Delegate authentication to Oauth2Proxy. + + +### When to Use + +- **oauth2-proxy user:** Use this authentication method if you want to delegate authentication to an external Oauth2Proxy service. + +### Setup Instructions + +To start Keep with Oauth2Proxy authentication, set the following environment variables: + +#### Frontend Environment Variables + +| Environment Variable | Description | Required | Default Value | +|--------------------|-----------|:--------:|:-------------:| +| AUTH_TYPE | Set to 'OAUTH2PROXY' for OAUTH2PROXY authentication | Yes | - | + +#### Backend Environment Variables + +| Environment Variable | Description | Required | Default Value | +|--------------------|-----------|:--------:|:-------------:| +| AUTH_TYPE | Set to 'OAUTH2PROXY' for OAUTH2PROXY authentication | Yes | - | +| KEEP_OAUTH2_PROXY_USER_HEADER | Header for the authenticated user's email | Yes | x-forwarded-email | +| KEEP_OAUTH2_PROXY_ROLE_HEADER | Header for the authenticated user's role | Yes | x-forwarded-groups | +| KEEP_OAUTH2_PROXY_AUTO_CREATE_USER | Automatically create user if not exists | No | true | +| KEEP_OAUTH2_PROXY_ADMIN_ROLE | Role name for admin users | No | admin | +| KEEP_OAUTH2_PROXY_NOC_ROLE | Role name for NOC (Network Operations Center) users | No | noc | +| KEEP_OAUTH2_PROXY_WEBHOOK_ROLE | Role name for webhook users | No | webhook | diff --git a/docs/deployment/authentication/overview.mdx b/docs/deployment/authentication/overview.mdx index 0a234e6dd..3ce52bfda 100644 --- a/docs/deployment/authentication/overview.mdx +++ b/docs/deployment/authentication/overview.mdx @@ -20,12 +20,13 @@ Choosing the right authentication strategy depends on your specific use case, se ### Authentication Features Comparison -| Identity Provider | RBAC | SAML/OIDC | SSO | LDAP | Resource-based permission | User Management | Group Management | On Prem | License | -|:---:|:----:|:---------:|:---:|:----:|:-------------------------:|:----------------:|:-----------------:|:-------:|:-------:| -| **No Auth** | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | **OSS** | -| **DB** | ✅
(Predefiend roles) | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ | **OSS** | -| **Auth0** | ✅
(Predefiend roles) | ✅ | ✅ | 🚧 | 🚧 | ✅ | 🚧 | ❌ | **EE** | -| **Keycloak** | ✅
(Custom roles) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | **EE** | +| Identity Provider | RBAC | SAML/OIDC/SSO | LDAP | Resource-based permission | User Management | Group Management | On Prem | License | +|:---:|:----:|:---------:|:----:|:-------------------------:|:----------------:|:-----------------:|:-------:|:-------:| +| **No Auth** | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | **OSS** | +| **DB** | ✅
(Predefiend roles) | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ | **OSS** | +| **Auth0** | ✅
(Predefiend roles) | ✅ | 🚧 | 🚧 | ✅ | 🚧 | ❌ | **EE** | +| **Keycloak** | ✅
(Custom roles) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | **EE** | +| **Oauth2Proxy** | ✅
(Predefiend roles) | ✅ | ❌ | ❌ | N/A | N/A | ✅ | **OSS** | ### How To Configure Some authentication providers require additional environment variables. These will be covered in detail on the specific authentication provider pages. @@ -39,5 +40,6 @@ The authentication scheme on Keep is controlled with environment variables both | **DB** | `AUTH_TYPE=DB` | `KEEP_JWT_SECRET` | | **Auth0** | `AUTH_TYPE=AUTH0` | `AUTH0_DOMAIN`, `AUTH0_CLIENT_ID`, `AUTH0_CLIENT_SECRET` | | **Keycloak** | `AUTH_TYPE=KEYCLOAK` | `KEYCLOAK_URL`, `KEYCLOAK_REALM`, `KEYCLOAK_CLIENT_ID`, `KEYCLOAK_CLIENT_SECRET` | +| **Oauth2Proxy** | `AUTH_TYPE=OAUTH2PROXY` | `OAUTH2_PROXY_USER_HEADER`, `OAUTH2_PROXY_ROLE_HEADER`, `OAUTH2_PROXY_AUTO_CREATE_USER` | For more details on each authentication strategy, including setup instructions and implications, refer to the respective sections. diff --git a/docs/mint.json b/docs/mint.json index 3ad4297b5..dad519a53 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -63,7 +63,8 @@ "deployment/authentication/no-auth", "deployment/authentication/db-auth", "deployment/authentication/auth0-auth", - "deployment/authentication/keycloak-auth" + "deployment/authentication/keycloak-auth", + "deployment/authentication/oauth2proxy-auth" ] }, { diff --git a/keep-ui/pages/api/auth/[...nextauth].ts b/keep-ui/pages/api/auth/[...nextauth].ts index 889df6f85..1b143751a 100644 --- a/keep-ui/pages/api/auth/[...nextauth].ts +++ b/keep-ui/pages/api/auth/[...nextauth].ts @@ -331,6 +331,7 @@ export const authOptions = ? singleTenantAuthOptions : authType === AuthenticationType.KEYCLOAK ? keycloakAuthOptions + // oauth2proxy same configuration as noauth : noAuthOptions; export default NextAuth(authOptions); diff --git a/keep-ui/utils/authenticationType.ts b/keep-ui/utils/authenticationType.ts index 6f17eddb0..4cee9c542 100644 --- a/keep-ui/utils/authenticationType.ts +++ b/keep-ui/utils/authenticationType.ts @@ -4,6 +4,7 @@ export enum AuthenticationType { AUTH0 = "AUTH0", DB = "DB", KEYCLOAK = "KEYCLOAK", + OAUTH2PROXY = "OAUTH2PROXY", NOAUTH = "NOAUTH" // Default } diff --git a/keep/api/api.py b/keep/api/api.py index 4e4b3d939..33e195f2b 100644 --- a/keep/api/api.py +++ b/keep/api/api.py @@ -29,7 +29,6 @@ KEEP_ARQ_TASK_POOL_BASIC_PROCESSING, KEEP_ARQ_TASK_POOL_NONE, ) -from keep.api.core.config import AuthenticationType from keep.api.core.db import get_api_key from keep.api.core.dependencies import SINGLE_TENANT_UUID from keep.api.logging import CONFIG as logging_config @@ -59,7 +58,10 @@ from keep.api.routes.auth import groups as auth_groups from keep.api.routes.auth import permissions, roles, users from keep.event_subscriber.event_subscriber import EventSubscriber -from keep.identitymanager.identitymanagerfactory import IdentityManagerFactory +from keep.identitymanager.identitymanagerfactory import ( + IdentityManagerFactory, + IdentityManagerTypes, +) from keep.posthog.posthog import get_posthog_client # load all providers into cache @@ -77,7 +79,7 @@ SCHEDULER = os.environ.get("SCHEDULER", "true") == "true" CONSUMER = os.environ.get("CONSUMER", "true") == "true" -AUTH_TYPE = os.environ.get("AUTH_TYPE", AuthenticationType.NO_AUTH.value) +AUTH_TYPE = os.environ.get("AUTH_TYPE", IdentityManagerTypes.NOAUTH.value).lower() PROVISION_RESOURCES = os.environ.get("PROVISION_RESOURCES", "true") == "true" try: KEEP_VERSION = metadata.version("keep") @@ -178,7 +180,7 @@ async def dispatch(self, request: Request, call_next): def get_app( - auth_type: AuthenticationType = AuthenticationType.NO_AUTH.value, + auth_type: IdentityManagerTypes = IdentityManagerTypes.NOAUTH.value, ) -> FastAPI: if not os.environ.get("KEEP_API_URL", None): os.environ["KEEP_API_URL"] = f"http://{HOST}:{PORT}" @@ -344,6 +346,11 @@ async def log_middleware(request: Request, call_next): f"Request started: {request.method} {request.url.path}", extra={"tenant_id": identity}, ) + + # for debugging purposes, log the payload + if os.environ.get("LOG_AUTH_PAYLOAD", "false") == "true": + logger.info(f"Request headers: {request.headers}") + start_time = time.time() request.state.tenant_id = identity response = await call_next(request) diff --git a/keep/api/config.py b/keep/api/config.py index 13247844e..5a793a308 100644 --- a/keep/api/config.py +++ b/keep/api/config.py @@ -3,9 +3,9 @@ import keep.api.logging from keep.api.api import AUTH_TYPE -from keep.api.core.config import AuthenticationType from keep.api.core.db_on_start import migrate_db, try_create_single_tenant from keep.api.core.dependencies import SINGLE_TENANT_UUID +from keep.identitymanager.identitymanagerfactory import IdentityManagerTypes PORT = int(os.environ.get("PORT", 8080)) @@ -16,15 +16,24 @@ def on_starting(server=None): """This function is called by the gunicorn server when it starts""" logger.info("Keep server starting") - + migrate_db() # Create single tenant if it doesn't exist if AUTH_TYPE in [ - AuthenticationType.SINGLE_TENANT.value, - AuthenticationType.NO_AUTH.value, + IdentityManagerTypes.DB.value, + IdentityManagerTypes.NOAUTH.value, + IdentityManagerTypes.OAUTH2PROXY.value, + "no_auth", # backwards compatibility + "single_tenant", # backwards compatibility ]: - try_create_single_tenant(SINGLE_TENANT_UUID) + # for oauth2proxy, we don't want to create the default user + try_create_single_tenant( + SINGLE_TENANT_UUID, + create_default_user=( + False if AUTH_TYPE == IdentityManagerTypes.OAUTH2PROXY.value else True + ), + ) if os.environ.get("USE_NGROK", "false") == "true": from pyngrok import ngrok diff --git a/keep/api/core/config.py b/keep/api/core/config.py index ba5b9c909..b141537e8 100644 --- a/keep/api/core/config.py +++ b/keep/api/core/config.py @@ -1,5 +1,4 @@ import pathlib -from enum import Enum from starlette.config import Config @@ -10,10 +9,3 @@ config = Config(BASE_DIR / ".env") except FileNotFoundError: config = Config() - - -class AuthenticationType(Enum): - MULTI_TENANT = "MULTI_TENANT" - SINGLE_TENANT = "SINGLE_TENANT" - KEYCLOAK = "KEYCLOAK" - NO_AUTH = "NO_AUTH" diff --git a/keep/api/core/db.py b/keep/api/core/db.py index b3c718a3b..bc694c3d3 100644 --- a/keep/api/core/db.py +++ b/keep/api/core/db.py @@ -1421,6 +1421,38 @@ def create_user(tenant_id, username, password, role): return user +def update_user_last_sign_in(tenant_id, username): + from keep.api.models.db.user import User + + with Session(engine) as session: + user = session.exec( + select(User) + .where(User.tenant_id == tenant_id) + .where(User.username == username) + ).first() + if user: + user.last_sign_in = datetime.utcnow() + session.add(user) + session.commit() + return user + + +def update_user_role(tenant_id, username, role): + from keep.api.models.db.user import User + + with Session(engine) as session: + user = session.exec( + select(User) + .where(User.tenant_id == tenant_id) + .where(User.username == username) + ).first() + if user and user.role != role: + user.role = role + session.add(user) + session.commit() + return user + + def save_workflow_results(tenant_id, workflow_execution_id, workflow_results): with Session(engine) as session: workflow_execution = session.exec( diff --git a/keep/api/core/db_on_start.py b/keep/api/core/db_on_start.py index b65dc698d..8081ce31b 100644 --- a/keep/api/core/db_on_start.py +++ b/keep/api/core/db_on_start.py @@ -42,7 +42,7 @@ engine = create_db_engine() -def try_create_single_tenant(tenant_id: str) -> None: +def try_create_single_tenant(tenant_id: str, create_default_user=True) -> None: """ Creates the single tenant and the default user if they don't exist. """ @@ -71,7 +71,7 @@ def try_create_single_tenant(tenant_id: str) -> None: # check if at least one user exists: user = session.exec(select(User)).first() # if no users exist, let's create the default user - if not user: + if not user and create_default_user: logger.info("Creating default user") default_username = os.environ.get("KEEP_DEFAULT_USERNAME", "keep") default_password = hashlib.sha256( @@ -150,7 +150,7 @@ def try_create_single_tenant(tenant_id: str) -> None: pass logger.info(f"Api key {api_key_name} provisioned") logger.info("Api keys provisioned") - + # commit the changes session.commit() logger.info("Single tenant created") @@ -181,4 +181,4 @@ def migrate_db(): os.path.dirname(os.path.abspath(__file__)) + "/../models/db/migrations", ) alembic.command.upgrade(config, "head") - logger.info("Finished migrations") \ No newline at end of file + logger.info("Finished migrations") diff --git a/keep/api/routes/settings.py b/keep/api/routes/settings.py index 2eb760602..f6bc88e28 100644 --- a/keep/api/routes/settings.py +++ b/keep/api/routes/settings.py @@ -1,7 +1,6 @@ import io import json import logging -import os import smtplib from email.mime.text import MIMEText from typing import Optional, Tuple @@ -11,11 +10,10 @@ from pydantic import BaseModel, Field from sqlmodel import Session -from keep.api.core.config import AuthenticationType, config +from keep.api.core.config import config from keep.api.core.db import get_session from keep.api.models.alert import AlertDto from keep.api.models.smtp import SMTPSettings -from keep.api.models.user import User from keep.api.models.webhook import WebhookSettings from keep.api.utils.tenant_utils import ( create_api_key, @@ -35,8 +33,6 @@ logger = logging.getLogger(__name__) -auth_type = os.environ.get("AUTH_TYPE", AuthenticationType.NO_AUTH.value) - class CreateUserRequest(BaseModel): email: str = Field(alias="username") @@ -83,63 +79,6 @@ def webhook_settings( ) -@router.get("/users", description="Get all users") -def get_users( - authenticated_entity: AuthenticatedEntity = Depends( - IdentityManagerFactory.get_auth_verifier(["read:settings"]) - ), -) -> list[User]: - tenant_id = authenticated_entity.tenant_id - identity_manager = IdentityManagerFactory.get_identity_manager( - tenant_id=tenant_id, - identity_manager_type=auth_type, - context_manager=ContextManager(tenant_id=tenant_id), - ) - users = identity_manager.get_users() - return users - - -@router.delete("/users/{user_email}", description="Delete a user") -def delete_user( - user_email: str, - authenticated_entity: AuthenticatedEntity = Depends( - IdentityManagerFactory.get_auth_verifier(["delete:settings"]) - ), -): - tenant_id = authenticated_entity.tenant_id - identity_manager = IdentityManagerFactory.get_identity_manager( - tenant_id=tenant_id, - identity_manager_type=auth_type, - context_manager=ContextManager(tenant_id=tenant_id), - ) - - return identity_manager.delete_user(user_email) - - -@router.post("/users", description="Create a user") -async def create_user( - request_data: CreateUserRequest, - authenticated_entity: AuthenticatedEntity = Depends( - IdentityManagerFactory.get_auth_verifier(["write:settings"]) - ), -): - tenant_id = authenticated_entity.tenant_id - user_email = request_data.email - password = request_data.password - role = request_data.role - - if not user_email: - raise HTTPException(status_code=400, detail="Email is required") - - identity_manager = IdentityManagerFactory.get_identity_manager( - tenant_id=tenant_id, - identity_manager_type=auth_type, - context_manager=ContextManager(tenant_id=tenant_id), - ) - user = identity_manager.create_user(user_email, password, role) - return user - - @router.post("/smtp", description="Install or update SMTP settings") async def update_smtp_settings( smtp_settings: SMTPSettings = Body(...), @@ -430,7 +369,6 @@ async def get_sso_settings( ): identity_manager = IdentityManagerFactory.get_identity_manager( tenant_id=authenticated_entity.tenant_id, - identity_manager_type=auth_type, context_manager=ContextManager(tenant_id=authenticated_entity.tenant_id), ) diff --git a/keep/identitymanager/identity_managers/db/db_authverifier.py b/keep/identitymanager/identity_managers/db/db_authverifier.py index 52305fcce..f2d3e5b9c 100644 --- a/keep/identitymanager/identity_managers/db/db_authverifier.py +++ b/keep/identitymanager/identity_managers/db/db_authverifier.py @@ -17,6 +17,7 @@ def _verify_bearer_token(self, token: str) -> AuthenticatedEntity: # validate the token jwt_secret = os.environ.get("KEEP_JWT_SECRET") if not jwt_secret: + self.logger.warning("missing KEEP_JWT_SECRET environment variable") raise HTTPException(status_code=401, detail="Missing JWT secret") try: diff --git a/keep/identitymanager/identity_managers/oauth2proxy/__init__.py b/keep/identitymanager/identity_managers/oauth2proxy/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/keep/identitymanager/identity_managers/oauth2proxy/oauth2proxy_authverifier.py b/keep/identitymanager/identity_managers/oauth2proxy/oauth2proxy_authverifier.py new file mode 100644 index 000000000..55ef8baf4 --- /dev/null +++ b/keep/identitymanager/identity_managers/oauth2proxy/oauth2proxy_authverifier.py @@ -0,0 +1,152 @@ +from typing import Optional + +from fastapi import HTTPException, Request +from fastapi.security import HTTPAuthorizationCredentials + +from keep.api.core.config import config +from keep.api.core.db import ( + create_user, + update_user_last_sign_in, + update_user_role, + user_exists, +) +from keep.api.core.dependencies import SINGLE_TENANT_UUID +from keep.identitymanager.authenticatedentity import AuthenticatedEntity +from keep.identitymanager.authverifierbase import AuthVerifierBase +from keep.identitymanager.rbac import get_role_by_role_name + + +class Oauth2proxyAuthVerifier(AuthVerifierBase): + """Handles authentication and authorization for single tenant mode""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.oauth2_proxy_user_header = config( + "KEEP_OAUTH2_PROXY_USER_HEADER", default="x-forwarded-email" + ) + self.oauth2_proxy_role_header = config( + "KEEP_OAUTH2_PROXY_ROLE_HEADER", default="x-forwarded-groups" + ) + self.auto_create_user = config( + "KEEP_OAUTH2_PROXY_AUTO_CREATE_USER", default=True + ) + self.role_mappings = { + config("KEEP_OAUTH2_PROXY_ADMIN_ROLE", default=""): "admin", + config("KEEP_OAUTH2_PROXY_NOC_ROLE", default=""): "noc", + config("KEEP_OAUTH2_PROXY_WEBHOOK_ROLE", default=""): "webhook", + } + self.logger.info("Oauth2proxy Auth Verifier initialized") + + def authenticate( + self, + request: Request, + api_key: str, + authorization: Optional[HTTPAuthorizationCredentials], + token: Optional[str], + ) -> AuthenticatedEntity: + + # https://github.com/keephq/keep/issues/1203 + # get user name + self.logger.info( + f"Authenticating user with {self.oauth2_proxy_user_header} header" + ) + user_name = request.headers.get(self.oauth2_proxy_user_header) + + if not user_name: + raise HTTPException( + status_code=401, + detail=f"Unauthorized - no user in {self.oauth2_proxy_user_header} header found", + ) + + role = request.headers.get(self.oauth2_proxy_role_header) + if not role: + raise HTTPException( + status_code=401, + detail=f"Unauthorized - no role in {self.oauth2_proxy_role_header} header found", + ) + + # else, if its a list seperated by comma e.g. org:admin, org:foobar or role:admin, role:foobar + if "," in role: + # split the roles by comma + roles = role.split(",") + # trim + roles = [r.strip() for r in roles] + else: + roles = [role] + + # Define the priority order of roles + role_priority = ["admin", "noc", "webhook"] + + mapped_role = None + for priority_role in role_priority: + self.logger.debug(f"Checking for role {priority_role}") + for role in roles: + self.logger.debug(f"Checking for role {role}") + # map the role if its a mapped one, or just use the role + mapped_role_name = self.role_mappings.get(role, role) + self.logger.debug(f"Checking for mapped role {mapped_role_name}") + if mapped_role_name == priority_role: + try: + self.logger.debug(f"Getting role {mapped_role_name}") + mapped_role = get_role_by_role_name(mapped_role_name) + self.logger.debug(f"Role {mapped_role_name} found") + break + except HTTPException: + self.logger.debug(f"Role {mapped_role_name} not found") + continue + if mapped_role: + self.logger.debug(f"Role {mapped_role_name} found") + break + + # if no valid role was found, throw a 403 exception + if not mapped_role: + self.logger.debug(f"No valid role found among {roles}") + raise HTTPException( + status_code=403, + detail=f"No valid role found among {roles}", + ) + + # auto provision user + if self.auto_create_user and not user_exists( + tenant_id=SINGLE_TENANT_UUID, username=user_name + ): + self.logger.info(f"Auto provisioning user: {user_name}") + create_user( + tenant_id=SINGLE_TENANT_UUID, + username=user_name, + role=mapped_role.get_name(), + password="", + ) + self.logger.info(f"User {user_name} created") + elif user_exists(tenant_id=SINGLE_TENANT_UUID, username=user_name): + # update last login + self.logger.debug(f"Updating last login for user: {user_name}") + try: + update_user_last_sign_in( + tenant_id=SINGLE_TENANT_UUID, username=user_name + ) + self.logger.debug(f"Last login updated for user: {user_name}") + except Exception: + self.logger.warning( + f"Failed to update last login for user: {user_name}" + ) + pass + # update role + self.logger.debug(f"Updating role for user: {user_name}") + try: + update_user_role( + tenant_id=SINGLE_TENANT_UUID, + username=user_name, + role=mapped_role.get_name(), + ) + self.logger.debug(f"Role updated for user: {user_name}") + except Exception: + self.logger.warning(f"Failed to update role for user: {user_name}") + pass + + self.logger.info(f"User {user_name} authenticated with role {mapped_role}") + return AuthenticatedEntity( + tenant_id=SINGLE_TENANT_UUID, + email=user_name, + role=mapped_role.get_name(), + ) diff --git a/keep/identitymanager/identity_managers/oauth2proxy/oauth2proxy_identitymanager.py b/keep/identitymanager/identity_managers/oauth2proxy/oauth2proxy_identitymanager.py new file mode 100644 index 000000000..0b510e625 --- /dev/null +++ b/keep/identitymanager/identity_managers/oauth2proxy/oauth2proxy_identitymanager.py @@ -0,0 +1,38 @@ +from keep.api.core.db import get_users as get_users_from_db +from keep.api.models.user import User +from keep.contextmanager.contextmanager import ContextManager +from keep.identitymanager.identity_managers.oauth2proxy.oauth2proxy_authverifier import ( + Oauth2proxyAuthVerifier, +) +from keep.identitymanager.identitymanager import BaseIdentityManager + + +class Oauth2proxyIdentityManager(BaseIdentityManager): + def __init__(self, tenant_id, context_manager: ContextManager, **kwargs): + super().__init__(tenant_id, context_manager, **kwargs) + self.logger.info("Oauth2 proxy Identity Manager initialized") + + def get_users(self) -> list[User]: + users = get_users_from_db() + users = [ + User( + email=f"{user.username}", + name=user.username, + role=user.role, + last_login=str(user.last_sign_in) if user.last_sign_in else None, + created_at=str(user.created_at), + ) + for user in users + ] + return users + + def get_auth_verifier(self, scopes) -> Oauth2proxyAuthVerifier: + return Oauth2proxyAuthVerifier(scopes) + + # Not implemented + def create_user(self, **kawrgs) -> User: + return None + + # Not implemented + def delete_user(self, **kwargs) -> User: + return None diff --git a/keep/identitymanager/identitymanagerfactory.py b/keep/identitymanager/identitymanagerfactory.py index 1ad7ee62c..acd2387dd 100644 --- a/keep/identitymanager/identitymanagerfactory.py +++ b/keep/identitymanager/identitymanagerfactory.py @@ -18,6 +18,7 @@ class IdentityManagerTypes(enum.Enum): KEYCLOAK = "keycloak" DB = "db" NOAUTH = "noauth" + OAUTH2PROXY = "oauth2proxy" class IdentityManagerFactory: diff --git a/keep/workflowmanager/workflowmanager.py b/keep/workflowmanager/workflowmanager.py index acbdb286b..3cfdf5b72 100644 --- a/keep/workflowmanager/workflowmanager.py +++ b/keep/workflowmanager/workflowmanager.py @@ -6,13 +6,14 @@ from pandas.core.common import flatten -from keep.api.core.config import AuthenticationType, config +from keep.api.core.config import config from keep.api.core.db import ( get_enrichment, get_previous_alert_by_fingerprint, save_workflow_results, ) from keep.api.models.alert import AlertDto, AlertSeverity, IncidentDto +from keep.identitymanager.identitymanagerfactory import IdentityManagerTypes from keep.providers.providers_factory import ProviderConfigurationException from keep.workflowmanager.workflow import Workflow from keep.workflowmanager.workflowscheduler import WorkflowScheduler @@ -74,9 +75,7 @@ def _get_workflow_from_store(self, tenant_id, workflow_model): try: # get the actual workflow that can be triggered self.logger.info("Getting workflow from store") - workflow = self.workflow_store.get_workflow( - tenant_id, workflow_model.id - ) + workflow = self.workflow_store.get_workflow(tenant_id, workflow_model.id) self.logger.info("Got workflow from store") return workflow except ProviderConfigurationException: @@ -117,11 +116,17 @@ def insert_incident(self, tenant_id: str, incident: IncidentDto, trigger: str): continue incident_triggers = flatten( - [t.get("events", []) for t in workflow.workflow_triggers if t["type"] == "incident"] + [ + t.get("events", []) + for t in workflow.workflow_triggers + if t["type"] == "incident" + ] ) if trigger not in incident_triggers: - self.logger.debug("workflow does not contain trigger %s, skipping", trigger) + self.logger.debug( + "workflow does not contain trigger %s, skipping", trigger + ) continue self.logger.info("Adding workflow to run") @@ -369,10 +374,10 @@ def _check_premium_providers(self, workflow: Workflow): Raises: Exception: If the workflow uses premium providers in multi tenant mode. """ - if ( - os.environ.get("AUTH_TYPE", AuthenticationType.NO_AUTH.value) - == AuthenticationType.MULTI_TENANT.value - ): + if os.environ.get("AUTH_TYPE", IdentityManagerTypes.NOAUTH.value) in ( + IdentityManagerTypes.AUTH0.value, + "MULTI_TENANT", + ): # backward compatibility for provider in workflow.workflow_providers_type: if provider in self.PREMIUM_PROVIDERS: raise Exception( diff --git a/pyproject.toml b/pyproject.toml index 0ccc9b1de..cd1781363 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "keep" -version = "0.25.1" +version = "0.25.2" description = "Alerting. for developers, by developers." authors = ["Keep Alerting LTD"] readme = "README.md" diff --git a/tests/test_auth.py b/tests/test_auth.py index f0c506137..b8af7371c 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -304,3 +304,77 @@ def test_api_key_impersonation_provisioned_user_cant_login( ) assert response.status_code == 401 assert response.json()["message"] == "Empty password" + + +@pytest.mark.parametrize( + "test_app", + [ + { + "AUTH_TYPE": "OAUTH2PROXY", + "KEEP_OAUTH2_PROXY_USER_HEADER": "x-forwarded-email", + "KEEP_OAUTH2_PROXY_USER_ROLE": "x-forwarded-groups", + }, + ], + indirect=True, +) +def test_oauth_proxy(db_session, client, test_app): + """Tests the API key impersonation with different environment settings""" + response = client.post( + "/auth/users", + headers={ + "x-forwarded-email": "shahar", + "x-forwarded-groups": "noc,admin", + }, + json={"email": "shahar", "role": "admin"}, + ) + # admin role should be able to create users + assert response.status_code == 200 + + response = client.post( + "/auth/users", + headers={ + "x-forwarded-email": "shahar", + "x-forwarded-groups": "noc", + }, + json={"email": "shahar", "role": "admin"}, + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize( + "test_app", + [ + { + "AUTH_TYPE": "OAUTH2PROXY", + "KEEP_OAUTH2_PROXY_USER_HEADER": "x-forwarded-email", + "KEEP_OAUTH2_PROXY_USER_ROLE": "X-Forwarded-Groups", + "KEEP_OAUTH2_PROXY_ADMIN_ROLE": "team-platform@example.com", + "KEEP_OAUTH2_PROXY_NOC_ROLE": "dept-engineering-product@example.com", + "KEEP_OAUTH2_PROXY_WEBHOOK_ROLE": "foo@example.com", + "KEEP_OAUTH2_PROXY_AUTO_CREATE_USER": "true", + }, + ], + indirect=True, +) +def test_oauth_proxy2(db_session, client, test_app): + """Tests the oauth2proxy impersonation with different environment settings""" + response = client.post( + "/auth/users", + headers={ + "x-forwarded-email": "shahar", + "x-forwarded-groups": "all@example.com,aws@example.com,dept-engineering-product@example.com,team-platform@example.com", + }, + json={"email": "shahar", "role": "admin"}, + ) + # admin role should be able to create users, noc would fail + assert response.status_code == 200 + + response = client.post( + "/auth/users", + headers={ + "x-forwarded-email": "shahar", + "x-forwarded-groups": "dept-engineering-product@example.com,foo@example.com", + }, + json={"email": "shahar", "role": "admin"}, + ) + assert response.status_code == 403